Weapons built from traits, fashion, Hourglass, About tab, and Ithaca

- Every attack is now assembled from traits on its definition instead
  of per-weapon code: an anchor and path (thrusts, out-and-back, arcs),
  shared flight physics (gravity, drag, acceleration, homing, and one
  wall response: end, burst, stick, crawl, bounce, stop, pass, return,
  latch), and flags like bouncy, drifting, tethered, orbiting, instant
  and arming. Melee are projectiles too.
- Shots have a lifetime and a fade (when damage bottoms out). Range is
  speed times lifetime. Ricochets and the Vacuum wind lifetime back.
- The Vacuum pulls anyone's shots, and its airblast flips them out
  along the aim, claiming enemies' (fading into your colour).
- New trinket: Hourglass, longer projectile lifetime and range. Ropes
  and chains lengthen with it and the Accelerator.
- Fashion: 23 items worn on the head, face or torso, chosen in Profile.
- Flamethrower flames hit continuously, the whip coils and sweeps,
  bouncing shots roll over ramps, and Ricochet relights grenade fuses.
- Profile tab, colour tooltips, option hints on every setting, an
  About tab with credits and links, and the Ithaca font.
- Loadouts lock once readied, tomatoes clear with the match, and the
  round-over pause is 4 seconds.
- CC0 license. Tools and CLAUDE.md are no longer tracked, and the web
  shell moves to web/.
This commit is contained in:
shweet committed 2026-11-10 15:27:12 -05:00
1 parent e55596b276
commit e9d8cac926
101 files changed
+5787 -7148

No files matched your search

