- Tomato splats are kept in the match, so everyone sees everyone's (others' arrive in replays, which play no effects), each splatting with juice and a sound once. - The sandbox's first player (solo or hosting) spawns on the arena's P1 mark. - A thrown flail ball or grapple stays out when switching weapons, and another weapon fires while a grapple hangs on. Mines, flail balls and grapples go once their slot no longer holds the weapon that put them out (mines fizzling). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
885 lines
35 KiB
C++
885 lines
35 KiB
C++
#include "match.h"
|
|
#include "sim.h"
|
|
#include <algorithm>
|
|
#include <numeric>
|
|
#include <cstring>
|
|
|
|
constexpr float TOMATO_COOLDOWN = 0.25f;
|
|
constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40};
|
|
constexpr float RACE_GRACE = 15;
|
|
constexpr float ROUND_OVER_TIME = 2.5f;
|
|
constexpr float SECONDS_PER_MINUTE = 60;
|
|
constexpr int RACE_PLAYERS = 4;
|
|
constexpr int RACE_TIMER = 3;
|
|
|
|
Rules rules_get(int mode)
|
|
{
|
|
Rules rules = {.mode = mode};
|
|
if (mode == MODE_RACE)
|
|
{
|
|
rules.players = RACE_PLAYERS;
|
|
rules.timer = RACE_TIMER;
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
// Rules from someone else (online), kept in the ranges the menu allows. Only deathmatches can be queued for now.
|
|
Rules rules_clean(Rules rules)
|
|
{
|
|
rules.mode = MODE_DEATHMATCH;
|
|
rules.players = std::clamp(rules.players, PLAYERS_MIN, PLAYERS_MAX);
|
|
rules.bestOf = std::clamp(rules.bestOf, 1, BEST_OF_MAX) | 1;
|
|
rules.timer = std::clamp(rules.timer, 0, TIMER_MAX);
|
|
rules.health = std::clamp(rules.health, (float)BASE_HEALTH_MIN, (float)BASE_HEALTH_MAX);
|
|
if (!(rules.health == rules.health))
|
|
rules.health = rules_get(MODE_DEATHMATCH).health;
|
|
rules.healthMultiplier = std::clamp(rules.healthMultiplier, 1.0f, MULTIPLIER_MAX);
|
|
if (!(rules.healthMultiplier == rules.healthMultiplier))
|
|
rules.healthMultiplier = 1;
|
|
for (ItemRules* items : {&rules.weapons, &rules.trinkets})
|
|
{
|
|
items->slots = std::clamp(items->slots, 1, SLOT_MAX);
|
|
items->period = std::clamp(items->period, PERIOD_MIN, PERIOD_MAX);
|
|
items->startLevel = std::clamp(items->startLevel, 1, LEVEL_COUNT);
|
|
items->timing = std::clamp(items->timing, (int)PICK_FIRST_ROUND, (int)PICK_PERIOD);
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
// Fills a loadout's empty slots with random picks out of count items, none repeating what's already in it. Returns
|
|
// whether it filled any.
|
|
static bool picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDuplicates)
|
|
{
|
|
bool isDealt = false;
|
|
for (int i = 0; i < slots; i++)
|
|
{
|
|
if (loadout[i] >= 0)
|
|
continue;
|
|
int pick = rng.random_get(0, count);
|
|
while (!isDuplicates && std::find(loadout, loadout + slots, pick) != loadout + slots)
|
|
pick = rng.random_get(0, count);
|
|
loadout[i] = pick;
|
|
isDealt = true;
|
|
}
|
|
return isDealt;
|
|
}
|
|
|
|
// Whether a round hands out this kind of item: the first round of the match always, every round, or every period.
|
|
static bool is_pick_round(ItemRules& items, int round, bool isFirst)
|
|
{
|
|
return isFirst || items.timing == PICK_EVERY_ROUND || (items.timing == PICK_PERIOD && round % items.period == 0);
|
|
}
|
|
|
|
static int level_get(ItemRules& items, int round)
|
|
{
|
|
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)
|
|
{
|
|
timer -= TICK;
|
|
for (Tomato& tomato : tomatoes)
|
|
tomato.age += TICK;
|
|
// Offline, the menu's Play starts a game, and leaving the results goes back to the sandbox.
|
|
bool isPlay = !isOnline && isPlayRequested;
|
|
bool isSandbox = !isOnline && state == MATCH_OVER && isSandboxRequested;
|
|
isPlayRequested = false;
|
|
isSandboxRequested = false;
|
|
|
|
if (state != MATCH_PRACTICE)
|
|
leaves_take(sim);
|
|
switch (state)
|
|
{
|
|
case MATCH_PRACTICE:
|
|
case MATCH_OVER:
|
|
if (state == MATCH_OVER)
|
|
tomatoes_update(sim);
|
|
// Online, after a queued game, Ready says to play it again, and once every player still in it is set, it
|
|
// counts up to the server starting afresh with them.
|
|
if (state == MATCH_OVER && isOnline)
|
|
{
|
|
int humans = 0;
|
|
bool isSet = true;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
if (sim.fighters[i].is_pressed(CONTROL_READY) && !isGone[i])
|
|
{
|
|
isReady[i] = !isReady[i];
|
|
sim.cues->sound_play(isReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP);
|
|
}
|
|
bool isHuman = owners[i] >= 0 && !isGone[i];
|
|
humans += isHuman;
|
|
isSet &= !isHuman || isReady[i];
|
|
}
|
|
isSet &= humans >= PLAYERS_MIN || (humans > 0 && rules.isBotFill);
|
|
againTicks = isSet ? againTicks + 1 : 0;
|
|
}
|
|
// In the sandbox, every fighter's edits land, and Ready (R) puts a fighter back at its spawn, still, with
|
|
// its attacks cleared away. (Keys typed into a text field never reach the controls.)
|
|
// Online, Ready in a queued game's area readies up for it instead.
|
|
if (state == MATCH_PRACTICE)
|
|
queues_update(sim);
|
|
for (int i = 0; i < sim.fighterCount && state == MATCH_PRACTICE; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
edit_take(sim, fighter);
|
|
if (!fighter.is_pressed(CONTROL_READY))
|
|
continue;
|
|
if (queuedIn[i] >= 0)
|
|
{
|
|
isQueueReady[i] = !isQueueReady[i];
|
|
sim.cues->sound_play(isQueueReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP);
|
|
continue;
|
|
}
|
|
fighter.position_reset(sim);
|
|
std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == i; });
|
|
}
|
|
if (isPlay)
|
|
start(sim);
|
|
else if (isSandbox && state == MATCH_OVER)
|
|
sandbox_return(sim);
|
|
break;
|
|
case MATCH_COUNTDOWN:
|
|
{
|
|
// Ready sticks once pressed, and bots always are. Readying deals any slots still empty at random. Once
|
|
// everyone is, the wait drops to its last second. Fighters choosing items pick them through their controls.
|
|
bool isEveryone = true;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
edit_take(sim, fighter);
|
|
bool wasReady = isReady[i];
|
|
isReady[i] |= fighter.isBot || fighter.is_down(CONTROL_READY);
|
|
if (isReady[i] && !wasReady && loadout_deal(sim, i))
|
|
sim.cues->deal_play(i);
|
|
isEveryone &= isReady[i];
|
|
}
|
|
if (isEveryone)
|
|
timer = std::min(timer, COUNTDOWN_TIME);
|
|
if (timer <= 0)
|
|
fight_start(sim);
|
|
break;
|
|
}
|
|
case MATCH_FIGHT:
|
|
clock += TICK;
|
|
if (rules.mode == MODE_RACE)
|
|
race_update(sim);
|
|
else
|
|
deathmatch_update(sim);
|
|
break;
|
|
case MATCH_ROUND_OVER:
|
|
tomatoes_update(sim);
|
|
if (timer > 0)
|
|
break;
|
|
if (winner >= 0 && wins[winner] >= rules.wins_get())
|
|
{
|
|
state = MATCH_OVER;
|
|
std::fill(std::begin(isReady), std::end(isReady), false);
|
|
break;
|
|
}
|
|
round += winner >= 0;
|
|
round_start(sim, false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Loads the arena (without the dummy) or builds a fresh course, maps it for the bots, sets everyone's slots to the
|
|
// rules' and starts the first round. In single player, every fighter but this screen's is a bot.
|
|
void Match::start(Sim& sim)
|
|
{
|
|
if (state == MATCH_PRACTICE)
|
|
sandbox = sim.fighters[0];
|
|
winner = -1;
|
|
int humans = sim.fighterCount;
|
|
sim.fighterCount = rules.players;
|
|
if (rules.mode == MODE_RACE)
|
|
sim.world.course_build(sim.rng);
|
|
else
|
|
sim.world.arena_load();
|
|
sim.bot.graph_build(sim.world);
|
|
std::fill(std::begin(wins), std::end(wins), 0);
|
|
round = 0;
|
|
|
|
// Offline, the fighters added beyond the sandbox's are bots. Online (a queued game), the bots are whoever no member
|
|
// plays, and everyone's colour was settled already.
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
fighter.isBot = isOnline ? owners[i] < 0 : i >= humans || fighter.isBot;
|
|
fighter.slots_set(rules.weapons.slots, rules.trinkets.slots);
|
|
}
|
|
if (!isOnline)
|
|
colors_assign(sim);
|
|
sim.cues->match_start();
|
|
round_start(sim, true);
|
|
}
|
|
|
|
// Another player on this screen joins the sandbox, starting from the first fighter's sandbox loadout and settings, with
|
|
// PvP off. Returns its fighter, or -1 when there's no room.
|
|
int Match::sandbox_join(Sim& sim)
|
|
{
|
|
if (sim.fighterCount >= MAX_FIGHTERS)
|
|
return -1;
|
|
int index = sim.fighterCount++;
|
|
Fighter& fighter = sim.fighters[index];
|
|
Fighter& host = sim.fighters[0];
|
|
fighter = host;
|
|
fighter.init(sim, index);
|
|
std::copy(std::begin(host.weaponLoadout), std::end(host.weaponLoadout), fighter.weaponLoadout);
|
|
std::copy(std::begin(host.trinketLoadout), std::end(host.trinketLoadout), fighter.trinketLoadout);
|
|
fighter.isBot = false;
|
|
fighter.isPvp = false;
|
|
fighter.controls = {};
|
|
colors_assign(sim);
|
|
sim.cues->sound_play(SOUND_EQUIP);
|
|
return index;
|
|
}
|
|
|
|
// A fighter leaves the sandbox with its attacks, and the ones after it move down a place.
|
|
void Match::sandbox_leave(Sim& sim, int index)
|
|
{
|
|
std::vector<Attack>& attacks = sim.weapons.attacks;
|
|
std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; });
|
|
for (Attack& attack : attacks)
|
|
attack.owner -= attack.owner > index;
|
|
for (int i = index; i + 1 < sim.fighterCount; i++)
|
|
{
|
|
sim.fighters[i] = sim.fighters[i + 1];
|
|
sim.fighters[i].index = i;
|
|
}
|
|
sim.fighterCount--;
|
|
colors_assign(sim);
|
|
sim.cues->sound_play(SOUND_UNEQUIP);
|
|
}
|
|
|
|
// Gives every fighter a colour of their own: the one they asked for if it's free (the earlier fighter keeps it if two
|
|
// ask for the same), then the rest in palette order. Only the first fighter asks for now (offline, this screen's).
|
|
void Match::colors_assign(Sim& sim)
|
|
{
|
|
int wanted[MAX_FIGHTERS];
|
|
std::fill(std::begin(wanted), std::end(wanted), COLOR_UNASSIGNED);
|
|
wanted[0] = colorWanted;
|
|
bool isTaken[COLOR_COUNT] = {};
|
|
for (int i = 0; i < MAX_FIGHTERS; i++)
|
|
{
|
|
colors[i] = COLOR_UNASSIGNED;
|
|
if (wanted[i] >= 0 && !isTaken[wanted[i]])
|
|
{
|
|
colors[i] = wanted[i];
|
|
isTaken[wanted[i]] = true;
|
|
}
|
|
}
|
|
for (int i = 0; i < MAX_FIGHTERS; i++)
|
|
{
|
|
if (colors[i] >= 0)
|
|
continue;
|
|
colors[i] = std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken;
|
|
isTaken[colors[i]] = true;
|
|
}
|
|
}
|
|
|
|
// Sets everyone's levels and health for the round, and hands out items if it's their round: dealt at random, or
|
|
// emptied for choosing in the menu (bots are dealt theirs) with the menu opened to them.
|
|
void Match::round_start(Sim& sim, bool isFirst)
|
|
{
|
|
isWeaponPick = is_pick_round(rules.weapons, round, isFirst);
|
|
isTrinketPick = is_pick_round(rules.trinkets, round, isFirst);
|
|
sim.weapons.attacks.clear();
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
fighter.levels_fill(level_get(rules.weapons, round), level_get(rules.trinkets, round));
|
|
// Mirrored items are dealt once, to the first fighter, and copied to the rest below. Bots choose nothing then.
|
|
if (isWeaponPick)
|
|
{
|
|
std::fill(fighter.weaponLoadout, fighter.weaponLoadout + fighter.weaponSlots, -1);
|
|
bool isDealt =
|
|
rules.weapons.isMirror ? i == 0 && !rules.weapons.isChoice : !rules.weapons.isChoice || fighter.isBot;
|
|
if (isDealt)
|
|
picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT,
|
|
rules.weapons.isDuplicates);
|
|
}
|
|
if (isTrinketPick)
|
|
{
|
|
std::fill(fighter.trinketLoadout, fighter.trinketLoadout + fighter.trinketSlots, -1);
|
|
bool isDealt = rules.trinkets.isMirror ? i == 0 && !rules.trinkets.isChoice
|
|
: !rules.trinkets.isChoice || fighter.isBot;
|
|
if (isDealt)
|
|
picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT,
|
|
rules.trinkets.isDuplicates);
|
|
}
|
|
fighter.state_reset(sim, health_get());
|
|
// Someone who left a queued game stays out of it.
|
|
fighter.isAlive &= !isGone[i];
|
|
}
|
|
if (isWeaponPick && rules.weapons.isMirror)
|
|
mirror_copy(sim, false);
|
|
if (isTrinketPick && rules.trinkets.isMirror)
|
|
mirror_copy(sim, true);
|
|
isWeaponPick &= rules.weapons.isChoice;
|
|
isTrinketPick &= rules.trinkets.isChoice;
|
|
|
|
std::fill(std::begin(isReady), std::end(isReady), false);
|
|
std::fill(std::begin(finishTimes), std::end(finishTimes), -1.0f);
|
|
clock = 0;
|
|
state = MATCH_COUNTDOWN;
|
|
// Choosing takes longer the more there is to choose: each kind being chosen adds time for every slot past its
|
|
// first.
|
|
countdown = READY_TIME;
|
|
if (isWeaponPick)
|
|
countdown += CHOICE_SLOT_TIME * (rules.weapons.slots - 1);
|
|
if (isTrinketPick)
|
|
countdown += CHOICE_SLOT_TIME * (rules.trinkets.slots - 1);
|
|
timer = countdown;
|
|
}
|
|
|
|
// Applies the edit in a fighter's controls, if any (see Edit). In a match only Choice picks count: an item goes into
|
|
// its slot as long as that kind is being chosen and the slot is in use, leaving any other slot it was in unless
|
|
// duplicates are allowed, and mirrored items go into every fighter's slot. The sandbox takes every kind of edit.
|
|
void Match::edit_take(Sim& sim, Fighter& fighter)
|
|
{
|
|
int code = fighter.controls.edit;
|
|
if (code < 0)
|
|
return;
|
|
fighter.controls.edit = -1;
|
|
Edit edit = edit_decode(code);
|
|
bool isSandbox = state == MATCH_PRACTICE;
|
|
bool isTrinket = edit.isTrinket;
|
|
int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots;
|
|
int items = isTrinket ? TRINKET_COUNT : WEAPON_COUNT;
|
|
int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout;
|
|
int level = std::clamp(edit.value, 0, LEVEL_COUNT - 1);
|
|
switch (edit.type)
|
|
{
|
|
case EDIT_ITEM:
|
|
{
|
|
bool isPicking = isSandbox || (state == MATCH_COUNTDOWN && (isTrinket ? isTrinketPick : isWeaponPick));
|
|
int item = edit.value == EDIT_EMPTY ? -1 : edit.value;
|
|
if (!isPicking || edit.index >= count || item >= items)
|
|
return;
|
|
if (isSandbox)
|
|
{
|
|
fighter.item_set(isTrinket, edit.index, item);
|
|
return;
|
|
}
|
|
ItemRules& kind = isTrinket ? rules.trinkets : rules.weapons;
|
|
for (int i = 0; i < count && !kind.isDuplicates && item >= 0; i++)
|
|
loadout[i] = loadout[i] == item ? -1 : loadout[i];
|
|
loadout[edit.index] = item;
|
|
if (kind.isMirror)
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
std::copy(loadout, loadout + count,
|
|
isTrinket ? sim.fighters[i].trinketLoadout : sim.fighters[i].weaponLoadout);
|
|
return;
|
|
}
|
|
case EDIT_SLOT_LEVEL:
|
|
if (isSandbox && edit.index < count && loadout[edit.index] >= 0)
|
|
{
|
|
int& copy = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[edit.index];
|
|
level_step(sim, copy, level - copy);
|
|
}
|
|
return;
|
|
case EDIT_ITEM_LEVEL:
|
|
{
|
|
if (!isSandbox || edit.index >= items)
|
|
return;
|
|
int& itemLevel = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[edit.index];
|
|
level_step(sim, itemLevel, level - itemLevel);
|
|
for (int i = 0; i < SLOT_MAX; i++)
|
|
if (loadout[i] == edit.index)
|
|
(isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i] = itemLevel;
|
|
return;
|
|
}
|
|
case EDIT_SLOTS:
|
|
if (isSandbox)
|
|
fighter.slots_set(std::clamp(edit.index, 1, SLOT_MAX), std::clamp(edit.value, 1, SLOT_MAX));
|
|
return;
|
|
case EDIT_PVP:
|
|
if (isSandbox)
|
|
fighter.isPvp = edit.value != 0;
|
|
return;
|
|
case EDIT_COLOR:
|
|
{
|
|
bool isTaken = edit.value >= COLOR_COUNT;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
isTaken |= i != fighter.index && colors[i] == edit.value;
|
|
if (isSandbox && !isTaken)
|
|
colors[fighter.index] = edit.value;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// After a round and on the results, everyone but the winner can splat tomatoes where they click, for show, a few a
|
|
// second at most.
|
|
void Match::tomatoes_update(Sim& sim)
|
|
{
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
tomatoCooldowns[i] -= TICK;
|
|
// Bots' controls land after this each tick, so theirs count while held (see Bot::update).
|
|
bool isThrown = fighter.isBot ? fighter.is_down(CONTROL_ATTACK) : fighter.is_pressed(CONTROL_ATTACK);
|
|
if (i == winner || isGone[i] || tomatoCooldowns[i] > 0 || !isThrown)
|
|
continue;
|
|
tomatoCooldowns[i] = TOMATO_COOLDOWN;
|
|
tomatoes[tomatoCount % TOMATO_MAX] = {.position = fighter.controls.cursor, .owner = i, .serial = tomatoCount};
|
|
tomatoCount++;
|
|
}
|
|
}
|
|
|
|
// Copies the first fighter's loadout of one kind of item to every other fighter.
|
|
void Match::mirror_copy(Sim& sim, bool isTrinket)
|
|
{
|
|
Fighter& first = sim.fighters[0];
|
|
for (int i = 1; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
if (isTrinket)
|
|
std::copy(first.trinketLoadout, first.trinketLoadout + SLOT_MAX, fighter.trinketLoadout);
|
|
else
|
|
std::copy(first.weaponLoadout, first.weaponLoadout + SLOT_MAX, fighter.weaponLoadout);
|
|
}
|
|
}
|
|
|
|
// Deals a fighter's empty slots at random, returning whether it filled any. A mirrored kind is dealt only to the
|
|
// first fighter, and copied to everyone.
|
|
bool Match::loadout_deal(Sim& sim, int index)
|
|
{
|
|
Fighter& fighter = sim.fighters[index];
|
|
bool isDealt = false;
|
|
for (int kind = 0; kind < 2; kind++)
|
|
{
|
|
bool isTrinket = kind == 1;
|
|
ItemRules& items = isTrinket ? rules.trinkets : rules.weapons;
|
|
if (items.isMirror && index > 0)
|
|
continue;
|
|
if (isTrinket)
|
|
isDealt |=
|
|
picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, items.isDuplicates);
|
|
else
|
|
isDealt |=
|
|
picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, items.isDuplicates);
|
|
if (items.isMirror)
|
|
mirror_copy(sim, isTrinket);
|
|
}
|
|
return isDealt;
|
|
}
|
|
|
|
// The countdown is over: slots still empty after choosing (by anyone who never readied) are dealt at random, and the
|
|
// menu closes.
|
|
void Match::fight_start(Sim& sim)
|
|
{
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
loadout_deal(sim, i);
|
|
isWeaponPick = false;
|
|
isTrinketPick = false;
|
|
clock = 0;
|
|
state = MATCH_FIGHT;
|
|
}
|
|
|
|
// The round ends with one fighter (or none) left standing, or when time runs out with whoever has the most health,
|
|
// drawn if that's shared.
|
|
void Match::deathmatch_update(Sim& sim)
|
|
{
|
|
int alive = 0;
|
|
winner = -1;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
alive += sim.fighters[i].isAlive;
|
|
winner = sim.fighters[i].isAlive ? i : winner;
|
|
}
|
|
bool isTimeUp = time_limit_get() > 0 && clock >= time_limit_get();
|
|
if (alive > 1 && !isTimeUp)
|
|
return;
|
|
|
|
if (alive > 1)
|
|
{
|
|
float best = 0;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
if (!fighter.isAlive || fighter.health < best)
|
|
continue;
|
|
winner = fighter.health == best ? -1 : i;
|
|
best = fighter.health;
|
|
}
|
|
}
|
|
if (winner >= 0)
|
|
wins[winner]++;
|
|
state = MATCH_ROUND_OVER;
|
|
timer = ROUND_OVER_TIME;
|
|
sim.cues->sound_play(SOUND_FINISH);
|
|
}
|
|
|
|
// Whether the coming deathmatch round decides the match whoever wins it: every fighter is a win short of taking it.
|
|
bool Match::is_final_round(Sim& sim)
|
|
{
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
if (wins[i] != rules.wins_get() - 1)
|
|
return false;
|
|
return rules.mode != MODE_RACE;
|
|
}
|
|
|
|
// A round's health: the rules' for the first, multiplied for each round after.
|
|
float Match::health_get()
|
|
{
|
|
return rules.health * math_pow(rules.healthMultiplier, round);
|
|
}
|
|
|
|
// A round's time limit in seconds, or 0 for none.
|
|
float Match::time_limit_get()
|
|
{
|
|
return rules.timer * SECONDS_PER_MINUTE;
|
|
}
|
|
|
|
// Whether hits only knock fighters about: any part of a race.
|
|
bool Match::is_racing()
|
|
{
|
|
return rules.mode == MODE_RACE && state != MATCH_PRACTICE;
|
|
}
|
|
|
|
// Sees fighters over the finish line, then ends the race once nobody is left running, a while after the winner
|
|
// finished, or when time runs out, when the racer furthest along wins if nobody has finished.
|
|
void Match::race_update(Sim& sim)
|
|
{
|
|
int running = 0;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
if (fighter.isAlive && sim.world.is_touching(fighter.body.box, TILE_FINISH))
|
|
{
|
|
finishTimes[i] = clock;
|
|
winner = winner < 0 ? i : winner;
|
|
fighter.isAlive = false;
|
|
fighter.charge_cancel(sim);
|
|
for (Attack& attack : sim.weapons.attacks)
|
|
attack.isDead |= attack.owner == i;
|
|
sim.cues->burst_spawn(FINISH_BURST, fighter.center_get(), 0, i);
|
|
sim.cues->sound_play(SOUND_FINISH);
|
|
}
|
|
running += fighter.isAlive;
|
|
}
|
|
|
|
bool isGraceOver = winner >= 0 && clock > finishTimes[winner] + RACE_GRACE;
|
|
bool isTimeUp = time_limit_get() > 0 && clock > time_limit_get();
|
|
if (running > 0 && !isGraceOver && !isTimeUp)
|
|
return;
|
|
float furthest = -INFINITY;
|
|
for (int i = 0; i < sim.fighterCount && finishTimes[std::max(0, winner)] < 0; i++)
|
|
{
|
|
float x = sim.fighters[i].center_get().x;
|
|
if (sim.fighters[i].isAlive && x > furthest)
|
|
{
|
|
winner = i;
|
|
furthest = x;
|
|
}
|
|
}
|
|
state = MATCH_OVER;
|
|
}
|
|
|
|
// Back to the sandbox: the arena and its dummy, the local fighter alone with its own slots, loadout and levels again.
|
|
void Match::sandbox_return(Sim& sim)
|
|
{
|
|
state = MATCH_PRACTICE;
|
|
isWeaponPick = false;
|
|
isTrinketPick = false;
|
|
sim.fighterCount = 1;
|
|
sim.world.sandbox_load();
|
|
sim.bot.graph_build(sim.world);
|
|
sim.weapons.attacks.clear();
|
|
sim.cues->sandbox_return();
|
|
|
|
Fighter& fighter = sim.fighters[0];
|
|
fighter.slots_set(sandbox.weaponSlots, sandbox.trinketSlots);
|
|
std::copy(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), fighter.weaponLoadout);
|
|
std::copy(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), fighter.trinketLoadout);
|
|
std::copy(std::begin(sandbox.weaponItemLevels), std::end(sandbox.weaponItemLevels), fighter.weaponItemLevels);
|
|
std::copy(std::begin(sandbox.trinketItemLevels), std::end(sandbox.trinketItemLevels), fighter.trinketItemLevels);
|
|
std::copy(std::begin(sandbox.weaponSlotLevels), std::end(sandbox.weaponSlotLevels), fighter.weaponSlotLevels);
|
|
std::copy(std::begin(sandbox.trinketSlotLevels), std::end(sandbox.trinketSlotLevels), fighter.trinketSlotLevels);
|
|
fighter.state_reset(sim, 1);
|
|
}
|
|
|
|
// Online, opens a fighter's queued game: an area on the ground big enough for everyone it's for, as near the fighter
|
|
// as there's room (on the floor below it, or the nearest stretch of floor to either side that's flat, clear overhead
|
|
// and not another queue's), with the fighter standing still at its left end, readied up.
|
|
void Match::queue_open(Sim& sim, int fighter, const Rules& wanted)
|
|
{
|
|
World& world = sim.world;
|
|
Queue& queue = queues[fighter];
|
|
queue = {.rules = rules_clean(wanted), .isActive = true};
|
|
float width = queue.rules.players * QUEUE_SLOT_WIDTH + QUEUE_MARGIN * 2;
|
|
Vec2 size = world.size_get();
|
|
Fighter& queuer = sim.fighters[fighter];
|
|
Vec2 feet = {queuer.body.box.x + queuer.body.box.w / 2, queuer.body.box.y + queuer.body.box.h};
|
|
|
|
// The top of the first floor at or below a point, if there's one.
|
|
auto floor_find = [&](float x, float from) {
|
|
for (float y = std::max(from, 1.0f); y < size.y; y++)
|
|
if (world.is_blocking_at({x, y}) && !world.is_blocking_at({x, y - 1}))
|
|
return y;
|
|
return -1.0f;
|
|
};
|
|
auto is_fitting = [&](float left, float floor) {
|
|
if (left < 0 || left + width > size.x || floor < QUEUE_HEIGHT)
|
|
return false;
|
|
for (float x = left; x <= left + width; x += QUEUE_PROBE_STEP)
|
|
{
|
|
if (!world.is_blocking_at({x, floor + 1}) || world.is_blocking_at({x, floor - 1}))
|
|
return false;
|
|
for (float up = QUEUE_PROBE_STEP; up <= QUEUE_HEIGHT; up += QUEUE_PROBE_STEP)
|
|
if (world.is_solid_at({x, floor - up}))
|
|
return false;
|
|
}
|
|
Rect area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT};
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
if (i != fighter && queues[i].isActive && is_rect_overlapping(area, queues[i].area))
|
|
return false;
|
|
return true;
|
|
};
|
|
float start = std::round(feet.x - QUEUE_MARGIN - QUEUE_SLOT_WIDTH / 2);
|
|
queue.area = {start, feet.y - QUEUE_HEIGHT, width, QUEUE_HEIGHT};
|
|
for (float offset = 0; offset < size.x; offset += QUEUE_PROBE_STEP)
|
|
{
|
|
bool isFound = false;
|
|
for (float left : {start + offset, start - offset})
|
|
{
|
|
float floor = floor_find(left + width / 2, std::round(feet.y - TILE_SIZE));
|
|
if (floor < 0 || !is_fitting(left, floor))
|
|
continue;
|
|
queue.area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT};
|
|
isFound = true;
|
|
break;
|
|
}
|
|
if (isFound)
|
|
break;
|
|
}
|
|
queuer.position_set(sim, {queue.area.x + QUEUE_MARGIN + QUEUE_SLOT_WIDTH / 2, queue.area.y + queue.area.h});
|
|
std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == fighter; });
|
|
queuedIn[fighter] = fighter;
|
|
isQueueReady[fighter] = false;
|
|
sim.cues->sound_play(SOUND_EQUIP);
|
|
}
|
|
|
|
// Who's in a queued game's area: the queuer first, then the rest in fighter order. Returns how many.
|
|
int Match::queue_members_get(Sim& sim, int queuer, int* members)
|
|
{
|
|
int count = 0;
|
|
members[count++] = queuer;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
if (i != queuer && queuedIn[i] == queuer)
|
|
members[count++] = i;
|
|
return count;
|
|
}
|
|
|
|
// Whether a queued game's area has everyone it's for.
|
|
bool Match::is_queue_full(Sim& sim, int queuer)
|
|
{
|
|
int members[MAX_FIGHTERS];
|
|
return queue_members_get(sim, queuer, members) >= queues[queuer].rules.players;
|
|
}
|
|
|
|
// Whether a queued game is set to start: everyone in its area has readied up, and either it's full or it fills its
|
|
// empty places with bots (and isn't over full). The queuer readying is what starts one with bots.
|
|
bool Match::is_queue_set(Sim& sim, int queuer)
|
|
{
|
|
Queue& queue = queues[queuer];
|
|
if (!queue.isActive)
|
|
return false;
|
|
int members[MAX_FIGHTERS];
|
|
int count = queue_members_get(sim, queuer, members);
|
|
bool isReadied = true;
|
|
for (int i = 0; i < count; i++)
|
|
isReadied &= isQueueReady[members[i]];
|
|
return isReadied && (count == queue.rules.players || (queue.rules.isBotFill && count < queue.rules.players));
|
|
}
|
|
|
|
// The queued game to start now, if any: one that's been set to for a moment (so a change of mind can stop it, and
|
|
// the Ready that set it is let go before its countdown, which would take it).
|
|
int Match::queue_startable_get(Sim& sim)
|
|
{
|
|
for (int queuer = 0; queuer < sim.fighterCount; queuer++)
|
|
if (is_queue_set(sim, queuer) && queues[queuer].readyTicks >= QUEUE_START_TICKS)
|
|
return queuer;
|
|
return -1;
|
|
}
|
|
|
|
// Moves the per-fighter queue state down a place from a fighter leaving, so it stays with its fighters.
|
|
void Match::queue_slots_shift(int from, int count)
|
|
{
|
|
for (int i = from; i + 1 < count; i++)
|
|
{
|
|
queues[i] = queues[i + 1];
|
|
isGone[i] = isGone[i + 1];
|
|
}
|
|
if (count > 0)
|
|
{
|
|
queues[count - 1] = {};
|
|
isGone[count - 1] = false;
|
|
}
|
|
std::fill(std::begin(queuedIn), std::end(queuedIn), -1);
|
|
std::fill(std::begin(isQueueReady), std::end(isQueueReady), false);
|
|
}
|
|
|
|
// Who stands in which queued game's area this tick (anyone away playing stands in none, and a queuer always in its
|
|
// own). Stepping out unreadies. A queuer can't move or attack (only crouch, and ready up), and anyone away does
|
|
// nothing.
|
|
void Match::queues_update(Sim& sim)
|
|
{
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
int in = -1;
|
|
if (queues[i].isActive)
|
|
in = i;
|
|
Vec2 feet = {fighter.body.box.x + fighter.body.box.w / 2, fighter.body.box.y + fighter.body.box.h};
|
|
for (int j = 0; j < sim.fighterCount && in < 0; j++)
|
|
{
|
|
Rect area = queues[j].area;
|
|
area = {area.x - QUEUE_FEET_SLACK, area.y, area.w + QUEUE_FEET_SLACK * 2, area.h + QUEUE_FEET_SLACK};
|
|
if (queues[j].isActive && fighter.isAlive && is_point_in_rect(feet, area))
|
|
in = j;
|
|
}
|
|
if (in != queuedIn[i])
|
|
isQueueReady[i] = false;
|
|
queuedIn[i] = in;
|
|
if (queues[i].isActive)
|
|
{
|
|
Uint16 kept = 1 << CONTROL_DOWN | 1 << CONTROL_READY;
|
|
fighter.controls.buttons &= kept;
|
|
}
|
|
}
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
queues[i].readyTicks = is_queue_set(sim, i) ? queues[i].readyTicks + 1 : 0;
|
|
}
|
|
|
|
// In a queued game, how many players (not bots) haven't left it.
|
|
int Match::humans_left_get(Sim& sim)
|
|
{
|
|
int count = 0;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
count += owners[i] >= 0 && !isGone[i];
|
|
return count;
|
|
}
|
|
|
|
// Online, in a queued game, a player leaving it (their Leave edit): they're out for good, which counts as a KO. With
|
|
// only one player left after that, they win the match outright.
|
|
void Match::leaves_take(Sim& sim)
|
|
{
|
|
bool isLeft = false;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = sim.fighters[i];
|
|
if (fighter.controls.edit < 0 || edit_decode(fighter.controls.edit).type != EDIT_LEAVE)
|
|
continue;
|
|
fighter.controls.edit = -1;
|
|
if (isGone[i])
|
|
continue;
|
|
isGone[i] = true;
|
|
isForfeited[i] = state != MATCH_OVER;
|
|
isReady[i] = false;
|
|
fighter.isAlive = false;
|
|
std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == i; });
|
|
isLeft = true;
|
|
}
|
|
if (!isLeft || state == MATCH_OVER)
|
|
return;
|
|
int left = -1;
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
left = owners[i] >= 0 && !isGone[i] ? (left == -1 ? i : -2) : left;
|
|
if (left == -2)
|
|
return;
|
|
winner = left;
|
|
state = MATCH_OVER;
|
|
std::fill(std::begin(isReady), std::end(isReady), false);
|
|
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 knows them).
|
|
Profile profile_get(Fighter& fighter)
|
|
{
|
|
Profile profile;
|
|
profile.weaponSlots = fighter.weaponSlots;
|
|
profile.trinketSlots = fighter.trinketSlots;
|
|
std::copy(std::begin(fighter.weaponLoadout), std::end(fighter.weaponLoadout), profile.weaponLoadout);
|
|
std::copy(std::begin(fighter.trinketLoadout), std::end(fighter.trinketLoadout), profile.trinketLoadout);
|
|
std::copy(std::begin(fighter.weaponSlotLevels), std::end(fighter.weaponSlotLevels), profile.weaponSlotLevels);
|
|
std::copy(std::begin(fighter.trinketSlotLevels), std::end(fighter.trinketSlotLevels), profile.trinketSlotLevels);
|
|
std::copy(std::begin(fighter.weaponItemLevels), std::end(fighter.weaponItemLevels), profile.weaponItemLevels);
|
|
std::copy(std::begin(fighter.trinketItemLevels), std::end(fighter.trinketItemLevels), profile.trinketItemLevels);
|
|
profile.isPvp = fighter.isPvp;
|
|
return profile;
|
|
}
|
|
|
|
// Online, a member's fighter appears, set up from their profile, in the colour they asked for if it's free. Returns
|
|
// it, or -1 when the game is full.
|
|
int Match::member_join(Sim& sim, int member, const Profile& profile)
|
|
{
|
|
if (sim.fighterCount >= MAX_FIGHTERS)
|
|
return -1;
|
|
int index = sim.fighterCount++;
|
|
Fighter& fighter = sim.fighters[index];
|
|
fighter = Fighter{};
|
|
fighter.init(sim, index);
|
|
fighter.slots_set(std::clamp(profile.weaponSlots, 1, SLOT_MAX), std::clamp(profile.trinketSlots, 1, SLOT_MAX));
|
|
std::copy(std::begin(profile.weaponLoadout), std::end(profile.weaponLoadout), fighter.weaponLoadout);
|
|
std::copy(std::begin(profile.trinketLoadout), std::end(profile.trinketLoadout), fighter.trinketLoadout);
|
|
std::copy(std::begin(profile.weaponSlotLevels), std::end(profile.weaponSlotLevels), fighter.weaponSlotLevels);
|
|
std::copy(std::begin(profile.trinketSlotLevels), std::end(profile.trinketSlotLevels), fighter.trinketSlotLevels);
|
|
std::copy(std::begin(profile.weaponItemLevels), std::end(profile.weaponItemLevels), fighter.weaponItemLevels);
|
|
std::copy(std::begin(profile.trinketItemLevels), std::end(profile.trinketItemLevels), fighter.trinketItemLevels);
|
|
fighter.isPvp = profile.isPvp;
|
|
owners[index] = member;
|
|
std::memcpy(names[index], profile.name, sizeof(names[index]));
|
|
names[index][PLAYER_NAME_MAX] = '\0';
|
|
bool isTaken[COLOR_COUNT] = {};
|
|
for (int i = 0; i < index; i++)
|
|
isTaken[colors[i]] = true;
|
|
bool isFree = profile.color >= 0 && profile.color < COLOR_COUNT && !isTaken[profile.color];
|
|
colors[index] = isFree ? profile.color : (std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken) % COLOR_COUNT;
|
|
return index;
|
|
}
|
|
|
|
// Online, a member's fighter goes with its attacks, and the fighters after it move down a place.
|
|
void Match::member_leave(Sim& sim, int member)
|
|
{
|
|
int index = fighter_find(sim, member);
|
|
if (index < 0)
|
|
return;
|
|
std::vector<Attack>& attacks = sim.weapons.attacks;
|
|
std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; });
|
|
for (Attack& attack : attacks)
|
|
attack.owner -= attack.owner > index;
|
|
for (int i = index; i + 1 < sim.fighterCount; i++)
|
|
{
|
|
sim.fighters[i] = sim.fighters[i + 1];
|
|
sim.fighters[i].index = i;
|
|
sim.bot.states[i] = sim.bot.states[i + 1];
|
|
owners[i] = owners[i + 1];
|
|
colors[i] = colors[i + 1];
|
|
std::memcpy(names[i], names[i + 1], sizeof(names[i]));
|
|
}
|
|
queue_slots_shift(index, sim.fighterCount);
|
|
sim.fighterCount--;
|
|
owners[sim.fighterCount] = -1;
|
|
}
|
|
|
|
// Which fighter a member plays, or -1 for none.
|
|
int Match::fighter_find(Sim& sim, int member)
|
|
{
|
|
for (int i = 0; i < sim.fighterCount; i++)
|
|
if (owners[i] == member)
|
|
return i;
|
|
return -1;
|
|
}
|