Files
smack-a-brother/src/main.cpp
T
shweetandClaude Opus 5.5 9a41474069 Tomatoes, match tabs, shared pings, and a round of fixes
- On the results, everyone but the winner splats tomatoes where they
  click (bots aim at the winner), with a new splat sound.
- During a match the menu has the item tabs (choosing when the
  countdown allows, and otherwise levelling the sandbox's levels for
  when you're back), Play (the game's rules) and Settings.
- Pings are shared through the server (protocol 4), so other players'
  connection bars show over their heads and on their windows.
- PvP comes into lobbies with your profile and is saved.
- The choice menu opens by itself however the countdown began.
- Knocked-out fighters keep their buttons current, so Ready on the
  results toggles once per press.
- The weapon wheel wraps both ways. Readying with empty slots plays
  the equip sound for your own fighter.
- Shock Absorber: up to 50% less weapon movement and 33% less
  knockback. Only the catalog previews the next level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:11:06 -05:00

283 lines
9.7 KiB
C++

#include "game.h"
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <algorithm>
constexpr int WINDOW_SCALE = 2;
constexpr double MAX_FRAME_TIME = 0.25;
#ifdef SDL_PLATFORM_EMSCRIPTEN
constexpr SDL_WindowFlags WINDOW_FILL = SDL_WINDOW_FILL_DOCUMENT;
#else
constexpr SDL_WindowFlags WINDOW_FILL = 0;
#endif
constexpr float CAMERA_FOLLOW = 0.15f;
// Where the camera's corner wants to be: this screen's living fighters' middle (or the spectated fighter) in the
// middle, clamped to the world or centred when the world is smaller.
static Vec2 camera_target_get(Game& game)
{
Vec2 world = game.sim.world.size_get();
Vec2 canvas = {CANVAS_WIDTH, CANVAS_HEIGHT};
Vec2 center = {};
int count = 0;
for (int i = 0; i < game.sim.fighterCount; i++)
{
if (!game.is_local(i) || !game.sim.fighters[i].isAlive)
continue;
center = center + game.sim.fighters[i].center_get();
count++;
}
// Spectating, it follows whoever is being watched instead.
if (game.ui.spectated >= 0)
{
center = game.sim.fighters[game.ui.spectated].center_get();
count = 1;
}
Vec2 target = (count > 0 ? center * (1.0f / count) : game.local_get().center_get()) - canvas * 0.5f;
target.x = world.x > canvas.x ? std::clamp(target.x, 0.0f, world.x - canvas.x) : (world.x - canvas.x) / 2;
target.y = world.y > canvas.y ? std::clamp(target.y, 0.0f, world.y - canvas.y) : (world.y - canvas.y) / 2;
return target;
}
// Eases the camera towards its target.
static void camera_update(Game& game)
{
game.camera = game.camera + (camera_target_get(game) - game.camera) * CAMERA_FOLLOW;
}
// One tick of the game this screen shows, with what it asks of the match offline: the menu's Play, Escape (or B)
// leaving the results unless it's just closed the menu, and its player's colour. The menu is this screen's alone, so
// it updates before the tick (see frame_run), not in it.
void game_tick(Game& game)
{
Match& match = game.sim.match;
Input& input = game.input;
match.isOnline = game.session.isActive;
match.isPlayRequested = input.isPlayRequested;
match.isSandboxRequested =
game.menu.isLeaving || (!game.menu.isOpen && input.is_pad_tapped(SDL_GAMEPAD_BUTTON_EAST));
match.colorWanted = game.settings.color_get();
game.sim.tick();
input.tick_end();
}
// Steps what only this screen sees, once per live tick: particles, HUD fades and the following camera.
static void presentation_update(Game& game)
{
game.effects.update();
game.ui.update(game);
game.scene.update();
game.cameraPrevious = game.camera;
camera_update(game);
}
// Draws the world part way between the last two ticks. Fighters and attacks are swapped
// for blended copies while drawing, then the real simulation state is put back.
static void game_draw(Game& game)
{
Rect boxes[MAX_FIGHTERS];
std::vector<Attack> attacks = game.sim.weapons.attacks;
for (int i = 0; i < game.sim.fighterCount; i++)
{
boxes[i] = game.sim.fighters[i].body.box;
game.sim.fighters[i].blend(game.alpha);
}
game.sim.weapons.blend(game.alpha);
Vec2 camera = vec2_lerp(game.cameraPrevious, game.camera, game.alpha);
if (game.settings.isShaking)
camera = camera + game.effects.shake_get();
game.renderer.camera = {std::round(camera.x), std::round(camera.y)};
game.scene.draw();
game.effects.draw(game);
game.ui.draw(game);
game.renderer.present();
for (int i = 0; i < game.sim.fighterCount; i++)
game.sim.fighters[i].body.box = boxes[i];
game.sim.weapons.attacks = attacks;
}
// Sets up the game, seeded from RPG_SEED (every number its own odd state) or the clock. Its cues go nowhere until a
// window shows them.
static void simulation_init(Game& game)
{
const char* seed = SDL_getenv("RPG_SEED");
game.sim.init(seed ? (Uint32)SDL_atoi(seed) * 2 + 1 : (Uint32)SDL_GetPerformanceCounter() | 1);
game.local_get().isBot = SDL_getenv("RPG_BOT_LOCAL");
game.rollback.init();
}
// The frame clock: when the last frame began, and the time not yet simulated.
struct FrameClock
{
Uint64 previous = 0;
double accumulator = 0;
};
static Game game;
static FrameClock frameClock;
// One frame: input, as many fixed ticks as the time since the last frame allows, then drawing (unless it's not to be
// drawn). Returns whether to keep going.
static bool frame_run(bool isDrawn = true)
{
Uint64 now = SDL_GetPerformanceCounter();
frameClock.accumulator +=
std::min((now - frameClock.previous) / (double)SDL_GetPerformanceFrequency(), MAX_FRAME_TIME);
frameClock.previous = now;
game.input.poll(game);
game.net.update();
game.chat.update(game);
Session& session = game.session;
for (; frameClock.accumulator >= TICK; frameClock.accumulator -= TICK)
{
// Online, the session runs the room this screen is in, taking its controls; otherwise each fighter on this
// screen takes its device's. Watching, they do nothing.
game.menu.update(game);
if (session.is_wanted(game))
{
Fighter& own = game.local_get();
bool isHeld = game.menu.isOpen || game.chat.isOpen || session.is_watching();
Controls controls = game.input.controls_get(DEVICE_KEYBOARD, game.camera, own, isHeld);
session.update(game, controls);
game.input.tick_end();
if (session.isViewChanged)
{
session.isViewChanged = false;
game.camera = game.cameraPrevious = camera_target_get(game);
}
presentation_update(game);
continue;
}
for (int i = 0; i < game.sim.fighterCount; i++)
{
Fighter& fighter = game.sim.fighters[i];
bool isMenuOpen = game.menu.isOpen && i == game.local;
if (game.is_local(i))
fighter.controls = game.input.controls_get(game.devices[i], game.camera, fighter, isMenuOpen);
}
game.rollback.update(game);
presentation_update(game);
}
game.settings.update(game);
game.alpha = frameClock.accumulator / TICK;
if (isDrawn)
game_draw(game);
return !game.input.isQuit;
}
#ifdef SDL_PLATFORM_EMSCRIPTEN
static void frame_callback()
{
frame_run();
}
// A hidden tab gets no frames from the browser, but online the shared game can't wait for it: everyone else would
// stall. So a worker's timer (which the browser doesn't hold back the way it does a hidden page's own) calls this
// every 16 ms while the tab is hidden, to keep the game running, undrawn.
extern "C" EMSCRIPTEN_KEEPALIVE void frame_hidden()
{
if (game.session.is_wanted(game))
frame_run(false);
}
#endif
// Runs the game in a window (on the web, filling the page and driven by the browser's frames), or with "--balance N"
// plays N bot matches headless and prints the results ("--balance N race" plays races).
// The lobby to join on starting, if any: from the page's ?lobby= in the browser, or --join on the command line.
static std::string lobby_requested(int argc, char** argv)
{
#ifdef SDL_PLATFORM_EMSCRIPTEN
char* code = (char*)EM_ASM_PTR({
var code = new URLSearchParams(location.search).get('lobby');
return code ? stringToNewUTF8(code) : 0;
});
std::string lobby = code ? code : "";
free(code);
return lobby;
#else
for (int i = 1; i + 1 < argc; i++)
if (SDL_strcmp(argv[i], "--join") == 0)
return argv[i + 1];
return "";
#endif
}
int main(int argc, char** argv)
{
int balanceMatches = argc > 2 && SDL_strcmp(argv[1], "--balance") == 0 ? SDL_atoi(argv[2]) : 0;
if (balanceMatches > 0)
{
simulation_init(game);
bool isRace = argc > 3 && SDL_strcmp(argv[3], "race") == 0;
game.balance.matches_run(game, balanceMatches, isRace ? MODE_RACE : MODE_DEATHMATCH);
return 0;
}
if (!SDL_Init(SDL_INIT_VIDEO))
{
SDL_Log("Failed to init SDL: %s", SDL_GetError());
return 1;
}
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD))
SDL_Log("Gamepads unavailable: %s", SDL_GetError());
game.settings.load(game);
game.window =
SDL_CreateWindow(GAME_NAME, CANVAS_WIDTH * WINDOW_SCALE, CANVAS_HEIGHT * WINDOW_SCALE,
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | WINDOW_FILL);
if (!game.window || !game.renderer.init(game.window))
{
SDL_Log("Failed to create window: %s", SDL_GetError());
return 1;
}
game.text.init(game.renderer);
game.audio.init();
game.ui.init(game);
game.sprites.init(game);
simulation_init(game);
game.sim.cues = &game.scene;
game.renderer.atlas_upload();
game.settings.apply(game);
game.settings.restore(game);
std::string lobby = lobby_requested(argc, argv);
if (!lobby.empty())
{
game.net.lobby_join(game, lobby.c_str());
game.menu.online_open();
}
game.camera = camera_target_get(game);
game.cameraPrevious = game.camera;
SDL_HideCursor();
frameClock.previous = SDL_GetPerformanceCounter();
#ifdef SDL_PLATFORM_EMSCRIPTEN
// The browser calls each frame from here on; main returns but the game lives on.
emscripten_set_main_loop(frame_callback, 0, false);
EM_ASM({
var ticker = new Worker(URL.createObjectURL(
new Blob(["setInterval(function() { postMessage(0); }, 16);"], {type : "text/javascript"})));
ticker.onmessage = function()
{
if (document.hidden)
_frame_hidden();
};
});
return 0;
#endif
while (frame_run())
{
}
game.settings.save(game);
game.net.shutdown();
SDL_Quit();
return 0;
}