+97 -21
View File
@@ -85,6 +85,21 @@ struct Member
int room = 0;
};
// How a member comes into a room: their fighter joining it from their profile, or already seated (a game set up with
// them in it) or spectating, with nothing to add.
enum RoomEntry
{
ENTRY_SEATED,
ENTRY_JOIN
};
// Why a member leaves a lobby: of their own accord (or dropping out), or kicked by the host.
enum LeaveReason
{
LEAVE_QUIT,
LEAVE_KICKED
};
// A lobby: its members in the order they joined (the first hosting), and its rooms, the sandbox first.
struct Lobby
{
@@ -119,7 +134,7 @@ static int nextRoom = 1;
// Sends a member a lobby message (text) or a game one (binary): binary goes over their data channels once both are
// open (the lossy one for frames), and the socket until then. A connection can drop between checking it and sending,
// which throws. It's let go: its closing comes in later.
static void send(int id, const std::string& message, bool isBinary = false, bool isLossy = false)
static void send(int id, const std::string& message, Delivery delivery = DELIVERY_TEXT)
{
auto found = members.find(id);
if (found == members.end())
@@ -127,10 +142,11 @@ static void send(int id, const std::string& message, bool isBinary = false, bool
Member& member = found->second;
auto data = reinterpret_cast<const std::byte*>(message.data());
bool isLinked = member.channel && member.channel->isOpen() && member.inputs && member.inputs->isOpen();
bool isBinary = delivery != DELIVERY_TEXT;
try
{
if (isBinary && isLinked)
(isLossy ? member.inputs : member.channel)->send(data, message.size());
(delivery == DELIVERY_LOSSY ? member.inputs : member.channel)->send(data, message.size());
else if (isBinary && member.socket->isOpen())
member.socket->send(rtc::binary(data, data + message.size()));
else if (member.socket->isOpen())
@@ -210,10 +226,10 @@ static void error_send(int id, const char* message)
send(id, "{\"type\":\"error\",\"message\":" + json_quote(message) + "}");
}
static void lobby_send(const std::string& code, const std::string& message, bool isBinary = false)
static void lobby_send(const std::string& code, const std::string& message, Delivery delivery = DELIVERY_TEXT)
{
for (int id : lobbies[code].members)
send(id, message, isBinary);
send(id, message, delivery);
}
// A name as the lobby shows it: printable characters only, cut to length, or "Player" when nothing's left.
@@ -257,12 +273,12 @@ static void event_add(int serial, RoomEvent event)
packet.put(PACKET_EVENT).put(serial).put((int)room.events.size() - 1).put(event);
for (auto& [id, member] : members)
if (member.room == serial)
send(id, packet.bytes, true);
send(id, packet.bytes, DELIVERY_RELIABLE);
}
// Puts a member in a room as its next tick starts: their fighter joins (from their profile, or their seat in a game
// already set up for them), and they get the room as it is then, with every event it's had.
static void room_enter(int id, int serial, bool isJoining)
static void room_enter(int id, int serial, RoomEntry entry)
{
Member& member = members[id];
Room& room = rooms[serial];
@@ -272,10 +288,10 @@ static void room_enter(int id, int serial, bool isJoining)
PacketWriter packet;
packet.put(PACKET_STATE).put(serial).put(room.tick);
packet.bytes += state_save(room.sim);
send(id, packet.bytes, true);
send(id, packet.bytes, DELIVERY_RELIABLE);
for (size_t i = 0; i < room.events.size(); i++)
send(id, PacketWriter().put(PACKET_EVENT).put(serial).put((int)i).put(room.events[i]).bytes, true);
if (isJoining)
send(id, PacketWriter().put(PACKET_EVENT).put(serial).put((int)i).put(room.events[i]).bytes, DELIVERY_RELIABLE);
if (entry == ENTRY_JOIN)
event_add(serial, {.type = ROOM_JOIN, .member = id, .profile = member.profile});
}
@@ -322,7 +338,7 @@ static void lobby_enter(int id, const std::string& code)
// Takes a member out of its lobby, telling the rest (and who hosts now, and why if they were kicked). An empty lobby is
// gone, with its rooms.
static void lobby_exit(int id, const std::string& kick = "", bool isKicked = false)
static void lobby_exit(int id, LeaveReason leave = LEAVE_QUIT, const std::string& kick = "")
{
std::string code = members[id].code;
if (code.empty())
@@ -339,7 +355,7 @@ static void lobby_exit(int id, const std::string& kick = "", bool isKicked = fal
std::printf("Lobby %s closed (%zu open)\n", code.c_str(), lobbies.size());
return;
}
std::string reason = isKicked ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : "";
std::string reason = leave == LEAVE_KICKED ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : "";
lobby_send(code, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) +
",\"host\":" + std::to_string(lobby.members.front()) + reason + "}");
}
@@ -409,7 +425,7 @@ static void text_handle(int id, const std::string& text)
std::string reason = text_clean(message["reason"].get(raw) ? "" : raw, KICK_REASON_MAX);
send((int)target, "{\"type\":\"kicked\",\"reason\":" + json_quote(reason) + "}");
std::printf("Lobby %s: %d kicked %d\n", code.c_str(), id, (int)target);
return lobby_exit((int)target, reason, true);
return lobby_exit((int)target, LEAVE_KICKED, reason);
}
error_send(id, "Unknown message");
}
@@ -428,7 +444,7 @@ static void text_relay(int id, PacketType type, PacketReader& reader, size_t max
PacketWriter packet;
packet.put(type).put(id).put((Uint16)text.size());
packet.bytes += text;
lobby_send(members[id].code, packet.bytes, true);
lobby_send(members[id].code, packet.bytes, DELIVERY_RELIABLE);
}
// The game messages, in binary (see protocol.h), from a member in a lobby.
@@ -450,7 +466,7 @@ static void packet_handle(int id, const std::string& bytes)
member.profile.color = std::clamp(member.profile.color, -1, COLOR_COUNT - 1);
std::memcpy(member.profile.name, member.name.c_str(), member.name.size() + 1);
if (member.room == 0)
room_enter(id, lobbies[member.code].rooms.front(), true);
room_enter(id, lobbies[member.code].rooms.front(), ENTRY_JOIN);
return;
}
if (type == PACKET_INPUTS)
@@ -467,6 +483,9 @@ static void packet_handle(int id, const std::string& bytes)
Room& room = rooms[serial];
member.ack = std::max(member.ack, std::min(ack, room.tick - 1));
member.lead = first + count - 1 - room.tick;
// A spectator's controls play nothing, so aren't kept.
if (room.sim.match.fighter_find(room.sim, id) < 0)
return;
for (int i = 0; i < count; i++)
{
Controls controls;
@@ -493,13 +512,38 @@ static void packet_handle(int id, const std::string& bytes)
{
Uint8 isTyping = 0;
if (reader.get(isTyping))
lobby_send(member.code, PacketWriter().put(PACKET_TYPING).put(id).put(isTyping).bytes, true);
lobby_send(member.code, PacketWriter().put(PACKET_TYPING).put(id).put(isTyping).bytes, DELIVERY_RELIABLE);
}
if (type == PACKET_WATCH)
{
// From the sandbox, spectating one of the lobby's games. Spectating, back to the sandbox. Someone playing in a
// game leaves it by its own means.
int serial = 0;
std::vector<int>& lobbyRooms = lobbies[member.code].rooms;
int sandbox = lobbyRooms.front();
bool isInSandbox = member.room == sandbox;
auto room = rooms.find(member.room);
bool isPlaying = room != rooms.end() && room->second.sim.match.fighter_find(room->second.sim, id) >= 0;
if (!reader.get(serial))
return;
bool isGame = serial != sandbox && std::find(lobbyRooms.begin(), lobbyRooms.end(), serial) != lobbyRooms.end();
if (isGame && isInSandbox)
{
room_exit(id);
room_enter(id, serial, ENTRY_SEATED);
}
else if (serial == 0 && !isInSandbox && !isPlaying && member.room != 0)
{
room_exit(id);
room_enter(id, sandbox, ENTRY_JOIN);
}
return;
}
if (type == PACKET_PING)
{
Uint16 ping = 0;
if (reader.get(ping))
lobby_send(member.code, PacketWriter().put(PACKET_PING).put(id).put(ping).bytes, true);
lobby_send(member.code, PacketWriter().put(PACKET_PING).put(id).put(ping).bytes, DELIVERY_RELIABLE);
}
}
@@ -565,7 +609,7 @@ static void game_open(int sandboxSerial, int queuer)
for (int k = 0; k < count; k++)
{
room_exit(players[k]);
room_enter(players[k], serial, false);
room_enter(players[k], serial, ENTRY_SEATED);
}
}
@@ -588,7 +632,7 @@ static void game_follow(int serial)
if (match.isGone[k])
{
room_exit(owner);
room_enter(owner, sandbox, true);
room_enter(owner, sandbox, ENTRY_JOIN);
continue;
}
seats[humans++] = k;
@@ -606,13 +650,43 @@ static void game_follow(int serial)
{
int owner = match.owners[seats[k]];
room_exit(owner);
room_enter(owner, next, false);
room_enter(owner, next, ENTRY_SEATED);
}
}
// Anyone still here was spectating, and goes back to the sandbox.
for (auto& [id, member] : members)
{
if (member.room != serial)
continue;
room_exit(id);
room_enter(id, sandbox, ENTRY_JOIN);
}
std::erase(lobbies[game.code].rooms, serial);
rooms.erase(serial);
}
// Tells a lobby's members who's in each of its rooms (the sandbox, then its games): each room's mode and the players
// still in it with their colours, for the lobby page's lists.
static void rooms_send(const std::string& code)
{
std::vector<int>& lobbyRooms = lobbies[code].rooms;
PacketWriter packet;
packet.put(PACKET_ROOMS).put((Uint8)lobbyRooms.size());
for (int serial : lobbyRooms)
{
Match& match = rooms[serial].sim.match;
int fighters = rooms[serial].sim.fighterCount;
Uint8 count = 0;
for (int k = 0; k < fighters; k++)
count += match.owners[k] >= 0 && !match.isGone[k];
packet.put(serial).put((Uint8)match.rules.mode).put(count);
for (int k = 0; k < fighters; k++)
if (match.owners[k] >= 0 && !match.isGone[k])
packet.put(match.owners[k]).put((Uint8)match.colors[k]);
}
lobby_send(code, packet.bytes, DELIVERY_RELIABLE);
}
// 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.
static void frames_send(int serial)
@@ -642,14 +716,14 @@ static void frames_send(int serial)
for (int i = 0; i < frame.count; i++)
packet.put(frame.controls[i]);
}
send(id, packet.bytes, true, true);
send(id, packet.bytes, DELIVERY_LOSSY);
}
if (room.tick % CHECKSUM_INTERVAL == 0)
for (auto& [id, member] : members)
if (member.room == serial)
send(id,
PacketWriter().put(PACKET_CHECKSUM).put(serial).put(room.tick).put(room.sim.checksum_get()).bytes,
true);
DELIVERY_RELIABLE);
}
// One tick of every room, in every lobby: the sandbox, then its games, then the frames out.
@@ -669,6 +743,8 @@ static void rooms_step()
int queuer = room.sim.match.queue_startable_get(room.sim);
if (queuer >= 0)
game_open(serial, queuer);
if (room.tick % CHECKSUM_INTERVAL == 0)
rooms_send(room.code);
}
else
game_follow(serial);