- 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>
1006 lines
41 KiB
C++
1006 lines
41 KiB
C++
#include "scene.h"
|
|
#include "game.h"
|
|
#include <SDL3/SDL_timer.h>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <string>
|
|
|
|
constexpr int BLINK_MILLISECONDS = 80;
|
|
|
|
constexpr float TRAJECTORY_ALPHA = 0.12f;
|
|
constexpr float TRAJECTORY_TAPER = 0.25f;
|
|
|
|
constexpr float LINK_WIDTH = 1;
|
|
constexpr float SURFACE_WIDTH = 2;
|
|
|
|
constexpr float BLOCK_WARN = 1;
|
|
constexpr Vec2 CORNER_DIRECTIONS[] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}};
|
|
constexpr int CORNER_SIZE = 2;
|
|
constexpr float RAMP_OUTLINE = 2;
|
|
|
|
constexpr float CHECKPOINT_ALPHA = 0.3f;
|
|
constexpr float CHECKPOINT_HEIGHT = 24;
|
|
// Animations drawn facing their own way, picked by the way the fighter moves (right, then left). The rest are
|
|
// mirrored to face left.
|
|
constexpr const char* CRAWL_ANIMATIONS[] = {"Crawl Right", "Crawl Left"};
|
|
constexpr float POUND_RING_ALPHA = 0.25f;
|
|
constexpr const char* WALK_ANIMATIONS[] = {"Walk Right", "Walk Left"};
|
|
|
|
constexpr Burst AURA_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.3f, .count = 1};
|
|
constexpr float BEAM_INNER = 4;
|
|
constexpr float BEAM_OUTER = 10;
|
|
constexpr float BOLT_JITTER = 2;
|
|
constexpr float BOLT_SEGMENT = 8;
|
|
constexpr float BOLT_WIDTH = 1;
|
|
constexpr float BOOMERANG_SPIN = 20;
|
|
constexpr float CHAIN_SPACING = 8;
|
|
// How hard a charging weapon shakes against an attack filled in as far.
|
|
constexpr float CHARGE_SHAKE = 0.5f;
|
|
constexpr float COLLISION_ALPHA = 0.25f;
|
|
constexpr float EXPLOSION_FLASH = 0.05f;
|
|
constexpr float EXPLOSION_GROW = 4;
|
|
constexpr float EXPLOSION_OUTLINE = 2;
|
|
constexpr float FALLOFF_CURVE = 1.5f;
|
|
constexpr float FALLOFF_DIM = 0.15f;
|
|
constexpr float FALLOFF_STEP = 6;
|
|
constexpr float FILL_JITTER = 2;
|
|
constexpr float FILL_PARTICLES = 60;
|
|
constexpr float FLAME_FADE = 3;
|
|
constexpr float FLAME_GROW = 0.8f;
|
|
constexpr float FLAME_SHRINK = 0.6f;
|
|
constexpr float FLAME_STAGGER = 1.12f;
|
|
constexpr float GRAPPLE_HANDLE = 7;
|
|
constexpr float HELD_ICON_SCALE = 0.6f;
|
|
constexpr float KICK_DECAY = 0.82f;
|
|
constexpr float KICK_SWEEP = 0.1f;
|
|
constexpr float MINE_BLINK = 0.6f;
|
|
constexpr float MINE_BLINK_ON = 0.1f;
|
|
constexpr float PIP_GAP = 3;
|
|
constexpr float PIP_SIZE = 2;
|
|
constexpr float PIP_SPIN = 8;
|
|
constexpr float RELOAD_DROP = 5;
|
|
constexpr float RELOAD_PUMP = 4;
|
|
constexpr float RELOAD_START = 0.2f;
|
|
constexpr float ROPE_INNER = 1;
|
|
constexpr float ROPE_OUTER = 3;
|
|
constexpr float TELEPORT_CORE = 0.4f;
|
|
constexpr float TELEPORT_RING_ALPHA = 0.35f;
|
|
constexpr float WHIP_INNER = 2;
|
|
constexpr float WHIP_JITTER = 0.5f;
|
|
constexpr float WHIP_OUTER = 5;
|
|
constexpr float WISP_FRAME_STAGGER = 0.3f;
|
|
constexpr Burst TOMATO_BURST = {.gravity = 400, .life = 0.5f, .size = 2, .speed = 140, .spread = 1.2f, .count = 12};
|
|
|
|
// A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage
|
|
// number.
|
|
static Vec2 jitter_get(float fill)
|
|
{
|
|
float reach = FILL_JITTER * fill;
|
|
return {random_get(-reach, reach), random_get(-reach, reach)};
|
|
}
|
|
|
|
// Sheds particles off the edge of an empowered shape, more the more its colour fills in. They take the shape's colour
|
|
// and fill in just as far.
|
|
static void aura_spawn(Game& game, Capsule shape, float fill, Color color)
|
|
{
|
|
Burst burst = AURA_BURST;
|
|
burst.fill = fill;
|
|
int count = FILL_PARTICLES * fill * TICK + random_get(0, 1);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
float angle = random_get(-PI, PI);
|
|
Vec2 point = vec2_lerp(shape.from, shape.to, random_get(0, 1)) + vec2_polar(angle, shape.radius);
|
|
game.effects.burst(burst, point, angle, color);
|
|
}
|
|
}
|
|
|
|
// A colour decayed to match a weakened strength: drained toward grey and darkened, faster than the damage drops so
|
|
// weakened shots read clearly.
|
|
static Color color_dim(Color color, float strength)
|
|
{
|
|
strength = math_pow(std::min(1.0f, strength), FALLOFF_CURVE);
|
|
float grey = (color.r + color.g + color.b) / 3;
|
|
Color washed = {math_lerp(grey, color.r, strength), math_lerp(grey, color.g, strength),
|
|
math_lerp(grey, color.b, strength), color.a};
|
|
return color_shade(washed, FALLOFF_DIM + (1 - FALLOFF_DIM) * strength);
|
|
}
|
|
|
|
// The owner's colour (darker for secondary attacks), dimming as a shot's damage falls off.
|
|
static Color owner_color_get(Game& game, Attack& attack)
|
|
{
|
|
return color_shade(game.color_get(attack.owner), attack.isSecondary ? SECONDARY_SHADE : 1);
|
|
}
|
|
|
|
static Color tint_get(Game& game, Attack& attack)
|
|
{
|
|
return color_dim(owner_color_get(game, attack), strength_get(attack, attack.tip));
|
|
}
|
|
|
|
// A path drawn as an outlined line: an outer width around a black core of the inner width, kinked by jitter.
|
|
struct Stroke
|
|
{
|
|
std::vector<Vec2> points;
|
|
float outer = 0;
|
|
float inner = 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
|
|
// 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)
|
|
{
|
|
Color color = owner_color_get(game, attack);
|
|
float base = attack.bonus * (1 + attack.ricochets * RICOCHET_BONUS);
|
|
Vec2 shake = jitter_get(fill_get(base));
|
|
std::vector<Vec2> points;
|
|
std::vector<float> distances;
|
|
float distance = attack.def()->kind == ATTACK_BEAM ? attack.travelled : 0;
|
|
for (size_t i = 0; i + 1 < stroke.points.size(); i++)
|
|
{
|
|
Vec2 from = stroke.points[i];
|
|
Vec2 delta = stroke.points[i + 1] - from;
|
|
Vec2 normal = vec2_normalize({-delta.y, delta.x});
|
|
float length = vec2_length(delta);
|
|
int steps = length / FALLOFF_STEP + 1;
|
|
for (int step = 0; step < steps; step++)
|
|
{
|
|
bool isEnd = i == 0 && step == 0;
|
|
float nudge = isEnd ? 0 : random_get(-stroke.jitter, stroke.jitter);
|
|
points.push_back(from + delta * ((float)step / steps) + normal * nudge + shake);
|
|
distances.push_back(distance + length * (step + 0.5f) / steps);
|
|
}
|
|
distance += length;
|
|
}
|
|
if (stroke.points.empty())
|
|
return;
|
|
points.push_back(stroke.points.back() + shake);
|
|
|
|
for (int layer = 0; layer < 2; layer++)
|
|
{
|
|
for (size_t i = 0; i + 1 < points.size(); i++)
|
|
{
|
|
float strength = base * falloff_at(distances[i]);
|
|
if (layer == 0)
|
|
game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength));
|
|
else
|
|
game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// A kinked line from one end of the capsule to the other, its radius as the width. Each kink is nudged sideways
|
|
// by up to jitter.
|
|
static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color color)
|
|
{
|
|
Vec2 delta = bolt.to - bolt.from;
|
|
Vec2 normal = vec2_normalize({-delta.y, delta.x});
|
|
int kinks = vec2_length(delta) / BOLT_SEGMENT + 1;
|
|
Vec2 previous = bolt.from;
|
|
|
|
for (int k = 1; k <= kinks; k++)
|
|
{
|
|
float nudge = k == kinks ? 0 : random_get(-jitter, jitter);
|
|
Vec2 point = bolt.from + delta * ((float)k / kinks) + normal * nudge;
|
|
renderer.line_draw(previous, point, bolt.radius, color);
|
|
previous = point;
|
|
}
|
|
}
|
|
|
|
// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even.
|
|
static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color)
|
|
{
|
|
Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY};
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
Capsule& shape = shapes[i];
|
|
bounds.x = std::min({bounds.x, shape.from.x - shape.radius, shape.to.x - shape.radius});
|
|
bounds.y = std::min({bounds.y, shape.from.y - shape.radius, shape.to.y - shape.radius});
|
|
bounds.w = std::max({bounds.w, shape.from.x + shape.radius, shape.to.x + shape.radius});
|
|
bounds.h = std::max({bounds.h, shape.from.y + shape.radius, shape.to.y + shape.radius});
|
|
}
|
|
auto isInside = [&](Vec2 point) {
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
Vec2 line = shapes[i].to - shapes[i].from;
|
|
float length = vec2_dot(line, line);
|
|
float t = length > 0 ? std::clamp(vec2_dot(point - shapes[i].from, line) / length, 0.0f, 1.0f) : 0;
|
|
if (vec2_length(point - (shapes[i].from + line * t)) <= shapes[i].radius)
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
for (float y = std::floor(bounds.y); y <= bounds.h; y++)
|
|
{
|
|
float start = -INFINITY;
|
|
for (float x = std::floor(bounds.x); x <= bounds.w + 1; x++)
|
|
{
|
|
bool isIn = x <= bounds.w && isInside({x + 0.5f, y + 0.5f});
|
|
if (isIn && start == -INFINITY)
|
|
start = x;
|
|
if (!isIn && start != -INFINITY)
|
|
{
|
|
renderer.rect_draw({start, y, x - start, 1}, color);
|
|
start = -INFINITY;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The held weapon's turn away from the aim: a quick eased sweep from the kick to the follow-through, which then
|
|
// settles back to the aim.
|
|
static float kick_get(Fighter& fighter)
|
|
{
|
|
if (fighter.kickTime < KICK_SWEEP)
|
|
{
|
|
float t = fighter.kickTime / KICK_SWEEP;
|
|
return math_lerp(fighter.kickFrom, fighter.kickTo, t * (2 - t));
|
|
}
|
|
return fighter.kickTo * math_pow(KICK_DECAY, (fighter.kickTime - KICK_SWEEP) / TICK);
|
|
}
|
|
|
|
// How much of a weapon's cooldown is left, from 1 just fired to 0 ready.
|
|
static float cooldown_left_get(Fighter& fighter, int key)
|
|
{
|
|
float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1);
|
|
return total > 0 ? std::clamp(fighter.cooldowns[key] / total, 0.0f, 1.0f) : 0;
|
|
}
|
|
|
|
// A reloading weapon's pose through its cooldown: once the kick has played out it tips up to stand vertical, dips
|
|
// down and back like a pump, then swings back to the aim just as it's ready.
|
|
static void reload_pose(Fighter& fighter, int key, Draw& draw)
|
|
{
|
|
if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || fighter.cooldowns[key] <= 0)
|
|
return;
|
|
float t = std::clamp((1 - cooldown_left_get(fighter, key) - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f);
|
|
float weight = math_sin(PI * t);
|
|
draw.angle = angle_lerp(draw.angle, -PI / 2, weight);
|
|
draw.position.y += RELOAD_DROP * math_pow(weight, RELOAD_PUMP);
|
|
}
|
|
|
|
// How far a fighter's weapons (and crosshair) fill in with its colour: with the charge as it builds, or the speed bonus
|
|
// if that's more, then once the charge is ready blinking between full and the speed bonus alone.
|
|
float Scene::charge_fill_get(Fighter& fighter)
|
|
{
|
|
float buff = game.sim.weapons.buff_get(fighter);
|
|
if (fighter.charge >= 1)
|
|
return is_flashing(fighter) ? 1.0f : buff;
|
|
return std::max(buff, fighter.charge);
|
|
}
|
|
|
|
// How far a fighter's weapon (by key, or -1 for none) and crosshair shake this frame: harder the further its charge has
|
|
// got, and from fully charged as the weapon is used, easing to still as its cooldown runs out.
|
|
Vec2 Scene::weapon_shake_get(Fighter& fighter, int key)
|
|
{
|
|
float left = key < 0 ? 0 : cooldown_left_get(fighter, key);
|
|
return jitter_get(std::max(fighter.charge, left * left) * CHARGE_SHAKE);
|
|
}
|
|
|
|
static bool is_pair(const char* const (&pair)[2], const char* animation)
|
|
{
|
|
return animation == pair[0] || animation == pair[1];
|
|
}
|
|
|
|
Scene::Scene(Game& game) : game(game)
|
|
{
|
|
std::fill(std::begin(chargeVoices), std::end(chargeVoices), -1);
|
|
}
|
|
|
|
void Scene::sound_play(SoundId sound)
|
|
{
|
|
if (!isMuted)
|
|
game.audio.play(sound);
|
|
}
|
|
|
|
void Scene::charge_play(Fighter& fighter)
|
|
{
|
|
if (!isMuted)
|
|
chargeVoices[fighter.index] = game.audio.play(SOUND_CHARGE);
|
|
}
|
|
|
|
void Scene::charge_stop(Fighter& fighter)
|
|
{
|
|
game.audio.stop(chargeVoices[fighter.index]);
|
|
chargeVoices[fighter.index] = -1;
|
|
}
|
|
|
|
void Scene::burst_spawn(Burst burst, Vec2 position, float angle, int owner)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.burst(burst, position, angle, game.color_get(owner));
|
|
}
|
|
|
|
void Scene::attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.burst(burst, position, angle, tint_get(game, attack));
|
|
}
|
|
|
|
void Scene::shake_add(float amount)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.shake_add(amount);
|
|
}
|
|
|
|
void Scene::flash_spawn(Vec2 position, float angle, float size, int owner)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.flash_spawn(position, angle, size, game.color_get(owner));
|
|
}
|
|
|
|
void Scene::ring_spawn(Vec2 position, float radius, int owner)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.ring_spawn(position, radius, game.color_get(owner));
|
|
}
|
|
|
|
void Scene::fighter_afterimage_spawn(Fighter& fighter)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.afterimage_spawn(fighter_pose_get(fighter));
|
|
}
|
|
|
|
void Scene::attack_afterimage_spawn(Attack& attack)
|
|
{
|
|
if (!isMuted)
|
|
game.effects.afterimage_spawn(attack_pose(attack));
|
|
}
|
|
|
|
void Scene::fighter_aura_spawn(Fighter& fighter)
|
|
{
|
|
if (isMuted)
|
|
return;
|
|
Rect box = fighter.body.box;
|
|
float radius = box.w / 2;
|
|
aura_spawn(game, {{box.x + radius, box.y + radius}, {box.x + radius, box.y + box.h - radius}, radius},
|
|
game.sim.weapons.buff_get(fighter), game.color_get(fighter.index));
|
|
}
|
|
|
|
void Scene::attack_aura_spawn(Attack& attack)
|
|
{
|
|
if (!isMuted)
|
|
aura_spawn(game, {attack.position, attack.tip, attack.radius}, fill_get(strength_get(attack, attack.tip)),
|
|
tint_get(game, attack));
|
|
}
|
|
|
|
void Scene::number_spawn(Damage& damage)
|
|
{
|
|
if (isMuted)
|
|
return;
|
|
game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner));
|
|
game.effects.dps_record(damage.amount);
|
|
}
|
|
|
|
// A level going up or down chimes, and outside the menu pops up over this screen's fighter.
|
|
void Scene::level_step(bool isUp)
|
|
{
|
|
if (isMuted)
|
|
return;
|
|
game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN);
|
|
if (game.menu.isOpen)
|
|
return;
|
|
Fighter& fighter = game.local_get();
|
|
Vec2 head = {fighter.center_get().x, fighter.body.box.y};
|
|
game.effects.popup_spawn(head, isUp ? "Level Up" : "Level Down", isUp ? game.ui.tint : game.ui.shade);
|
|
}
|
|
|
|
// The menu closes for whoever plays a starting match on this screen (online, a queued game starting doesn't close a
|
|
// bystander's).
|
|
void Scene::match_start()
|
|
{
|
|
if (game.local_count() > 0)
|
|
game.menu.isOpen = false;
|
|
}
|
|
|
|
// A tomato thrown on the results: a splat in the thrower's colour where it landed, spraying juice.
|
|
void Scene::tomato_splat(Vec2 position, int owner)
|
|
{
|
|
if (isMuted)
|
|
return;
|
|
Color color = game.color_get(owner, true);
|
|
game.effects.splat_spawn(position, color);
|
|
game.effects.burst(TOMATO_BURST, position, -PI / 2, color);
|
|
game.audio.play(SOUND_SPLAT);
|
|
}
|
|
|
|
// Readying with empty slots dealt them: this screen's own fighters hear the items go in.
|
|
void Scene::deal_play(int fighter)
|
|
{
|
|
if (!isMuted && game.is_local(fighter))
|
|
game.audio.play(SOUND_EQUIP);
|
|
}
|
|
|
|
void Scene::sandbox_return()
|
|
{
|
|
game.menu.isOpen = false;
|
|
}
|
|
|
|
// A fully charged fighter's weapons blink, by the clock rather than the game.
|
|
bool Scene::is_flashing(Fighter& fighter)
|
|
{
|
|
return fighter.charge >= 1 && SDL_GetTicks() / BLINK_MILLISECONDS % 2;
|
|
}
|
|
|
|
// Each tick, every fighter's animator plays whatever its fighter is doing.
|
|
void Scene::update()
|
|
{
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
animator_update(game.sim.fighters[i], game.sprites.fighters[i]);
|
|
}
|
|
|
|
// The game between its last two ticks: the world, the bots' map (when debugging), afterimages, blasts under the
|
|
// fighters, the fighters, their attacks, and the hit shapes when they're shown.
|
|
void Scene::draw()
|
|
{
|
|
world_draw();
|
|
bot_draw();
|
|
game.effects.afterimage_draw(game);
|
|
blasts_draw();
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
fighter_draw(game.sim.fighters[i]);
|
|
attacks_draw();
|
|
if (game.settings.isCollisionShown)
|
|
collision_draw();
|
|
}
|
|
|
|
// A very faint line along each path the held weapon's attack would take if fired now, fading out over the last quarter
|
|
// of its length (so a path cut short by a wall fades too), ringing the blast it would make where it ends.
|
|
void Scene::paths_draw(Fighter& fighter)
|
|
{
|
|
Color base = game.color_get(fighter.index);
|
|
for (Path& path : game.sim.weapons.paths_get(game.sim, fighter))
|
|
{
|
|
int last = path.steps.size() - 1;
|
|
for (int i = 0; i < last; i++)
|
|
{
|
|
float fade = std::clamp((last - i) / (last * TRAJECTORY_TAPER), 0.0f, 1.0f);
|
|
game.renderer.line_draw(path.steps[i], path.steps[i + 1], 1, color_fade(base, TRAJECTORY_ALPHA * fade));
|
|
}
|
|
if (path.area > 0)
|
|
game.renderer.ring_draw(path.steps.back(), path.area, color_fade(base, BLAST_RING_ALPHA));
|
|
}
|
|
}
|
|
|
|
void Scene::world_draw()
|
|
{
|
|
Renderer& renderer = game.renderer;
|
|
World& world = game.sim.world;
|
|
Anm2& tileArt = game.sprites.tiles;
|
|
int startX = std::max(0, (int)std::floor(renderer.camera.x / TILE_SIZE));
|
|
int startY = std::max(0, (int)std::floor(renderer.camera.y / TILE_SIZE));
|
|
int endX = std::min(world.width - 1, (int)std::floor((renderer.camera.x + CANVAS_WIDTH) / TILE_SIZE));
|
|
int endY = std::min(world.height - 1, (int)std::floor((renderer.camera.y + CANVAS_HEIGHT) / TILE_SIZE));
|
|
|
|
for (int y = startY; y <= endY; y++)
|
|
{
|
|
for (int x = startX; x <= endX; x++)
|
|
{
|
|
auto isOpen = [&](float dx, float dy) {
|
|
Tile neighbour = world.tile_get(x + dx, y + dy);
|
|
return neighbour != TILE_SOLID && neighbour != TILE_SLOPE;
|
|
};
|
|
auto isEmpty = [&](int dx) {
|
|
return world.tile_get(x + dx, y) == TILE_EMPTY;
|
|
};
|
|
Tile tile = world.tile_get(x, y);
|
|
Vec2 position = {(float)x * TILE_SIZE, (float)y * TILE_SIZE};
|
|
|
|
if (tile == TILE_SOLID)
|
|
{
|
|
int mask = isOpen(0, -1) | isOpen(1, 0) << 1 | isOpen(0, 1) << 2 | isOpen(-1, 0) << 3;
|
|
tileArt.draw(renderer, "Wall", mask, {.position = position});
|
|
|
|
for (Vec2 corner : CORNER_DIRECTIONS)
|
|
{
|
|
if (isOpen(corner.x, 0) || isOpen(0, corner.y) || !isOpen(corner.x, corner.y))
|
|
continue;
|
|
float cornerX = corner.x < 0 ? position.x : position.x + TILE_SIZE - CORNER_SIZE;
|
|
float cornerY = corner.y < 0 ? position.y : position.y + TILE_SIZE - CORNER_SIZE;
|
|
renderer.rect_draw({cornerX, cornerY, CORNER_SIZE, CORNER_SIZE}, WHITE);
|
|
}
|
|
}
|
|
else if (tile == TILE_PLATFORM)
|
|
{
|
|
int mask = isEmpty(1) | isEmpty(-1) << 1;
|
|
tileArt.draw(renderer, "Platform", mask, {.position = position});
|
|
}
|
|
else if (tile == TILE_PIT || tile == TILE_FINISH)
|
|
tileArt.draw(renderer, tile == TILE_PIT ? "Pit" : "Finish", 0, {.position = position});
|
|
}
|
|
}
|
|
|
|
// Ramps: an outline just inside the slope and its flat top, and a wall down the high end where it stands free.
|
|
for (Ramp& ramp : world.ramps)
|
|
{
|
|
if (ramp.right < renderer.camera.x || ramp.left > renderer.camera.x + CANVAS_WIDTH)
|
|
continue;
|
|
float edge = ramp.anchor + (ramp.top - ramp.base) / ramp.slope;
|
|
Vec2 low = {ramp.anchor, ramp.base};
|
|
Vec2 high = {edge, ramp.top};
|
|
Vec2 end = {ramp.slope < 0 ? ramp.right : ramp.left, ramp.top};
|
|
Vec2 inset = vec2_normalize({ramp.slope, -1}) * (-RAMP_OUTLINE / 2);
|
|
renderer.line_draw(low + inset, high + inset, RAMP_OUTLINE, WHITE);
|
|
renderer.line_draw({high.x, high.y + RAMP_OUTLINE / 2}, {end.x, end.y + RAMP_OUTLINE / 2}, RAMP_OUTLINE, WHITE);
|
|
|
|
float side = ramp.slope < 0 ? 1 : -1;
|
|
Vec2 beyond = {end.x + side * TILE_SIZE / 2, ramp.top + TILE_SIZE / 2};
|
|
if (world.tile_at(beyond) != TILE_SOLID)
|
|
renderer.line_draw({end.x - side * RAMP_OUTLINE / 2, ramp.top},
|
|
{end.x - side * RAMP_OUTLINE / 2, ramp.base}, RAMP_OUTLINE, WHITE);
|
|
}
|
|
|
|
// Blocks crack as they lose health, and a broken one shows a faint outline just before it grows back.
|
|
for (Dummy& dummy : world.dummies)
|
|
{
|
|
if (dummy.tile < 0)
|
|
continue;
|
|
Vec2 position = {dummy.box.x, dummy.box.y};
|
|
if (world.tiles[dummy.tile] == TILE_BLOCK)
|
|
{
|
|
int stage = std::min(BLOCK_STAGES - 1, (int)((1 - dummy.health / BLOCK_HEALTH) * BLOCK_STAGES));
|
|
tileArt.draw(renderer, "Block", stage, {.position = position, .flash = dummy.flash});
|
|
}
|
|
else if (dummy.regrow < BLOCK_WARN)
|
|
tileArt.draw(renderer, "Block", 0,
|
|
{.color = color_fade(GRAY, 1 - dummy.regrow / BLOCK_WARN), .position = position});
|
|
}
|
|
|
|
for (Dummy& dummy : world.dummies)
|
|
{
|
|
if (dummy.tile >= 0)
|
|
continue;
|
|
Vec2 base = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h};
|
|
game.sprites.dummy.draw(renderer, "Idle", 0, {.position = base, .angle = dummy.wobble, .flash = dummy.flash});
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
Bot& bot = game.sim.bot;
|
|
if (!bot.isDrawn)
|
|
return;
|
|
|
|
Renderer& renderer = game.renderer;
|
|
for (Surface& surface : bot.surfaces)
|
|
renderer.line_draw({surface.left, surface.y_at(surface.left)}, {surface.right, surface.y_at(surface.right)},
|
|
SURFACE_WIDTH, GRAY);
|
|
for (Link& link : bot.links)
|
|
renderer.line_draw({link.takeoff, bot.surfaces[link.from].y_at(link.takeoff)},
|
|
{link.landing, bot.surfaces[link.to].y_at(link.landing)}, LINK_WIDTH,
|
|
color_fade(GRAY, GOAL_SPAN));
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
{
|
|
BotState& state = bot.states[i];
|
|
if (!game.sim.fighters[i].isBot)
|
|
continue;
|
|
for (int step = state.pathStep; step < state.pathLength; step++)
|
|
{
|
|
Link& link = bot.links[state.path[step]];
|
|
renderer.line_draw({link.takeoff, bot.surfaces[link.from].y_at(link.takeoff)},
|
|
{link.landing, bot.surfaces[link.to].y_at(link.landing)}, SURFACE_WIDTH, WHITE);
|
|
}
|
|
renderer.circle_draw(state.goal, SURFACE_WIDTH * 2, WHITE);
|
|
}
|
|
}
|
|
|
|
// Draws the fighter between its weapons, all shaking and filling in with the bonus its attacks would get right now.
|
|
// Behind it, in a race, a glow rising from the ground it would respawn on (its own only). Over it, the reach of an Iron
|
|
// Weights slam about to land. The local fighter also sees where its weapon would fire.
|
|
void Scene::fighter_draw(Fighter& fighter)
|
|
{
|
|
if (!fighter.isAlive)
|
|
return;
|
|
Renderer& renderer = game.renderer;
|
|
Color color = game.color_get(fighter.index);
|
|
bool isLocal = fighter.index == game.local && game.devices[fighter.index] != DEVICE_NONE;
|
|
if (isLocal && game.sim.match.is_racing())
|
|
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},
|
|
color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT)));
|
|
|
|
float fill = game.sim.weapons.buff_get(fighter);
|
|
Vec2 camera = renderer.camera;
|
|
renderer.camera = camera + jitter_get(fill);
|
|
Pose pose = fighter_pose_get(fighter);
|
|
pose.placement.flash = std::max(pose.placement.flash, fill);
|
|
offhand_draw(fighter);
|
|
pose.draw(renderer);
|
|
held_draw(fighter);
|
|
renderer.camera = camera;
|
|
|
|
float pound = fighter.pound_area_get();
|
|
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));
|
|
if (isLocal && !game.menu.isOpen && game.settings.isAimShown)
|
|
paths_draw(fighter);
|
|
}
|
|
|
|
// The animation for what the fighter is doing: tumbling when launched, kicking off or sliding down a wall, rolling,
|
|
// crawling, crouching, jumping or falling, walking, or standing.
|
|
const char* Scene::animation_get(Fighter& fighter)
|
|
{
|
|
if (fighter.isLaunched)
|
|
return "Hurt";
|
|
if (fighter.kickPose > 0)
|
|
return "Kick";
|
|
if (fighter.slideWall != 0)
|
|
return "Slide";
|
|
if (fighter.isSliding)
|
|
return "Roll";
|
|
if (fighter.is_crawling())
|
|
return CRAWL_ANIMATIONS[fighter.body.velocity.x < 0];
|
|
if (fighter.isCrouching)
|
|
return "Crouch";
|
|
if (!fighter.body.isGrounded)
|
|
return fighter.body.velocity.y < 0 ? "Jump" : "Fall";
|
|
if (std::abs(fighter.body.velocity.x) > STEP_SPEED)
|
|
return WALK_ANIMATIONS[fighter.body.velocity.x < 0];
|
|
return "Idle";
|
|
}
|
|
|
|
// Plays the fighter's animation for this tick. Walking and crawling cycle one frame per stride of ground covered, the
|
|
// rest play in time. Turning round mid-stride carries on the cycle in the other direction's animation.
|
|
void Scene::animator_update(Fighter& fighter, Animator& animator)
|
|
{
|
|
const char* animation = animation_get(fighter);
|
|
bool isWalking = is_pair(WALK_ANIMATIONS, animation);
|
|
bool isCrawling = is_pair(CRAWL_ANIMATIONS, animation);
|
|
bool isTurning = isWalking && is_pair(WALK_ANIMATIONS, animator.animation) ||
|
|
isCrawling && is_pair(CRAWL_ANIMATIONS, animator.animation);
|
|
float stride = isWalking ? WALK_STRIDE : isCrawling ? CRAWL_STRIDE : 0;
|
|
float frame = animator.frame;
|
|
animator.play(animation);
|
|
if (isTurning)
|
|
animator.frame = frame;
|
|
animator.update(stride > 0 ? std::abs(fighter.body.velocity.x) * TICK / stride : TICK * animator.art->fps);
|
|
}
|
|
|
|
// The fighter's animation placed where it is right now, in its colour: feet on the ground facing its way (walking and
|
|
// crawling face the way they move), facing away from a wall it kicks off or slides down, spinning about its middle when
|
|
// launched, and rolling as a ball resting on the ground along the surface's perpendicular. Shared with afterimages.
|
|
Pose Scene::fighter_pose_get(Fighter& fighter)
|
|
{
|
|
Animator& animator = game.sprites.fighters[fighter.index];
|
|
bool isDirected = is_pair(WALK_ANIMATIONS, animator.animation) || is_pair(CRAWL_ANIMATIONS, animator.animation);
|
|
float side = fighter.is_left() && !isDirected ? -1 : 1;
|
|
Draw draw = {.color = game.color_get(fighter.index),
|
|
.position = {fighter.center_get().x, fighter.body.box.y + fighter.body.box.h},
|
|
.scale = {side * fighter.scale, fighter.scale},
|
|
.flash = fighter.flash};
|
|
if (fighter.isLaunched)
|
|
{
|
|
draw.position = fighter.center_get();
|
|
draw.angle = fighter.tumble;
|
|
}
|
|
else if (fighter.kickPose > 0)
|
|
draw.scale.x = (fighter.body.velocity.x < 0 ? -1 : 1) * fighter.scale;
|
|
else if (fighter.slideWall != 0)
|
|
draw.scale.x = -fighter.slideWall * fighter.scale;
|
|
else if (fighter.isSliding)
|
|
{
|
|
Vec2 up = fighter.body.isGrounded ? vec2_normalize({fighter.body.slope, -1}) : Vec2{0, -1};
|
|
draw.position = draw.position + up * (ROLL_RADIUS * fighter.scale);
|
|
draw.angle = fighter.roll;
|
|
}
|
|
return animator.pose_get(draw);
|
|
}
|
|
|
|
void Scene::attacks_draw()
|
|
{
|
|
Renderer& renderer = game.renderer;
|
|
|
|
for (Attack& attack : game.sim.weapons.attacks)
|
|
{
|
|
Fighter& fighter = game.sim.fighters[attack.owner];
|
|
Color tint = tint_get(game, attack);
|
|
AttackKind kind = attack.def()->kind;
|
|
if (kind == ATTACK_HOOK)
|
|
{
|
|
// The rope runs from the end of the grapple's base in the hand.
|
|
Vec2 hand = fighter.hand_get(attack.hand);
|
|
Vec2 base = hand + vec2_polar(vec2_angle(attack.position - hand), GRAPPLE_HANDLE);
|
|
strength_line_draw(game, attack, {{base, attack.position}, ROPE_OUTER, ROPE_INNER});
|
|
}
|
|
|
|
if (kind == ATTACK_SPIN || kind == ATTACK_FLAIL)
|
|
{
|
|
Vec2 hand = fighter.hand_get();
|
|
Vec2 delta = attack.position - hand;
|
|
int links = vec2_length(delta) / CHAIN_SPACING;
|
|
for (int i = 1; i < links; i++)
|
|
game.sprites.effects.draw(renderer, "Chain", 0,
|
|
{.color = tint,
|
|
.position = hand + delta * ((float)i / links),
|
|
.flash = fill_get(strength_get(attack, attack.tip))});
|
|
}
|
|
|
|
float progress = attack.time / attack.duration;
|
|
if (kind == ATTACK_BEAM)
|
|
{
|
|
float width = (1 - progress) * attack.scale;
|
|
strength_line_draw(game, attack, {{attack.position, attack.tip}, BEAM_OUTER * width, BEAM_INNER * width});
|
|
}
|
|
else if (kind == ATTACK_WHIP)
|
|
{
|
|
// A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite
|
|
// rides the end of the whip, pointing the way it runs.
|
|
Vec2 shake = jitter_get(fill_get(strength_get(attack, attack.tip)));
|
|
float fade = attack.isCharged ? progress : std::max(0.0f, (progress - WHIP_UNFURL) / (1 - WHIP_UNFURL));
|
|
float width = (1 - fade) * attack.scale;
|
|
std::vector<Vec2> points =
|
|
attack.isCharged ? std::vector<Vec2>{attack.position, attack.tip} : lash_get(attack);
|
|
Vec2 end = points.back();
|
|
Vec2 before = points[points.size() - 2];
|
|
strength_line_draw(game, attack,
|
|
{points, WHIP_OUTER * width, WHIP_INNER * width, attack.isCharged ? 0 : WHIP_JITTER});
|
|
game.sprites.effects.draw(renderer, "Whip Tip", 0,
|
|
{.color = color_fade(tint, 1 - fade),
|
|
.position = end + shake,
|
|
.scale = {attack.scale, attack.scale},
|
|
.angle = vec2_angle(end - before)});
|
|
}
|
|
else if (kind != ATTACK_EXPLOSION)
|
|
attack_pose(attack).draw(renderer);
|
|
|
|
// A faint ring shows how far anything that explodes would reach, and pips in the shot's colour circle it, one
|
|
// for each ricochet it has left.
|
|
if (kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_MINE)
|
|
renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA));
|
|
for (int i = 0; i < attack.bounces && kind != ATTACK_BEAM && !attack.isAttached; i++)
|
|
{
|
|
float angle = attack.time * PIP_SPIN + 2 * PI * i / attack.bounces;
|
|
Vec2 pip = attack.position + vec2_polar(angle, attack.radius + PIP_GAP);
|
|
renderer.rect_draw({std::round(pip.x - PIP_SIZE / 2), std::round(pip.y - PIP_SIZE / 2), PIP_SIZE, PIP_SIZE},
|
|
tint);
|
|
}
|
|
}
|
|
magic_draw();
|
|
tesla_draw();
|
|
}
|
|
|
|
// The Grimoire's teleport, drawn rather than sprited: a faint ring where its blast will go with a core swelling inside
|
|
// it while it gathers. (Its wisps are drawn with the other attacks.)
|
|
void Scene::magic_draw()
|
|
{
|
|
for (Attack& attack : game.sim.weapons.attacks)
|
|
{
|
|
AttackKind kind = attack.def()->kind;
|
|
Color color = tint_get(game, attack);
|
|
if (kind == ATTACK_TELEPORT && !attack.isDead)
|
|
{
|
|
float progress = std::min(1.0f, attack.time / attack.duration);
|
|
float area = attack.stats.area * attack.scale;
|
|
game.renderer.ring_draw(attack.position, area, color_fade(color, TELEPORT_RING_ALPHA));
|
|
game.renderer.circle_draw(attack.position, area * TELEPORT_CORE * progress, color_fade(color, progress));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Each link is a jagged bolt whose kinks move every frame so it crackles.
|
|
void Scene::tesla_draw()
|
|
{
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
for (Capsule& link : game.sim.weapons.chain_get(game.sim, game.sim.fighters[i]))
|
|
bolt_draw(game.renderer, {link.from, link.to, BOLT_WIDTH}, BOLT_JITTER, game.color_get(i));
|
|
}
|
|
|
|
// The Display Collision setting: every attack's hit shapes as the hit test sees them, and the boxes fighters and
|
|
// dummies are hit in, see-through in their owner's colour.
|
|
void Scene::collision_draw()
|
|
{
|
|
Renderer& renderer = game.renderer;
|
|
for (Attack& attack : game.sim.weapons.attacks)
|
|
{
|
|
Capsule shapes[WHIP_RAYS];
|
|
int count = shapes_get(game.sim, attack, shapes);
|
|
capsules_fill(renderer, shapes, count, color_fade(owner_color_get(game, attack), COLLISION_ALPHA));
|
|
}
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
if (game.sim.fighters[i].isAlive)
|
|
renderer.rect_draw(game.sim.fighters[i].body.box, color_fade(game.color_get(i), COLLISION_ALPHA));
|
|
for (Dummy& dummy : game.sim.world.dummies)
|
|
if (dummy.tile < 0)
|
|
renderer.rect_draw(dummy.box, color_fade(WHITE, COLLISION_ALPHA));
|
|
}
|
|
|
|
// Explosions draw before the player, so their filled centre never hides anyone standing in the blast.
|
|
void Scene::blasts_draw()
|
|
{
|
|
for (Attack& attack : game.sim.weapons.attacks)
|
|
{
|
|
if (attack.def()->kind != ATTACK_EXPLOSION)
|
|
continue;
|
|
float progress = attack.time / attack.duration;
|
|
float radius = attack.stats.range * std::min(1.0f, progress * EXPLOSION_GROW);
|
|
Color color = color_dim(owner_color_get(game, attack), attack.bonus);
|
|
float fill = fill_get(attack.bonus);
|
|
Vec2 center = attack.position + jitter_get(fill);
|
|
game.renderer.circle_draw(center, radius, color_fade(color, 1 - progress));
|
|
if (attack.time > EXPLOSION_FLASH)
|
|
game.renderer.circle_draw(center, radius - EXPLOSION_OUTLINE,
|
|
color_fade(color_shade(color, fill), 1 - progress));
|
|
}
|
|
}
|
|
|
|
// How a sprite-based attack looks right now: its weapon, or the projectile or effect it fired. Shared by the live
|
|
// attack and its afterimages.
|
|
Pose Scene::attack_pose(Attack& attack)
|
|
{
|
|
const AttackDef& def = *attack.def();
|
|
float progress = attack.time / attack.duration;
|
|
float side = attack.isLeft ? -1 : 1;
|
|
Anm2& effects = game.sprites.effects;
|
|
Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name};
|
|
Draw& draw = pose.placement;
|
|
draw = {.position = attack.position, .scale = {attack.scale, attack.scale * side}, .angle = attack.angle};
|
|
Color tint = tint_get(game, attack);
|
|
draw.color = tint;
|
|
draw.flash = fill_get(strength_get(attack, attack.tip));
|
|
draw.position = draw.position + jitter_get(draw.flash);
|
|
|
|
switch (def.kind)
|
|
{
|
|
case ATTACK_SWING:
|
|
draw.position = attack.origin;
|
|
break;
|
|
case ATTACK_BOOMERANG:
|
|
draw.scale.y = attack.scale;
|
|
draw.angle = attack.time * BOOMERANG_SPIN;
|
|
break;
|
|
case ATTACK_SLASH:
|
|
draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress)));
|
|
pose = {&effects, "Slash", draw};
|
|
break;
|
|
case ATTACK_ARROW:
|
|
{
|
|
float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f);
|
|
draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1);
|
|
pose = {&effects, "Arrow", draw};
|
|
break;
|
|
}
|
|
case ATTACK_ROCKET:
|
|
pose = {&effects, "Rocket", draw};
|
|
break;
|
|
case ATTACK_PUNCH:
|
|
draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress)));
|
|
pose = {&effects, "Punch", draw};
|
|
break;
|
|
case ATTACK_UPPERCUT:
|
|
// The fist is drawn rising, so it stands upright when aimed straight ahead and leans with the launch.
|
|
// Facing left mirrors it.
|
|
draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress)));
|
|
draw.scale = {attack.scale * side, attack.scale};
|
|
draw.angle = attack.angle + PI / 2;
|
|
pose = {&effects, "Uppercut", draw, attack.time * effects.fps};
|
|
break;
|
|
case ATTACK_BULLET:
|
|
pose = {&effects, "Slug", draw};
|
|
break;
|
|
case ATTACK_PROJECTILE:
|
|
pose = {&effects, "Bullet", draw};
|
|
break;
|
|
case ATTACK_HOOK:
|
|
pose.animation = "Grapple Tip";
|
|
break;
|
|
case ATTACK_MINE:
|
|
draw.scale.y = attack.scale;
|
|
draw.angle = 0;
|
|
if (is_mine_armed(attack) && std::fmod(attack.time, MINE_BLINK) < MINE_BLINK_ON)
|
|
draw.flash = 1;
|
|
pose = {&effects, "Mine", draw};
|
|
break;
|
|
case ATTACK_FLAME:
|
|
case ATTACK_FIREBALL:
|
|
{
|
|
// Flames flicker through their animation, the spread jitter staggering them. Loose flames grow and fade
|
|
// out. Fireballs point their tip back along the path.
|
|
bool isFlame = def.kind == ATTACK_FLAME;
|
|
float size = attack.scale * (isFlame ? FLAME_SHRINK + FLAME_GROW * progress : 1);
|
|
draw.scale = {size, size};
|
|
draw.angle = isFlame ? 0 : attack.angle - PI / 2;
|
|
if (isFlame)
|
|
draw.color = color_fade(tint, std::min(1.0f, (1 - progress) * FLAME_FADE));
|
|
pose = {&effects, "Flame", draw, (attack.time + attack.aimAngle * FLAME_STAGGER) * effects.fps};
|
|
break;
|
|
}
|
|
case ATTACK_SPIN:
|
|
case ATTACK_FLAIL:
|
|
pose = {&effects, "Flail Ball", draw};
|
|
break;
|
|
case ATTACK_WISP:
|
|
{
|
|
// Wisps burn like little flames while they hover, tails flickering upward, then streak head-first along
|
|
// their path, each a little out of step with the rest.
|
|
bool isFlying = attack.velocity.x != 0 || attack.velocity.y != 0;
|
|
draw.scale = {attack.scale, attack.scale};
|
|
draw.angle = isFlying ? attack.angle - PI / 2 : 0;
|
|
pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps};
|
|
break;
|
|
}
|
|
case ATTACK_TELEPORT:
|
|
// Drawn as its marker and sparkles instead.
|
|
pose.art = nullptr;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return pose;
|
|
}
|
|
|
|
// Draws the selected weapon in the player's hand, aimed at the cursor, unless it is out attacking. It fills in as it
|
|
// charges (see charge_fill_get) and shakes with its charge and cooldown (see weapon_shake_get). A charging mine weapon
|
|
// holds up the mine it's about to place instead.
|
|
void Scene::held_draw(Fighter& fighter)
|
|
{
|
|
int held = fighter.weapon_key(fighter.slot);
|
|
if (held < 0 || (game.sim.weapons.is_busy(fighter, held) && !game.sim.weapons.tether_get(fighter, held, 0)))
|
|
return;
|
|
|
|
float side = fighter.is_left() ? -1 : 1;
|
|
Draw draw = {
|
|
.color = game.color_get(fighter.index),
|
|
.position = fighter.hand_get() + weapon_shake_get(fighter, held),
|
|
.scale = {1, side},
|
|
.angle = vec2_angle(fighter.aim) + kick_get(fighter) * side,
|
|
.flash = charge_fill_get(fighter),
|
|
};
|
|
if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE)
|
|
{
|
|
draw.scale = {1, 1};
|
|
draw.angle = 0;
|
|
game.sprites.effects.draw(game.renderer, "Mine", 0, draw);
|
|
return;
|
|
}
|
|
reload_pose(fighter, held, draw);
|
|
hand_draw(fighter, held, 0, draw);
|
|
}
|
|
|
|
// Dual Wield: the off-hand weapons, a darker shade of the fighter's colour, fanned out behind it (see
|
|
// Fighter::hand_get), the furthest back first so the nearer cover them. Each shakes and reloads like the main one.
|
|
void Scene::offhand_draw(Fighter& fighter)
|
|
{
|
|
int offs[SLOT_MAX];
|
|
float side = fighter.is_left() ? -1 : 1;
|
|
for (int hand = fighter.off_slots_get(offs); hand >= 1; hand--)
|
|
{
|
|
int off = fighter.weapon_key(offs[hand - 1]);
|
|
if (game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, hand))
|
|
continue;
|
|
Draw draw = {
|
|
.color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE),
|
|
.position = fighter.hand_get(hand) + weapon_shake_get(fighter, off),
|
|
.scale = {1, side},
|
|
.angle = vec2_angle(fighter.aim),
|
|
.flash = charge_fill_get(fighter),
|
|
};
|
|
reload_pose(fighter, off, draw);
|
|
hand_draw(fighter, off, hand, draw);
|
|
}
|
|
}
|
|
|
|
// Draws a weapon in a hand. While its whip or hook is out, the hand holds only its base ("<name> Base"), pointing
|
|
// down the lash or rope.
|
|
void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw)
|
|
{
|
|
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand);
|
|
std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name;
|
|
if (tether)
|
|
{
|
|
Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position;
|
|
draw.angle = vec2_angle(end - draw.position);
|
|
animation += " Base";
|
|
}
|
|
// A weapon without held art yet shows its icon, small and upright.
|
|
if (!game.sprites.weapons.animation_get(animation.c_str()))
|
|
{
|
|
draw.scale = {HELD_ICON_SCALE * (draw.scale.y < 0 ? -1 : 1), HELD_ICON_SCALE};
|
|
draw.angle = 0;
|
|
game.sprites.icons.draw(game.renderer, animation.c_str(), 0, draw);
|
|
return;
|
|
}
|
|
game.sprites.weapons.draw(game.renderer, animation.c_str(), 0, draw);
|
|
}
|