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

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

No files matched your search

+292 -60
View File
@@ -1,4 +1,5 @@
#include "scene.h"
#include <bit>
#include "game.h"
#include <SDL3/SDL_timer.h>
#include <algorithm>
@@ -7,6 +8,8 @@
constexpr int BLINK_MILLISECONDS = 80;
// How long a claimed shot takes to turn from its old owner's colour to its new one's.
constexpr float CLAIM_BLEND = 0.35f;
constexpr float TRAJECTORY_ALPHA = 0.12f;
constexpr float TRAJECTORY_TAPER = 0.25f;
@@ -37,7 +40,35 @@ 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 CONE_ALPHA = 0.35f;
constexpr float CONE_FILL_ALPHA = 0.3f;
// The air a Vacuum sucks in: how many motes stream along a cone at once, how long each takes from the far end to the
// muzzle, how much of that it spends fading in and out, and their size.
constexpr int SUCK_MOTES = 14;
constexpr float SUCK_TRAVEL = 0.4f;
constexpr float SUCK_FADE_IN = 0.25f;
constexpr float SUCK_FADE_OUT = 0.2f;
constexpr float SUCK_MOTE_SIZE = 2;
// How seeds for motes of air are spread and mixed (see mote_share_get).
constexpr Uint32 MOTE_KEYS = 16;
constexpr Uint32 MOTE_MIX_A = 0x7feb352d;
constexpr Uint32 MOTE_MIX_B = 0x846ca68b;
// How many motes an airblast blows out, and the least share of the cone's reach one gets to.
constexpr int GUST_MOTES = 20;
constexpr float GUST_REACH_MIN = 0.35f;
// How late (as a share of the blast's life) a mote may set off.
constexpr float GUST_STAGGER = 0.35f;
// How much a running Vacuum shakes in the hand at full spin.
constexpr float WHIR_SHAKE = 0.6f;
// How bright a weapon (and its crosshair) is just after firing, brightening back as it cools down.
constexpr float COOLDOWN_DARK = 0.35f;
// A fighter's whir voice when its Vacuum isn't running, or is running without a voice to play on.
constexpr int WHIR_NONE = -1;
constexpr int WHIR_SILENT = -2;
constexpr int CONE_ARC_STEPS = 8;
constexpr float EXPLOSION_FLASH = 0.05f;
// How far apart a double donk's blast blinks.
constexpr float DONK_BLINK = 0.1f;
constexpr float EXPLOSION_GROW = 4;
constexpr float EXPLOSION_OUTLINE = 2;
constexpr float FALLOFF_CURVE = 1.5f;
@@ -53,8 +84,6 @@ 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;
@@ -118,7 +147,12 @@ static Color color_dim(Color color, float 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);
// A claimed shot turns from its old owner's colour to its new one's.
Color color = game.color_get(attack.owner);
if (attack.ownerPrevious >= 0)
color = color_mix(game.color_get(attack.ownerPrevious), color,
std::min(1.0f, (attack.time - attack.claimTime) / CLAIM_BLEND));
return color_shade(color, attack.isSecondary ? SECONDARY_SHADE : 1);
}
static Color tint_get(Game& game, Attack& attack)
@@ -135,8 +169,8 @@ struct Stroke
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.
// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. 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);
@@ -144,7 +178,8 @@ static void strength_line_draw(Game& game, Attack& attack, Stroke stroke)
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;
float distance = 0;
float reach = falloff_reach_get(attack);
for (size_t i = 0; i + 1 < stroke.points.size(); i++)
{
Vec2 from = stroke.points[i];
@@ -169,7 +204,7 @@ static void strength_line_draw(Game& game, Attack& attack, Stroke stroke)
{
for (size_t i = 0; i + 1 < points.size(); i++)
{
float strength = base * falloff_at(distances[i]);
float strength = base * falloff_at(distances[i] / reach);
if (layer == 0)
game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength));
else
@@ -196,7 +231,28 @@ static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color colo
}
}
// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even.
// Fills every pixel in bounds (left, top, right, bottom) that isInside says is in a shape, a row of runs at a time, so
// where parts of it overlap stays even.
template <typename Inside> static void shape_fill(Renderer& renderer, Rect bounds, Inside isInside, Color color)
{
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;
}
}
}
}
// Fills every pixel inside any of the capsules.
static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color)
{
Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY};
@@ -219,23 +275,40 @@ static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color
}
return false;
};
for (float y = std::floor(bounds.y); y <= bounds.h; y++)
shape_fill(renderer, bounds, isInside, color);
}
// A cone from apex toward tip, cone radians to either side, filled as a fan fading from color at the apex to the share
// of it the cone's strength keeps at its far end (see cone_strength_get).
static void cone_fill_draw(Renderer& renderer, Vec2 apex, Vec2 tip, float cone, Color color)
{
float angle = vec2_angle(tip - apex);
float range = vec2_length(tip - apex);
Color far = color_fade(color, VACUUM_EDGE);
Color colors[] = {color, far, far};
for (int i = 0; i < CONE_ARC_STEPS; i++)
{
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;
}
}
Vec2 corners[] = {apex, apex + vec2_polar(angle + cone * (2.0f * i / CONE_ARC_STEPS - 1), range),
apex + vec2_polar(angle + cone * (2.0f * (i + 1) / CONE_ARC_STEPS - 1), range)};
renderer.triangle_draw(corners, colors);
}
}
// A cone from apex toward tip, cone radians to either side, as its two edges and the arc across its far end.
static void cone_outline_draw(Renderer& renderer, Vec2 apex, Vec2 tip, float cone, Color color)
{
float angle = vec2_angle(tip - apex);
float range = vec2_length(tip - apex);
Vec2 previous = apex;
for (int i = 0; i <= CONE_ARC_STEPS; i++)
{
Vec2 point = apex + vec2_polar(angle + cone * (2.0f * i / CONE_ARC_STEPS - 1), range);
renderer.line_draw(previous, point, 1, color);
previous = point;
}
renderer.line_draw(previous, apex, 1, color);
}
// 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)
@@ -251,7 +324,7 @@ static float kick_get(Fighter& fighter)
// 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);
float total = fighter.level_get(key).cooldown / fighter.stat_get(STAT_ATTACK_RATE);
return total > 0 ? std::clamp(fighter.cooldowns[key] / total, 0.0f, 1.0f) : 0;
}
@@ -281,8 +354,45 @@ float Scene::charge_fill_get(Fighter& fighter)
// 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)
{
// A running continuous weapon shakes as hard as it's spun up.
float left = key < 0 ? 0 : cooldown_left_get(fighter, key);
return jitter_get(std::max(fighter.charge, left * left) * CHARGE_SHAKE);
Attack* running = key < 0 ? nullptr : game.sim.weapons.continuous_get(fighter, key, 0);
float spin = running && !running->isReturning ? running->length * WHIR_SHAKE : 0;
return jitter_get(std::max({fighter.charge, left * left, spin}) * CHARGE_SHAKE);
}
// A weapon's colour (and its crosshair's) through its cooldown: darkened just after it fires (keeping its hue), back to
// color as it cools.
// One cooling too quickly to see doesn't fade, so rapid fire doesn't flicker.
Color Scene::cooldown_tint_get(Fighter& fighter, int key, Color color)
{
if (key < 0 || fighter.level_get(key).cooldown / fighter.stat_get(STAT_ATTACK_RATE) < COOLDOWN_VISIBLE)
return color;
return color_shade(color, 1 - cooldown_left_get(fighter, key) * (1 - COOLDOWN_DARK));
}
// Each fighter's Vacuum whirs while it runs: spinning up into a loop as it starts, winding down as it's let go. It
// follows the game's state each frame rather than cues, so replays can't leave a whir going.
void Scene::whirs_update()
{
for (int i = 0; i < MAX_FIGHTERS; i++)
{
bool isRunning = false;
for (Attack& attack : game.sim.weapons.attacks)
isRunning |= i < game.sim.fighterCount && attack.owner == i && !attack.isDead && !attack.isReturning &&
attack.def()->kind == ATTACK_VACUUM;
if (isRunning && whirVoices[i] == WHIR_NONE)
{
game.audio.play(SOUND_VACUUM_START);
whirVoices[i] = std::max(WHIR_SILENT, game.audio.loop_play(SOUND_VACUUM));
}
if (!isRunning && whirVoices[i] != WHIR_NONE)
{
game.audio.stop(whirVoices[i]);
whirVoices[i] = WHIR_NONE;
game.audio.play(SOUND_VACUUM_STOP);
}
}
}
static bool is_pair(const char* const (&pair)[2], const char* animation)
@@ -293,6 +403,7 @@ static bool is_pair(const char* const (&pair)[2], const char* animation)
Scene::Scene(Game& game) : game(game)
{
std::fill(std::begin(chargeVoices), std::end(chargeVoices), -1);
std::fill(std::begin(whirVoices), std::end(whirVoices), WHIR_NONE);
}
void Scene::sound_play(SoundId sound)
@@ -337,22 +448,16 @@ void Scene::flash_spawn(Vec2 position, float angle, float size, int owner)
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));
game.effects.afterimage_spawn(fighter_pose_get(fighter), game.fashion_get(fighter.index));
}
void Scene::attack_afterimage_spawn(Attack& attack)
{
if (!isMuted)
game.effects.afterimage_spawn(attack_pose(attack));
game.effects.afterimage_spawn(attack_pose(attack), FASHION_NONE);
}
void Scene::fighter_aura_spawn(Fighter& fighter)
@@ -381,16 +486,16 @@ void Scene::number_spawn(Damage& damage)
}
// A level going up or down chimes, and outside the menu pops up over this screen's fighter.
void Scene::level_step(bool isUp)
void Scene::level_step(int step)
{
if (isMuted)
return;
game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN);
game.audio.play(step > 0 ? 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);
Vec2 head = {fighter.center_get().x, top_get(fighter)};
game.effects.popup_spawn(head, step > 0 ? "Level Up" : "Level Down", step > 0 ? 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
@@ -422,6 +527,7 @@ bool Scene::is_flashing(Fighter& fighter)
// Each tick, every fighter's animator plays whatever its fighter is doing.
void Scene::update()
{
whirs_update();
for (int i = 0; i < game.sim.fighterCount; i++)
animator_update(game.sim.fighters[i], game.sprites.fighters[i]);
@@ -433,7 +539,7 @@ void Scene::update()
Tomato& tomato = match.tomatoes[tomatoesSeen % TOMATO_MAX];
if (tomato.serial != tomatoesSeen || tomato.age > TOMATO_FRESH)
continue;
game.effects.burst(TOMATO_BURST, tomato.position, -PI / 2, game.color_get(tomato.owner, true));
game.effects.burst(TOMATO_BURST, tomato.position, -PI / 2, game.color_get(tomato.owner, TINT_PLAIN));
game.audio.play(SOUND_SPLAT);
}
}
@@ -446,8 +552,8 @@ void Scene::tomatoes_draw()
{
if (tomato.owner < 0 || tomato.age >= TOMATO_LIFE)
continue;
Color color =
color_fade(game.color_get(tomato.owner, true), std::min(1.0f, (TOMATO_LIFE - tomato.age) / TOMATO_FADE));
Color color = color_fade(game.color_get(tomato.owner, TINT_PLAIN),
std::min(1.0f, (TOMATO_LIFE - tomato.age) / TOMATO_FADE));
game.renderer.circle_draw(tomato.position, TOMATO_CORE, color);
Rng look = {.state = (Uint32)tomato.serial * TOMATO_LOOK_STEP + 1};
for (int i = 1; i < TOMATO_BLOBS; i++)
@@ -483,6 +589,12 @@ void Scene::paths_draw(Fighter& fighter)
for (Path& path : game.sim.weapons.paths_get(game.sim, fighter))
{
int last = path.steps.size() - 1;
if (path.cone > 0)
{
cone_outline_draw(game.renderer, path.steps.front(), path.steps.back(), path.cone,
color_fade(base, TRAJECTORY_ALPHA));
continue;
}
for (int i = 0; i < last; i++)
{
float fade = std::clamp((last - i) / (last * TRAJECTORY_TAPER), 0.0f, 1.0f);
@@ -640,6 +752,7 @@ void Scene::fighter_draw(Fighter& fighter)
pose.placement.flash = std::max(pose.placement.flash, fill);
offhand_draw(fighter);
pose.draw(renderer);
game.sprites.fashion_draw(renderer, pose, game.fashion_get(fighter.index));
held_draw(fighter);
renderer.camera = camera;
@@ -721,6 +834,15 @@ Pose Scene::fighter_pose_get(Fighter& fighter)
return animator.pose_get(draw);
}
// The top of a fighter: its box's, or higher where what it wears can reach over it (at most, so it holds still as the
// fighter animates). Whatever shows over a fighter (its bars and name, chat, popups) sits above this.
float Scene::top_get(Fighter& fighter)
{
Rect box = fighter.body.box;
float reach = game.sprites.fashion_height_get(game.fashion_get(fighter.index)) * fighter.scale;
return std::min(box.y, box.y + box.h - reach);
}
void Scene::attacks_draw()
{
Renderer& renderer = game.renderer;
@@ -750,7 +872,7 @@ void Scene::attacks_draw()
.flash = fill_get(strength_get(attack, attack.tip))});
}
float progress = attack.time / attack.duration;
float progress = attack.progress_get();
if (kind == ATTACK_BEAM)
{
float width = (1 - progress) * attack.scale;
@@ -761,14 +883,14 @@ void Scene::attacks_draw()
// 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 fade = attack.is_charged() ? progress : std::max(0.0f, (progress - PATH_OUT) / (1 - PATH_OUT));
float width = (1 - fade) * attack.scale;
std::vector<Vec2> points =
attack.isCharged ? std::vector<Vec2>{attack.position, attack.tip} : lash_get(attack);
attack.is_charged() ? 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});
{points, WHIP_OUTER * width, WHIP_INNER * width, attack.is_charged() ? 0 : WHIP_JITTER});
game.sprites.effects.draw(renderer, "Whip Tip", 0,
{.color = color_fade(tint, 1 - fade),
.position = end + shake,
@@ -780,8 +902,21 @@ void Scene::attacks_draw()
// 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)
if (attack.def()->isBursting && !attack.def()->isInstant)
renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA));
// A Vacuum's cone is outlined: steady while it sucks, fading as an airblast passes.
// Its fill fades toward the far end, as its strength does.
if (is_cone(*attack.def()))
{
Vec2 tip = attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack));
float alpha = kind == ATTACK_GUST ? 1 - progress : 1;
cone_fill_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_FILL_ALPHA));
if (kind == ATTACK_VACUUM)
suck_draw(attack, tint);
else
gust_draw(attack, tint);
cone_outline_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_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;
@@ -812,24 +947,89 @@ void Scene::magic_draw()
}
}
// Each link is a jagged bolt whose kinks move every frame so it crackles.
// A share from 0 to 1 that a mote of air draws from a seed, the same every frame (a bit-mixing hash, so neighbouring
// seeds give unrelated shares).
static float mote_share_get(Uint32 seed)
{
seed ^= seed >> 16;
seed *= MOTE_MIX_A;
seed ^= seed >> 15;
seed *= MOTE_MIX_B;
seed ^= seed >> 16;
return (seed >> 8) / (float)(1u << 24);
}
// The air a Vacuum sucks in, drawn from its cone rather than spawned, so it always fits it as it swings about: motes
// set off from the far end, each along its own line in the cone, fade in, stream to the muzzle and fade out there,
// one after another on a steady clock. While sucks overlap, only the newest of a hand's draws them.
void Scene::suck_draw(Attack& attack, Color color)
{
float clock = SDL_GetTicks() / 1000.0f / SUCK_TRAVEL;
for (int i = 0; i < SUCK_MOTES; i++)
{
float phase = clock + (float)i / SUCK_MOTES;
float progress = phase - std::floor(phase);
Uint32 seed = ((Uint32)std::floor(phase) * SUCK_MOTES + i) * MOTE_KEYS + attack.owner;
float side = mote_share_get(seed) * 2 - 1;
float fade = std::min({1.0f, progress / SUCK_FADE_IN, (1 - progress) / SUCK_FADE_OUT});
Vec2 mote =
attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * (1 - progress));
Particle particle = {.color = color, .life = fade, .lifeMax = 1, .size = SUCK_MOTE_SIZE};
particle_draw(game.renderer, particle, mote);
}
}
// The air a Vacuum's airblast blows out, drawn from its cone like the suck's: motes, each along its own line in the
// cone, setting off a little apart and reaching their own share of the way to its far end, sweep out from the muzzle
// over the blast's life, fading as they go.
void Scene::gust_draw(Attack& attack, Color color)
{
float progress = std::min(1.0f, attack.progress_get());
Uint32 blast = std::bit_cast<Uint32>(attack.aimAngle) ^ attack.owner;
for (int i = 0; i < GUST_MOTES; i++)
{
Uint32 seed = blast + i * MOTE_KEYS;
float side = mote_share_get(seed) * 2 - 1;
float reach = GUST_REACH_MIN + (1 - GUST_REACH_MIN) * mote_share_get(seed + 1);
float delay = GUST_STAGGER * mote_share_get(seed + 2);
float own = std::clamp((progress - delay) / (1 - delay), 0.0f, 1.0f);
float sweep = 1 - (1 - own) * (1 - own);
Vec2 mote =
attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * reach * sweep);
Particle particle = {.color = color, .life = 1 - own, .lifeMax = 1, .size = SUCK_MOTE_SIZE};
particle_draw(game.renderer, particle, mote);
}
}
// Each link is a jagged bolt whose kinks move every frame so it crackles, one bolt width thicker per coil.
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));
{
Fighter& fighter = game.sim.fighters[i];
float width = BOLT_WIDTH * fighter.trinket_count(TRINKET_TESLA);
for (Capsule& link : game.sim.weapons.chain_get(game.sim, fighter))
bolt_draw(game.renderer, {link.from, link.to, 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.
// The Display Collision setting: every attack's hit shapes as the hit test sees them (and a Vacuum's whole cone, where
// it pulls or blows), 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)
{
Color color = color_fade(owner_color_get(game, attack), COLLISION_ALPHA);
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));
capsules_fill(renderer, shapes, count, color);
if (cone_get(attack) <= 0)
continue;
float range = cone_reach_get(attack);
Rect bounds = {attack.position.x - range, attack.position.y - range, attack.position.x + range,
attack.position.y + range};
shape_fill(renderer, bounds, [&](Vec2 point) { return is_in_cone(attack, point); }, color);
}
for (int i = 0; i < game.sim.fighterCount; i++)
if (game.sim.fighters[i].isAlive)
@@ -839,7 +1039,8 @@ void Scene::collision_draw()
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.
// Explosions draw before the player, so their filled centre never hides anyone standing in the blast. One that landed a
// double donk blinks bright as it lingers, like an armed mine.
void Scene::blasts_draw()
{
for (Attack& attack : game.sim.weapons.attacks)
@@ -847,9 +1048,10 @@ void Scene::blasts_draw()
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);
float radius = attack.stats.range * std::min(1.0f, attack.time / EXPLOSION_DEF.duration * EXPLOSION_GROW);
Color color = color_dim(owner_color_get(game, attack), attack.bonus);
float fill = fill_get(attack.bonus);
bool isLit = attack.isDonked && std::fmod(attack.time, DONK_BLINK) < DONK_BLINK * MINE_BLINK_LIT * 2;
float fill = isLit ? 1 : 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)
@@ -863,7 +1065,7 @@ void Scene::blasts_draw()
Pose Scene::attack_pose(Attack& attack)
{
const AttackDef& def = *attack.def();
float progress = attack.time / attack.duration;
float progress = attack.progress_get();
float side = attack.isLeft ? -1 : 1;
Anm2& effects = game.sprites.effects;
Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name};
@@ -889,7 +1091,7 @@ Pose Scene::attack_pose(Attack& attack)
break;
case ATTACK_ARROW:
{
float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f);
float fade = std::clamp((attack.lifetime - attack.life_get()) / ARROW_FADE, 0.0f, 1.0f);
draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1);
pose = {&effects, "Arrow", draw};
break;
@@ -921,7 +1123,7 @@ Pose Scene::attack_pose(Attack& attack)
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)
if (is_armed(attack) && attack.blink > mine_blink_get(attack) * (1 - MINE_BLINK_LIT))
draw.flash = 1;
pose = {&effects, "Mine", draw};
break;
@@ -954,9 +1156,21 @@ Pose Scene::attack_pose(Attack& attack)
break;
}
case ATTACK_TELEPORT:
// Drawn as its marker and sparkles instead.
case ATTACK_VACUUM:
case ATTACK_GUST:
// Drawn as its marker and sparkles, or as rushing air, instead.
pose.art = nullptr;
break;
case ATTACK_GRENADE:
draw.scale.y = attack.scale;
pose = {&effects, "Grenade", draw};
break;
case ATTACK_BOLT:
// Upright and unflipped, whichever way it crawls.
draw.angle = 0;
draw.scale = {attack.scale, attack.scale};
pose = {&effects, "Bolt", draw, attack.time * effects.fps};
break;
default:
break;
}
@@ -974,7 +1188,7 @@ void Scene::held_draw(Fighter& fighter)
float side = fighter.is_left() ? -1 : 1;
Draw draw = {
.color = game.color_get(fighter.index),
.color = cooldown_tint_get(fighter, held, 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,
@@ -1003,7 +1217,7 @@ void Scene::offhand_draw(Fighter& fighter)
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),
.color = cooldown_tint_get(fighter, off, 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),
@@ -1018,21 +1232,39 @@ void Scene::offhand_draw(Fighter& fighter)
// down the lash or rope.
void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw)
{
// A weapon's own animation idles, looping. Its charge animation (if it has one) shows how far it's charged, and its
// cooldown one how far it's cooled down, their frames stepping through and holding on the last once full.
Anm2& art = game.sprites.weapons;
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand);
std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name;
std::string charging = animation + " Charge";
std::string cooling = animation + " Cooldown";
Anm2Animation* charge = fighter.charge > 0 ? art.animation_get(charging.c_str()) : nullptr;
Anm2Animation* cooldown = fighter.cooldowns[weaponSlot] > 0 ? art.animation_get(cooling.c_str()) : nullptr;
float frame = SDL_GetTicks() * art.fps / SDL_MS_PER_SECOND;
if (tether)
{
Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position;
draw.angle = vec2_angle(end - draw.position);
animation += " Base";
}
else if (charge)
{
animation = charging;
frame = std::min(1.0f, fighter.charge) * (charge->length - 1);
}
else if (cooldown)
{
animation = cooling;
frame = (1 - cooldown_left_get(fighter, weaponSlot)) * (cooldown->length - 1);
}
// A weapon without held art yet shows its icon, small and upright.
if (!game.sprites.weapons.animation_get(animation.c_str()))
if (!art.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);
art.draw(game.renderer, animation.c_str(), frame, draw);
}