#include "match.h" #include "sim.h" #include #include #include 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& 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& 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; }