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:
1 parent
e55596b276
commit
e9d8cac926
101 files changed
+5787
-7148
No files matched your search
+292
-60
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user