Raise online play to 16-player lobbies and 8-player games

Lobbies hold 16 players and the sandbox 16 fighters, games up to 8. The arena
gets spawns 5-8 (outer ledges and middle platforms) and the race start eight
places. The sandbox adds eight more spawns, each the open spot farthest from
the rest. Rival cards wrap into rows of four, the lobby's player list goes to
two tiny columns when crowded, and the server keeps frame packets to one
datagram. Formatted with clang-format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 02:33:20 -05:00
1 parent 56af1c357c
commit 6003175173
18 files changed
+188 -81

No files matched your search

+18 -9
View File
@@ -37,7 +37,8 @@ constexpr const char INPUTS_LABEL[] = "inputs";
constexpr size_t MESSAGE_MAX = 65536; constexpr size_t MESSAGE_MAX = 65536;
constexpr int CODE_TRIES = 100; constexpr int CODE_TRIES = 100;
constexpr int FRAME_RING = 256; constexpr int FRAME_RING = 256;
constexpr int FRAMES_PER_PACKET = 30; // Frames go out a packet of at most this many bytes at a time, so a lossy one fits one datagram and is never split.
constexpr size_t FRAME_BYTES_MAX = 1100;
constexpr int INPUT_REACH = 120; constexpr int INPUT_REACH = 120;
constexpr int CHECKSUM_INTERVAL = 60; constexpr int CHECKSUM_INTERVAL = 60;
constexpr int CATCH_UP_TICKS = 5; constexpr int CATCH_UP_TICKS = 5;
@@ -66,9 +67,10 @@ struct Room
int tick = 0; int tick = 0;
}; };
// Someone connected: their name, socket and WebRTC connection (with its reliable channel and lossy one), their lobby (empty until they make or join one), the room they play in // Someone connected: their name, socket and WebRTC connection (with its reliable channel and lossy one), their lobby
// (0 for none), the last tick of it they've confirmed, how early their controls last came, and the fighter they // (empty until they make or join one), the room they play in (0 for none), the last tick of it they've confirmed, how
// brought, kept while they're away in a game so they come back to the sandbox as they left. // early their controls last came, and the fighter they brought, kept while they're away in a game so they come back to
// the sandbox as they left.
struct Member struct Member
{ {
Profile profile; Profile profile;
@@ -135,8 +137,7 @@ static void send(int id, const std::string& message, bool isBinary = false, bool
member.socket->send(message); member.socket->send(message);
} }
catch (const std::exception&) catch (const std::exception&)
{ {}
}
} }
// Answers a member's WebRTC offer with a connection of the server's own, passing its description and candidates back // Answers a member's WebRTC offer with a connection of the server's own, passing its description and candidates back
@@ -606,7 +607,7 @@ static void game_follow(int serial)
rooms.erase(serial); rooms.erase(serial);
} }
// Sends everyone in a room the frames they haven't confirmed (up to a packet's worth), and every so often the state's // Sends everyone in a room the frames they haven't confirmed (as many as fit a packet), and every so often the state's
// checksum. // checksum.
static void frames_send(int serial) static void frames_send(int serial)
{ {
@@ -616,7 +617,14 @@ static void frames_send(int serial)
if (member.room != serial) if (member.room != serial)
continue; continue;
int first = std::max(member.ack + 1, room.tick - FRAME_RING + 1); int first = std::max(member.ack + 1, room.tick - FRAME_RING + 1);
int count = std::min(room.tick - first, FRAMES_PER_PACKET); int count = 0;
for (size_t bytes = 0; first + count < room.tick; count++)
{
bytes +=
sizeof(Uint16) + sizeof(Uint8) + room.frames[(first + count) % FRAME_RING].count * sizeof(Controls);
if (bytes > FRAME_BYTES_MAX && count > 0)
break;
}
if (count <= 0) if (count <= 0)
continue; continue;
PacketWriter packet; PacketWriter packet;
@@ -633,7 +641,8 @@ static void frames_send(int serial)
if (room.tick % CHECKSUM_INTERVAL == 0) if (room.tick % CHECKSUM_INTERVAL == 0)
for (auto& [id, member] : members) for (auto& [id, member] : members)
if (member.room == serial) if (member.room == serial)
send(id, PacketWriter().put(PACKET_CHECKSUM).put(serial).put(room.tick).put(room.sim.checksum_get()).bytes, send(id,
PacketWriter().put(PACKET_CHECKSUM).put(serial).put(room.tick).put(room.sim.checksum_get()).bytes,
true); true);
} }
+1 -1
View File
@@ -13,7 +13,7 @@ struct Attack;
struct Sim; struct Sim;
constexpr int ECHO_MAX = 64; constexpr int ECHO_MAX = 64;
constexpr int MAX_FIGHTERS = SPAWN_COUNT; constexpr int MAX_FIGHTERS = SPAWN_MAX;
constexpr float BOUNCE_KEEP = 0.75f; constexpr float BOUNCE_KEEP = 0.75f;
constexpr float SECONDARY_SHADE = 0.5f; constexpr float SECONDARY_SHADE = 0.5f;
constexpr int COLOR_COUNT = 16; constexpr int COLOR_COUNT = 16;
+8 -1
View File
@@ -45,10 +45,17 @@ struct Game
Vec2 camera; Vec2 camera;
Vec2 cameraPrevious; Vec2 cameraPrevious;
float alpha = 1; float alpha = 1;
int devices[MAX_FIGHTERS] = {DEVICE_KEYBOARD, DEVICE_NONE, DEVICE_NONE, DEVICE_NONE}; int devices[MAX_FIGHTERS];
int local = 0; int local = 0;
SDL_Window* window = nullptr; SDL_Window* window = nullptr;
// This screen starts with the keyboard playing the first fighter.
Game()
{
std::fill(std::begin(devices), std::end(devices), DEVICE_NONE);
devices[0] = DEVICE_KEYBOARD;
}
Fighter& local_get() Fighter& local_get()
{ {
return sim.fighters[local]; return sim.fighters[local];
+11 -2
View File
@@ -1,6 +1,7 @@
#include "match.h" #include "match.h"
#include "sim.h" #include "sim.h"
#include <algorithm> #include <algorithm>
#include <numeric>
#include <cstring> #include <cstring>
constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40}; constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40};
@@ -69,6 +70,14 @@ static int level_get(ItemRules& items, int round)
return std::clamp(items.startLevel - 1 + (items.isLevelUp ? round : 0), 0, LEVEL_COUNT - 1); return std::clamp(items.startLevel - 1 + (items.isLevelUp ? round : 0), 0, LEVEL_COUNT - 1);
} }
// Fighters start in palette order, played by no one and in no queue.
Match::Match()
{
std::iota(std::begin(colors), std::end(colors), 0);
std::fill(std::begin(owners), std::end(owners), -1);
std::fill(std::begin(queuedIn), std::end(queuedIn), -1);
}
void Match::update(Sim& sim) void Match::update(Sim& sim)
{ {
timer -= TICK; timer -= TICK;
@@ -752,8 +761,8 @@ void Match::leaves_take(Sim& sim)
sim.cues->sound_play(SOUND_FINISH); sim.cues->sound_play(SOUND_FINISH);
} }
// A fighter's loadout, slots and levels as a profile, to bring it back as it was (a name and colour are added by whoever // A fighter's loadout, slots and levels as a profile, to bring it back as it was (a name and colour are added by
// knows them). // whoever knows them).
Profile profile_get(Fighter& fighter) Profile profile_get(Fighter& fighter)
{ {
Profile profile; Profile profile;
+5 -4
View File
@@ -6,7 +6,7 @@
struct Sim; struct Sim;
constexpr int BEST_OF_MAX = 9; constexpr int BEST_OF_MAX = 9;
constexpr int PLAYERS_MAX = 4; constexpr int PLAYERS_MAX = 8;
constexpr int PLAYERS_MIN = 2; constexpr int PLAYERS_MIN = 2;
constexpr int TIMER_MAX = 10; constexpr int TIMER_MAX = 10;
// A queued game's area: room per player, a margin each end, its height, how finely the ground's checked for one, and // A queued game's area: room per player, a margin each end, its height, how finely the ground's checked for one, and
@@ -195,17 +195,17 @@ public:
int round = 0; int round = 0;
int winner = -1; int winner = -1;
int wins[MAX_FIGHTERS] = {}; int wins[MAX_FIGHTERS] = {};
int colors[MAX_FIGHTERS] = {0, 1, 2, 3}; int colors[MAX_FIGHTERS];
// Online, which lobby member plays each fighter (-1 for none) and their name, kept with the game so every machine // Online, which lobby member plays each fighter (-1 for none) and their name, kept with the game so every machine
// (and a player joining) agrees who's who as fighters come and go. // (and a player joining) agrees who's who as fighters come and go.
int owners[MAX_FIGHTERS] = {-1, -1, -1, -1}; int owners[MAX_FIGHTERS];
char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1] = {}; char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1] = {};
bool isReady[MAX_FIGHTERS] = {}; bool isReady[MAX_FIGHTERS] = {};
// Online, in the lobby's sandbox: each fighter's queued game, which queue's area it's standing in (-1 for none), // Online, in the lobby's sandbox: each fighter's queued game, which queue's area it's standing in (-1 for none),
// and whether it's readied up there. // and whether it's readied up there.
// In a queued game, whether each fighter has left it (out for good), and whether that was before it was over. // In a queued game, whether each fighter has left it (out for good), and whether that was before it was over.
Queue queues[MAX_FIGHTERS]; Queue queues[MAX_FIGHTERS];
int queuedIn[MAX_FIGHTERS] = {-1, -1, -1, -1}; int queuedIn[MAX_FIGHTERS];
bool isQueueReady[MAX_FIGHTERS] = {}; bool isQueueReady[MAX_FIGHTERS] = {};
bool isGone[MAX_FIGHTERS] = {}; bool isGone[MAX_FIGHTERS] = {};
bool isForfeited[MAX_FIGHTERS] = {}; bool isForfeited[MAX_FIGHTERS] = {};
@@ -221,6 +221,7 @@ public:
bool isSandboxRequested = false; bool isSandboxRequested = false;
Fighter sandbox; Fighter sandbox;
Match();
void update(Sim& sim); void update(Sim& sim);
void start(Sim& sim); void start(Sim& sim);
void colors_assign(Sim& sim); void colors_assign(Sim& sim);
+28 -8
View File
@@ -32,6 +32,10 @@ constexpr float PLAY_HEIGHT = 22;
constexpr float RADIO_GAP = 12; constexpr float RADIO_GAP = 12;
constexpr float ROW_HEIGHT = 18; constexpr float ROW_HEIGHT = 18;
constexpr float TEXT_ROW_INSET = 1; constexpr float TEXT_ROW_INSET = 1;
// The lobby's player list, when crowded into two columns: the gap between its tiny rows, and the widest ping it leaves
// room for.
constexpr float COMPACT_ROW_GAP = 2;
constexpr const char* PING_SAMPLE = "999ms";
constexpr int LABEL_LENGTH = 64; constexpr int LABEL_LENGTH = 64;
constexpr float SCROLL_HEAD_MIN = 8; constexpr float SCROLL_HEAD_MIN = 8;
constexpr float SCROLL_WIDTH = 6; constexpr float SCROLL_WIDTH = 6;
@@ -661,23 +665,39 @@ void Menu::online_draw(Game& game, Rect area)
ui.widgets_enable(true); ui.widgets_enable(true);
y += ROW_HEIGHT; y += ROW_HEIGHT;
// Everyone in it, in the order they came (the first hosting), in their colours, with their connections. // Everyone in it, in the order they came (the first hosting), in their colours, with their connections. When they
// don't all fit down the page, they go in two columns in the tiny font, names cut short to fit.
ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Players"); ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Players");
y += ROW_HEIGHT; y += ROW_HEIGHT;
for (NetMember& member : net.members) int count = net.members.size();
bool isCompact = y + count * ROW_HEIGHT > area.y + area.h;
Font font = isCompact ? FONT_TINY : FONT_SMALL;
float rowHeight = isCompact ? text.line_height_get(FONT_TINY) + COMPACT_ROW_GAP : ROW_HEIGHT;
float inset = isCompact ? 0 : TEXT_ROW_INSET;
int rows = isCompact ? (count + 1) / 2 : count;
float columnWidth = isCompact ? std::floor((area.w - PADDING) / 2) : area.w;
float pingWidth = text.size_get(PING_SAMPLE).x + PADDING * 2;
for (int k = 0; k < count; k++)
{ {
NetMember& member = net.members[k];
bool isSelf = member.id == net.selfId; bool isSelf = member.id == net.selfId;
std::string name = session.isActive ? session.name_get(game, member.id) : member.name; std::string name = session.isActive ? session.name_get(game, member.id) : member.name;
SDL_snprintf(line, sizeof(line), "- %s%s%s", name.c_str(), member.id == net.hostId ? " [Host]" : "", const char* tags = member.id == net.hostId ? (isSelf ? " [Host] [You]" : " [Host]") : isSelf ? " [You]" : "";
isSelf ? " [You]" : ""); float room = columnWidth - (member.ping >= 0 ? pingWidth : 0);
SDL_snprintf(line, sizeof(line), "- %s%s", name.c_str(), tags);
while (text.size_get(line, font).x > room && !name.empty())
{
name.pop_back();
SDL_snprintf(line, sizeof(line), "- %s...%s", name.c_str(), tags);
}
int fighter = session.fighter_get(game, member.id); int fighter = session.fighter_get(game, member.id);
Color color = fighter >= 0 && game.sim.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint; Color color = fighter >= 0 && game.sim.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint;
text.draw(game.renderer, line, {.color = color, .position = {area.x, y + TEXT_ROW_INSET}}); Vec2 corner = {area.x + (k / rows) * (columnWidth + PADDING), y + (k % rows) * rowHeight + inset};
float x = area.x + text.size_get(line).x + PADDING * 2; text.draw(game.renderer, line, {.color = color, .position = corner}, font);
if (member.ping >= 0) if (member.ping >= 0)
ui.ping_draw(game, {x, y + TEXT_ROW_INSET}, member.ping, 1); ui.ping_draw(game, {corner.x + text.size_get(line, font).x + PADDING * 2, corner.y}, member.ping, 1);
y += ROW_HEIGHT;
} }
y += rows * rowHeight;
if (game.session.isDesynced) if (game.session.isDesynced)
status_draw("Out of sync with the lobby: rejoin to fix it", true); status_draw("Out of sync with the lobby: rejoin to fix it", true);
else if (game.session.isActive && !game.session.isJoined) else if (game.session.isActive && !game.session.isJoined)
+2 -2
View File
@@ -392,8 +392,8 @@ bool Net::is_linked()
return impl->link.is_open(); return impl->link.is_open();
} }
// Once a frame: handles what the network reported (on desktop), pings the server over the link, and on desktop keeps the // Once a frame: handles what the network reported (on desktop), pings the server over the link, and on desktop keeps
// server connection alive. // the server connection alive.
void Net::update() void Net::update()
{ {
std::vector<std::function<void()>> work; std::vector<std::function<void()>> work;
+1 -1
View File
@@ -28,7 +28,7 @@
#include <type_traits> #include <type_traits>
constexpr int PROTOCOL_VERSION = 3; constexpr int PROTOCOL_VERSION = 3;
constexpr int LOBBY_MEMBERS_MAX = 4; constexpr int LOBBY_MEMBERS_MAX = 16;
constexpr int LOBBY_CODE_LENGTH = 4; constexpr int LOBBY_CODE_LENGTH = 4;
// Letters only, without I and O, so codes are easy to read out and type. // Letters only, without I and O, so codes are easy to read out and type.
constexpr const char LOBBY_CODE_LETTERS[] = "ABCDEFGHJKLMNPQRSTUVWXYZ"; constexpr const char LOBBY_CODE_LETTERS[] = "ABCDEFGHJKLMNPQRSTUVWXYZ";
+6 -4
View File
@@ -125,7 +125,6 @@ struct Stroke
float jitter = 0; float jitter = 0;
}; };
// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Beams // A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Beams
// start as far along as they've travelled. Jitter kinks it at every piece, moving each frame so it crackles. // start as far along as they've travelled. Jitter kinks it at every piece, moving each frame so it crackles.
static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) static void strength_line_draw(Game& game, Attack& attack, Stroke stroke)
@@ -528,7 +527,8 @@ void Scene::world_draw()
} }
} }
// Debug view: bot.surfaces as lines, bot.links as thin lines from takeoff to landing, and each bot's remaining path in white. // Debug view: bot.surfaces as lines, bot.links as thin lines from takeoff to landing, and each bot's remaining path in
// white.
void Scene::bot_draw() void Scene::bot_draw()
{ {
Bot& bot = game.sim.bot; Bot& bot = game.sim.bot;
@@ -570,7 +570,8 @@ void Scene::fighter_draw(Fighter& fighter)
bool isLocal = fighter.index == game.local && game.devices[fighter.index] != DEVICE_NONE; bool isLocal = fighter.index == game.local && game.devices[fighter.index] != DEVICE_NONE;
if (isLocal && game.sim.match.is_racing()) if (isLocal && game.sim.match.is_racing())
for (int row = 0; row < CHECKPOINT_HEIGHT; row++) for (int row = 0; row < CHECKPOINT_HEIGHT; row++)
renderer.rect_draw({std::round(fighter.safe.x - BOX_WIDTH / 2), std::round(fighter.safe.y) - row - 1, BOX_WIDTH, 1}, renderer.rect_draw(
{std::round(fighter.safe.x - BOX_WIDTH / 2), std::round(fighter.safe.y) - row - 1, BOX_WIDTH, 1},
color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT))); color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT)));
float fill = game.sim.weapons.buff_get(fighter); float fill = game.sim.weapons.buff_get(fighter);
@@ -585,7 +586,8 @@ void Scene::fighter_draw(Fighter& fighter)
float pound = fighter.pound_area_get(); float pound = fighter.pound_area_get();
if (pound > 0) if (pound > 0)
renderer.ring_draw({fighter.center_get().x, fighter.body.box.y + fighter.body.box.h}, pound, color_fade(color, POUND_RING_ALPHA)); renderer.ring_draw({fighter.center_get().x, fighter.body.box.y + fighter.body.box.h}, pound,
color_fade(color, POUND_RING_ALPHA));
if (isLocal && !game.menu.isOpen && game.settings.isAimShown) if (isLocal && !game.menu.isOpen && game.settings.isAimShown)
paths_draw(fighter); paths_draw(fighter);
} }
+4 -2
View File
@@ -363,12 +363,14 @@ void Settings::apply(Game& game)
void Settings::fullscreen_apply(Game& game) void Settings::fullscreen_apply(Game& game)
{ {
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
EM_ASM({ EM_ASM(
{
if ($0 && !document.fullscreenElement) if ($0 && !document.fullscreenElement)
document.documentElement.requestFullscreen().catch(function(error){}); document.documentElement.requestFullscreen().catch(function(error){});
if (!$0 && document.fullscreenElement) if (!$0 && document.fullscreenElement)
document.exitFullscreen(); document.exitFullscreen();
}, isFullscreen); },
isFullscreen);
#else #else
SDL_SetWindowFullscreen(game.window, isFullscreen); SDL_SetWindowFullscreen(game.window, isFullscreen);
#endif #endif
-1
View File
@@ -11,7 +11,6 @@ struct Page;
constexpr int NAME_LENGTH = 16; constexpr int NAME_LENGTH = 16;
// The sandbox as it was left: where the fighter stood (its feet), the slot it held, and its loadouts and levels. // The sandbox as it was left: where the fighter stood (its feet), the slot it held, and its loadouts and levels.
struct SandboxSave struct SandboxSave
{ {
+1 -5
View File
@@ -103,11 +103,7 @@ void level_step(Sim& sim, int& level, int step)
Snapshot Sim::snapshot_save() Snapshot Sim::snapshot_save()
{ {
Snapshot snapshot = {.match = match, Snapshot snapshot = {.match = match, .weapons = weapons, .world = world, .rng = rng, .fighterCount = fighterCount};
.weapons = weapons,
.world = world,
.rng = rng,
.fighterCount = fighterCount};
std::copy(std::begin(fighters), std::end(fighters), snapshot.fighters); std::copy(std::begin(fighters), std::end(fighters), snapshot.fighters);
std::copy(std::begin(bot.states), std::end(bot.states), snapshot.bots); std::copy(std::begin(bot.states), std::end(bot.states), snapshot.bots);
return snapshot; return snapshot;
+34 -17
View File
@@ -9,23 +9,40 @@ class Cues
{ {
public: public:
virtual ~Cues() = default; virtual ~Cues() = default;
virtual void sound_play(SoundId sound) {} virtual void sound_play(SoundId sound)
virtual void charge_play(Fighter& fighter) {} {}
virtual void charge_stop(Fighter& fighter) {} virtual void charge_play(Fighter& fighter)
virtual void burst_spawn(Burst burst, Vec2 position, float angle, int owner) {} {}
virtual void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) {} virtual void charge_stop(Fighter& fighter)
virtual void shake_add(float amount) {} {}
virtual void flash_spawn(Vec2 position, float angle, float size, int owner) {} virtual void burst_spawn(Burst burst, Vec2 position, float angle, int owner)
virtual void fighter_afterimage_spawn(Fighter& fighter) {} {}
virtual void attack_afterimage_spawn(Attack& attack) {} virtual void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack)
virtual void fighter_aura_spawn(Fighter& fighter) {} {}
virtual void attack_aura_spawn(Attack& attack) {} virtual void shake_add(float amount)
virtual void number_spawn(Damage& damage) {} {}
virtual void level_step(bool isUp) {} virtual void flash_spawn(Vec2 position, float angle, float size, int owner)
virtual void match_start() {} {}
virtual void choice_open() {} virtual void fighter_afterimage_spawn(Fighter& fighter)
virtual void choice_close() {} {}
virtual void sandbox_return() {} virtual void attack_afterimage_spawn(Attack& attack)
{}
virtual void fighter_aura_spawn(Fighter& fighter)
{}
virtual void attack_aura_spawn(Attack& attack)
{}
virtual void number_spawn(Damage& damage)
{}
virtual void level_step(bool isUp)
{}
virtual void match_start()
{}
virtual void choice_open()
{}
virtual void choice_close()
{}
virtual void sandbox_return()
{}
}; };
inline Cues CUES_NONE; inline Cues CUES_NONE;
+1 -1
View File
@@ -122,7 +122,7 @@ template <class Stream, class SimRef> static void state_walk(Stream& stream, Sim
stream.list(world.obstacles); stream.list(world.obstacles);
stream.list(world.ramps); stream.list(world.ramps);
stream.list(world.tiles); stream.list(world.tiles);
stream.raw(world.spawns, SPAWN_COUNT); stream.raw(world.spawns, SPAWN_MAX);
stream.one(world.height); stream.one(world.height);
stream.one(world.width); stream.one(world.width);
} }
+19 -10
View File
@@ -23,6 +23,7 @@ constexpr float HUD_HOVER_FADE = 0.15f;
constexpr float RIVAL_PADDING = 2; constexpr float RIVAL_PADDING = 2;
constexpr int RIVAL_COLUMNS_MAX = 3; constexpr int RIVAL_COLUMNS_MAX = 3;
constexpr int RIVAL_FULL_SLOTS = 4; constexpr int RIVAL_FULL_SLOTS = 4;
constexpr int RIVALS_PER_ROW = 4;
constexpr float COOLDOWN_SLOTS = 2; constexpr float COOLDOWN_SLOTS = 2;
constexpr float MINI_ICON_SCALE = 0.5f; constexpr float MINI_ICON_SCALE = 0.5f;
constexpr float MINI_SLOT = 16; constexpr float MINI_SLOT = 16;
@@ -490,7 +491,8 @@ void Ui::race_draw(Game& game)
for (int i = 0; i < game.sim.fighterCount; i++) for (int i = 0; i < game.sim.fighterCount; i++)
{ {
Fighter& fighter = game.sim.fighters[i]; Fighter& fighter = game.sim.fighters[i];
float progress = game.sim.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f); float progress =
game.sim.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f);
float x = std::round(left + progress * TRACK_WIDTH - TRACK_DOT / 2); float x = std::round(left + progress * TRACK_WIDTH - TRACK_DOT / 2);
Rect dot = {x, std::round(y - TRACK_DOT / 2), TRACK_DOT, TRACK_DOT}; Rect dot = {x, std::round(y - TRACK_DOT / 2), TRACK_DOT, TRACK_DOT};
game.renderer.rect_draw(dot, game.color_get(i)); game.renderer.rect_draw(dot, game.color_get(i));
@@ -2192,11 +2194,12 @@ void Ui::spectate_draw(Game& game)
text_center_draw(game, text, y + LINE_HEIGHT, 1, shade); text_center_draw(game, text, y + LINE_HEIGHT, 1, shade);
} }
// Lays out the other fighters' windows in a row along the bottom right, never left of the screen's middle (nor over the // Lays out the other fighters' windows in rows of up to four along the bottom right (more rows stacking upwards, as
// local hotbar and cooldowns). A window is as wide as its icon row, but no wider than one holding two weapons and two // even as they go), never left of the screen's middle (nor over the local hotbar and cooldowns). A window is as wide as
// trinkets at full size: past that, or when the row doesn't fit, icons shrink to small, then tiny. Each kind wraps onto // its icon row, but no wider than one holding two weapons and two trinkets at full size: past that, or when the row
// more rows past three across, and tiny icons wrap further if they still don't fit. Names and health use the small // doesn't fit, icons shrink to small, then tiny. Each kind wraps onto more rows past three across, and tiny icons wrap
// font, or the tiny one for every window if any doesn't fit. // further if they still don't fit. Names and health use the small font, or the tiny one for every window if any doesn't
// fit.
RivalLayout Ui::rivals_layout(Game& game) RivalLayout Ui::rivals_layout(Game& game)
{ {
RivalLayout layout; RivalLayout layout;
@@ -2212,6 +2215,8 @@ RivalLayout Ui::rivals_layout(Game& game)
} }
if (layout.count == 0) if (layout.count == 0)
return layout; return layout;
int cardRows = (layout.count + RIVALS_PER_ROW - 1) / RIVALS_PER_ROW;
int perRow = (layout.count + cardRows - 1) / cardRows;
float step = SLOT_SIZE + SLOT_GAP; float step = SLOT_SIZE + SLOT_GAP;
float left = float left =
@@ -2241,7 +2246,7 @@ RivalLayout Ui::rivals_layout(Game& game)
width = std::ceil(std::max(icons, minimum)); width = std::ceil(std::max(icons, minimum));
layout.size = (IconSize)size; layout.size = (IconSize)size;
layout.rows = rows; layout.rows = rows;
bool isFit = icons <= widthMax && layout.count * width + (layout.count - 1) * RIVAL_GAP <= room; bool isFit = icons <= widthMax && perRow * width + (perRow - 1) * RIVAL_GAP <= room;
if (isFit) if (isFit)
size = ICON_SIZE_COUNT; size = ICON_SIZE_COUNT;
if (size == ICON_SIZE_COUNT) if (size == ICON_SIZE_COUNT)
@@ -2261,10 +2266,14 @@ RivalLayout Ui::rivals_layout(Game& game)
float healthHeight = game.sim.match.is_racing() ? 0 : layout.line + RIVAL_PADDING; float healthHeight = game.sim.match.is_racing() ? 0 : layout.line + RIVAL_PADDING;
float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP; float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP;
float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset; float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset;
float x = CANVAS_WIDTH - HOTBAR_MARGIN - layout.count * width - (layout.count - 1) * RIVAL_GAP;
for (int k = 0; k < layout.count; k++) for (int k = 0; k < layout.count; k++)
layout.cards[k] = {std::round(x + k * (width + RIVAL_GAP)), CANVAS_HEIGHT - HOTBAR_MARGIN - height, width, {
height}; int row = k / perRow;
int inRow = std::min(perRow, layout.count - row * perRow);
float x = CANVAS_WIDTH - HOTBAR_MARGIN - inRow * width - (inRow - 1) * RIVAL_GAP;
float y = CANVAS_HEIGHT - HOTBAR_MARGIN - height - row * (height + RIVAL_GAP);
layout.cards[k] = {std::round(x + (k % perRow) * (width + RIVAL_GAP)), y, width, height};
}
return layout; return layout;
} }
+5 -4
View File
@@ -336,8 +336,7 @@ std::vector<Vec2> lash_get(Attack& attack)
static bool is_hostile(Sim& sim, int owner, int target) static bool is_hostile(Sim& sim, int owner, int target)
{ {
Match& match = sim.match; Match& match = sim.match;
return match.state != MATCH_PRACTICE || return match.state != MATCH_PRACTICE || (sim.fighters[owner].isPvp && sim.fighters[target].isPvp);
(sim.fighters[owner].isPvp && sim.fighters[target].isPvp);
} }
static bool is_target(Sim& sim, int target, int owner) static bool is_target(Sim& sim, int target, int owner)
@@ -760,7 +759,8 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot)
if (weapon.flash > 0) if (weapon.flash > 0)
sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index); sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index);
if (weapon.isEjecting) if (weapon.isEjecting)
sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK),
fighter.index);
if (isCharged) if (isCharged)
sim.cues->shake_add(def.shake * FIRE_SHAKE); sim.cues->shake_add(def.shake * FIRE_SHAKE);
@@ -1051,7 +1051,8 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
break; break;
} }
attack.position = next; attack.position = next;
sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, attack.owner); sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL),
attack.angle + PI, attack.owner);
bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range; bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range;
if (sim.world.is_blocking_at(attack.position) || isSpent) if (sim.world.is_blocking_at(attack.position) || isSpent)
+38 -6
View File
@@ -7,11 +7,11 @@
constexpr const char* ARENA[] = { constexpr const char* ARENA[] = {
"####################################################", "###..............................................###", "####################################################", "###..............................................###",
"###..............................................###", "######........................................######", "###..............................................###", "######........................................######",
"##................................................##", "##................................................##", "##................................................##", "##.......................7........................##",
"##...................==========...................##", "##................................................##", "##...................==========...................##", "##................................................##",
"##................................................##", "####.........3........................4.........####", "##................................................##", "####.........3........................4.........####",
"####.......======..................======.......####", "##................................................##", "####.......======..................======.......####", "##................................................##",
"##................................................##", "##................................................##", "##................................................##", "##..5.....................8....................6..##",
"##======.............==========.............======##", "##................................................##", "##======.............==========.............======##", "##................................................##",
"##................................................##", "##...........1........................2...........##", "##................................................##", "##...........1........................2...........##",
"##.........======..................======.........##", "#######......................................#######", "##.........======..................======.........##", "#######......................................#######",
@@ -24,7 +24,7 @@ constexpr const char* CHUNK_START[COURSE_HEIGHT] = {
"..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................",
"..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................",
"..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................",
"...1..2..3..4.....", "##################", "##################", "##################", "##################", ".1.2.3.4.5.6.7.8..", "##################", "##################", "##################", "##################",
"##################", "##################", "##################", "##################",
}; };
@@ -212,7 +212,8 @@ static bool is_solid(Tile tile)
} }
// The arena with the practice dummy standing on the first surface below the middle of the map, and the player // The arena with the practice dummy standing on the first surface below the middle of the map, and the player
// spawning right underneath it, on the next surface down. // spawning right underneath it, on the next surface down. Its marked spawns are joined by more spread over its ground,
// for a lobby's sandbox full of players.
void World::sandbox_load() void World::sandbox_load()
{ {
arena_load(); arena_load();
@@ -233,6 +234,37 @@ void World::sandbox_load()
float groundY = groundRow * TILE_SIZE; float groundY = groundRow * TILE_SIZE;
Ramp* ramp = ramp_find({centerX, groundY + TILE_SIZE / 2.0f}); Ramp* ramp = ramp_find({centerX, groundY + TILE_SIZE / 2.0f});
spawns[0] = {centerX, ramp ? ramp->y_at(centerX) : groundY}; spawns[0] = {centerX, ramp ? ramp->y_at(centerX) : groundY};
spawns_spread(SPAWN_MARKS);
}
// Fills the spawns from one on with spots on flat open ground (solid or a platform underfoot, room for a fighter
// above), each the farthest from every spawn before it.
void World::spawns_spread(int from)
{
std::vector<Vec2> spots;
for (int y = 1; y + 1 < height; y++)
for (int x = 0; x < width; x++)
{
Tile below = tile_get(x, y + 1);
bool isFloor = below == TILE_SOLID || below == TILE_PLATFORM;
if (isFloor && tile_get(x, y) == TILE_EMPTY && tile_get(x, y - 1) == TILE_EMPTY)
spots.push_back({(x + 0.5f) * TILE_SIZE, (y + 1.0f) * TILE_SIZE});
}
for (int i = from; i < SPAWN_MAX && !spots.empty(); i++)
{
float farthest = -1;
for (Vec2 spot : spots)
{
float nearest = INFINITY;
for (int j = 0; j < i; j++)
nearest = std::min(nearest, vec2_dot(spot - spawns[j], spot - spawns[j]));
if (nearest > farthest)
{
farthest = nearest;
spawns[i] = spot;
}
}
}
} }
void World::arena_load() void World::arena_load()
@@ -264,7 +296,7 @@ void World::course_build(Rng& rng)
map_load(rows); map_load(rows);
} }
// Fills the grid from rows of symbols: # solid, = platform, B breakable block, ~ pit, F finish line, 1 to 4 spawns. // Fills the grid from rows of symbols: # solid, = platform, B breakable block, ~ pit, F finish line, 1 to 8 spawns.
// Every block gets a dummy to take its hits. // Every block gets a dummy to take its hits.
void World::map_load(std::vector<std::string>& rows) void World::map_load(std::vector<std::string>& rows)
{ {
@@ -287,7 +319,7 @@ void World::map_load(std::vector<std::string>& rows)
tile = TILE_PIT; tile = TILE_PIT;
else if (symbol == 'F') else if (symbol == 'F')
tile = TILE_FINISH; tile = TILE_FINISH;
else if (symbol >= '1' && symbol < '1' + SPAWN_COUNT) else if (symbol >= '1' && symbol < '1' + SPAWN_MARKS)
spawns[symbol - '1'] = {(x + 0.5f) * TILE_SIZE, (y + 1.0f) * TILE_SIZE}; spawns[symbol - '1'] = {(x + 0.5f) * TILE_SIZE, (y + 1.0f) * TILE_SIZE};
if (symbol != 'B') if (symbol != 'B')
continue; continue;
+6 -3
View File
@@ -78,10 +78,12 @@ struct Dummy
constexpr int BLOCK_STAGES = 3; constexpr int BLOCK_STAGES = 3;
constexpr int COURSE_HEIGHT = 22; constexpr int COURSE_HEIGHT = 22;
constexpr int SPAWN_COUNT = 4; // Maps mark up to SPAWN_MARKS spawns, 1 to 8. The sandbox has SPAWN_MAX, the rest found on its open ground.
constexpr int SPAWN_MARKS = 8;
constexpr int SPAWN_MAX = 16;
// Tile grid of any size, its collision, and the things standing in it. Spawns are where fighters stand at the start // Tile grid of any size, its collision, and the things standing in it. Spawns are where fighters stand at the start
// of a round, marked 1 to 4 in the map. Obstacles are extra solid boxes for the body moving right now, like a // of a round, marked 1 to 8 in the map. Obstacles are extra solid boxes for the body moving right now, like a
// fighter's own solid attacks. The map is the deathmatch arena or a race course stitched from chunks. Ramp tiles are // fighter's own solid attacks. The map is the deathmatch arena or a race course stitched from chunks. Ramp tiles are
// solid below their ramp's line, and bodies meet ramps by their own rules rather than the grid's. // solid below their ramp's line, and bodies meet ramps by their own rules rather than the grid's.
class World class World
@@ -91,13 +93,14 @@ public:
std::vector<Rect> obstacles; std::vector<Rect> obstacles;
std::vector<Ramp> ramps; std::vector<Ramp> ramps;
std::vector<Tile> tiles; std::vector<Tile> tiles;
Vec2 spawns[SPAWN_COUNT]; Vec2 spawns[SPAWN_MAX];
int height = 0; int height = 0;
int width = 0; int width = 0;
void update(Sim& sim); void update(Sim& sim);
void arena_load(); void arena_load();
void sandbox_load(); void sandbox_load();
void spawns_spread(int from);
void course_build(Rng& rng); void course_build(Rng& rng);
void block_break(Sim& sim, Dummy& block, int breaker); void block_break(Sim& sim, Dummy& block, int breaker);
void body_move(Body& body); void body_move(Body& body);