HUD pass, weapon and trinket rework, and font arrows

HUD:
- Hotbars fit a quarter of the screen, dropping to small then tiny slots
  (thinner outlines when tiny), with cooldown bars matching their size.
- HUD elements are drawn on their own layer and composited at one
  opacity: hotbar slots are see-through until hovered, the rest fade
  while hovered.
- Rival windows fit the right column in at most two rows, shrinking
  icons then font, then dropping icons for name, health and ping bars.
- Tooltips number equipped items' slots and say Recoil for weapons that
  only push back. Previews use arrows, spread uses degrees.
- Crosshair fills in and shakes with charge, and weapons shake after
  firing until their cooldown ends. DPS and speed meters off by default.

Gameplay:
- Dual Wield stacks: each copy fires another recently held weapon from
  a fanned off-hand, at its own level's cut.
- Flamethrower: no slowing, smaller fireball with some recoil, and an
  airblast that knocks others away.
- Iron Weights slam on any landing faster than a plain fall (platforms
  too), stacking radius and damage, and speeding fast falls.
- Stuck arrows are one-way ledges until they fade, matching their
  (slightly larger) hit size. Attacks now collide with slopes.
- Halved Tesla Coil, Recoil Coil, Diazepam, Shock Absorber, Feather and
  screenshake values.

Art and tools:
- Weapon and trinket icons merged into one sheet each (normal, small,
  tiny), cells in catalog order. Grimoire wisps stand upright.
- Fonts gain the degree sign and four arrows, decoded from UTF-8.
- Menu fighter preview walks at quarter speed.
- Unequip from either catalog tab by right click or gamepad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 16:43:45 -05:00
1 parent 6003175173
commit 6f112ab231
54 files changed
+1150 -3046

No files matched your search

+78 -63
View File
@@ -1,6 +1,7 @@
#include "weapon.h"
#include "sim.h"
#include <algorithm>
#include <functional>
constexpr int ATTACKS_MAX = 255;
constexpr int DAMAGE_STEP = 5;
@@ -8,12 +9,11 @@ constexpr int HEAVY_DAMAGE = 40;
constexpr int HOOK_SUBSTEPS = 4;
constexpr int TESLA_LINKS = 5;
constexpr float TESLA_INTERVAL = 0.2f;
constexpr float TESLA_RADIUS = 4;
constexpr float TESLA_RADIUS = 2;
constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220};
constexpr float DEGREE = PI / 180;
constexpr float ARROW_LINGER = 2.5f;
constexpr float ARROW_SPAN = 18;
constexpr float ARROW_THICKNESS = 2;
constexpr float ARROW_RADIUS = 6;
constexpr float BEAM_RADIUS = 4;
constexpr float BOOMERANG_LIFETIME = 6;
constexpr float BOOMERANG_RADIUS = 9;
@@ -30,8 +30,6 @@ constexpr float TRAJECTORY_REACH = 400;
constexpr float TRAJECTORY_STEP = 4;
constexpr float FIRE_SHAKE = 0.5f;
constexpr float FIREBALL_RADIUS = 5;
constexpr float FLAME_BRAKE = 0.9f;
constexpr float FLAME_BRAKE_REACH = 16;
constexpr float FLAIL_BOUNCE = 0.3f;
constexpr float FLAIL_BOUNCE_MAX = 0.9f;
constexpr float FLAIL_BOUNCE_PER_RICOCHET = 0.2f;
@@ -72,7 +70,7 @@ constexpr float PUNCH_RADIUS = 6;
constexpr float PUNCH_THRUST = 0.4f;
constexpr float SLASH_DISTANCE = 12;
constexpr float SLASH_GAP = 14;
constexpr float SHAKE_PER_DAMAGE = 0.006f;
constexpr float SHAKE_PER_DAMAGE = 0.003f;
constexpr float SLAM_RATIO = 0.75f;
constexpr float SPEED_DAMAGE = 0.05f;
// A normal attack let go partway through a charge hits harder, up to this much more just short of full.
@@ -418,9 +416,9 @@ int shapes_get(Sim& sim, Attack& attack, Capsule* shapes)
// Fires the weapon in hand with the given charge if it's ready. Full charge uses the charged attack.
// With the Doubler on, the same attack repeats a moment later for a fraction of the damage (stacked Doublers each
// add a repeat of their own, one after another). With Dual Wield on, the off-hand weapon fires too, for a fraction of
// its damage. Every weapon (see Fighter::weapon_key) keeps its own cooldown, in either hand, so swapping to another
// weapon never inherits the last one's wait.
// add a repeat of their own, one after another). With Dual Wield on, an off-hand weapon per copy fires too (see
// Fighter::off_slots_get), each for a fraction of its damage. Every weapon (see Fighter::weapon_key) keeps its own
// cooldown, in any hand, so swapping to another weapon never inherits the last one's wait.
void Weapons::fire(Sim& sim, Fighter& fighter, float charge)
{
int held = fighter.weapon_key(fighter.slot);
@@ -439,13 +437,22 @@ void Weapons::fire(Sim& sim, Fighter& fighter, float charge)
}
}
int off = fighter.off_slot_get();
off = off < 0 ? -1 : fighter.weapon_key(off);
float dual = fighter.trinket_get(TRINKET_DUAL, 0);
if (dual > 0 && off >= 0 && off != held && fighter.cooldowns[off] <= 0 && !is_busy(fighter, off))
// Each Dual Wield copy fires one off-hand weapon at its own level's cut, the strongest cut going to the first.
float powers[SLOT_MAX];
int copies = 0;
for (int i = 0; i < fighter.trinketSlots; i++)
if (fighter.trinketLoadout[i] == TRINKET_DUAL)
powers[copies++] = TRINKET_DEFS[TRINKET_DUAL].values[fighter.trinketSlotLevels[i]];
std::sort(powers, powers + copies, std::greater<float>());
int offs[SLOT_MAX];
int count = fighter.off_slots_get(offs);
for (int hand = 1; hand <= count; hand++)
{
int off = fighter.weapon_key(offs[hand - 1]);
if (fighter.cooldowns[off] > 0 || is_busy(fighter, off))
continue;
is_spawned(sim, fighter,
{.charge = charge, .power = dual, .weaponSlot = off, .isOffhand = true, .isSecondary = true});
{.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true});
fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves;
}
}
@@ -659,7 +666,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot)
Attack attack = {
.stats = stats,
.origin = fighter.hand_get(),
.origin = fighter.hand_get(shot.hand),
.velocity = aim * stats.speed,
.aimAngle = vec2_angle(aim),
.angle = vec2_angle(aim),
@@ -671,9 +678,9 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot)
.owner = fighter.index,
.weapon = held,
.key = key,
.hand = shot.hand,
.isCharged = isCharged,
.isLeft = fighter.is_left(),
.isOffhand = shot.isOffhand,
.isSecondary = shot.isSecondary,
};
attack.position = attack.origin + aim * MUZZLE_DISTANCE;
@@ -763,6 +770,12 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot)
fighter.index);
if (isCharged)
sim.cues->shake_add(def.shake * FIRE_SHAKE);
// A fireball goes off with an airblast at the muzzle, as wide as the fireball's own blast.
if (kind == ATTACK_FIREBALL)
blast_spawn(sim, {.position = muzzle,
.area = stats.area * fighter.trinket_get(TRINKET_ENLARGER, 1),
.owner = fighter.index,
.isHarmless = true});
// An uppercut launches its fighter the way it rises, cancelling any speed against that first. Shock Absorber
// softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil.
@@ -855,20 +868,6 @@ void Weapons::update(Sim& sim)
attack.time += TICK;
}
tesla_update(sim);
// Standing in your own lingering flames brakes you, launches included.
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
for (Attack& attack : attacks)
{
bool isNear = vec2_length(attack.position - fighter.center_get()) < FLAME_BRAKE_REACH;
if (attack.owner != i || attack.def()->kind != ATTACK_FLAME || attack.isDead || !isNear)
continue;
fighter.body.velocity = fighter.body.velocity * FLAME_BRAKE;
break;
}
}
attacks_cap(sim);
std::erase_if(attacks, [](Attack& attack) { return attack.isDead; });
attacks.insert(attacks.end(), pending.begin(), pending.end());
@@ -1002,7 +1001,10 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
// path so they can't skip past targets either.
Vec2 start = attack.position;
int steps = vec2_length(attack.velocity) * TICK / PROJECTILE_STEP + 1;
attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS : BULLET_RADIUS) * attack.scale;
attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS
: def.kind == ATTACK_ARROW ? ARROW_RADIUS
: BULLET_RADIUS) *
attack.scale;
for (int step = 0; step < steps && !attack.isDead; step++)
{
Vec2 next = attack.position + attack.velocity * (TICK / steps);
@@ -1080,7 +1082,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft;
if (attack.ricochets == 0)
{
attack.origin = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get();
attack.origin = fighter.hand_get(attack.hand);
attack.angle = vec2_angle(attack.tip - attack.origin);
attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE);
}
@@ -1091,7 +1093,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
// once. A plain lash rolls out from the handle, going with the hand, sweeping down across its cone from
// the upper edge, and hits anywhere in the cone out to its front until it cracks at full reach (short of
// any wall).
attack.origin = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get();
attack.origin = fighter.hand_get(attack.hand);
attack.radius = BEAM_RADIUS * attack.scale;
attack.isDead = progress >= 1;
if (attack.isCharged)
@@ -1151,7 +1153,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
case ATTACK_SPIN:
{
// Keeps circling while left is held, finishing the current lap once let go.
int hand = attack.isOffhand ? fighter.off_slot_get() : fighter.slot;
int hand = fighter.hand_slot_get(attack.hand);
bool isHeld = fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key;
if (isHeld && attack.time >= attack.duration)
{
@@ -1500,7 +1502,7 @@ void Weapons::teleport_move(Sim& sim, Attack& attack)
attack.isDead = true;
Vec2 spot = attack.position;
if (!attack.isCopy && !attack.isOffhand && fighter.isAlive)
if (!attack.isCopy && attack.hand == 0 && fighter.isAlive)
{
sim.cues->burst_spawn(PORTAL_BURST, fighter.center_get(), 0, attack.owner);
spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get()));
@@ -1600,7 +1602,7 @@ std::vector<Path> Weapons::paths_get(Sim& sim, Fighter& fighter)
{
std::vector<Path> paths;
int key = fighter.weapon_key(fighter.slot);
if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, false)))
if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, 0)))
return paths;
const WeaponDef& weapon = WEAPON_DEFS[fighter.weaponLoadout[key]];
const LevelDef& level = fighter.level_get(key);
@@ -1766,9 +1768,34 @@ void Weapons::attack_explode(Sim& sim, Attack& attack)
}
// Queues an explosion that hits everything inside it next tick, weaker toward its edge. Pushing blasts fling every
// fighter away from the centre, harder the closer they are.
// fighter away from the centre, harder the closer they are. A harmless blast (an airblast) only knocks everyone but its
// owner within its area, launching them if close enough, shown as a ring.
void Weapons::blast_spawn(Sim& sim, Blast blast)
{
float reach = blast.area * (blast.isHarmless ? 1 : KNOCKBACK_REACH);
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
Vec2 away = fighter.center_get() - blast.position;
float distance = vec2_length(away);
// Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback.
float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get();
bool isPushed = blast.isHarmless ? i != blast.owner : blast.isPushing;
if (!isPushed || !fighter.isAlive || distance >= reach)
continue;
Vec2 push =
vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * (1 - distance / reach) * absorb;
if (blast.isHarmless)
fighter.knock_take(push, blast.owner);
else
fighter.impulse_add(push);
}
if (blast.isHarmless)
{
sim.cues->ring_spawn(blast.position, reach, blast.owner);
return;
}
Attack attack = {
.stats = {.damage = blast.damage, .range = blast.area},
.origin = blast.position,
@@ -1785,42 +1812,29 @@ void Weapons::blast_spawn(Sim& sim, Blast blast)
};
attack_remember(attack);
pending.push_back(attack);
float reach = blast.area * KNOCKBACK_REACH;
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
Vec2 away = fighter.center_get() - blast.position;
float distance = vec2_length(away);
// Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback.
float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get();
if (blast.isPushing && fighter.isAlive && distance < reach)
fighter.impulse_add(vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED *
(1 - distance / reach) * absorb);
}
sim.cues->sound_play(blast.sound);
sim.cues->shake_add(EXPLOSION_DEF.shake);
sim.cues->burst_spawn(BLAST_BURST, blast.position, 0, blast.owner);
}
// The whip lashing or hook flying out from this hand, which leaves only the weapon's base in it.
Attack* Weapons::tether_get(Fighter& fighter, int key, bool isOffhand)
Attack* Weapons::tether_get(Fighter& fighter, int key, int hand)
{
for (Attack& attack : attacks)
{
AttackKind kind = attack.def()->kind;
if ((kind == ATTACK_WHIP || kind == ATTACK_HOOK) && attack.owner == fighter.index && attack.key == key &&
attack.isOffhand == isOffhand && !attack.isCopy && !attack.isDead)
attack.hand == hand && !attack.isCopy && !attack.isDead)
return &attack;
}
return nullptr;
}
// The fighter's own attacks it can stand on or bump into: stuck arrows and charged boomerangs in flight.
std::vector<Rect> Weapons::solids_get(Fighter& fighter)
// Adds the fighter's own attacks it can stand on to the world for its next move: charged boomerangs in flight as
// obstacles it bumps into, and stuck arrows (until they start fading) as ledges it can only land on from above (and
// drop through).
void Weapons::solids_add(Fighter& fighter, World& world)
{
std::vector<Rect> solids;
for (Attack& attack : attacks)
{
if (attack.owner != fighter.index || attack.isDead)
@@ -1829,17 +1843,18 @@ std::vector<Rect> Weapons::solids_get(Fighter& fighter)
if (kind == ATTACK_BOOMERANG && attack.isCharged)
{
float size = BOOMERANG_RADIUS * attack.scale;
solids.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2});
world.obstacles.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2});
}
if (kind == ATTACK_ARROW && attack.isAttached)
// A stuck arrow's ledge is the box around its hit shape.
if (kind == ATTACK_ARROW && attack.isAttached && attack.time < attack.duration - ARROW_FADE)
{
Vec2 middle = attack.position - vec2_polar(attack.angle, ARROW_SPAN / 2);
Vec2 half = {std::abs(math_cos(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS,
std::abs(math_sin(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS};
solids.push_back({middle.x - half.x, middle.y - half.y, half.x * 2, half.y * 2});
Vec2 low = {std::min(attack.position.x, attack.tip.x), std::min(attack.position.y, attack.tip.y)};
Vec2 high = {std::max(attack.position.x, attack.tip.x), std::max(attack.position.y, attack.tip.y)};
float radius = attack.radius;
world.ledges.push_back(
{low.x - radius, low.y - radius, high.x - low.x + radius * 2, high.y - low.y + radius * 2});
}
}
return solids;
}
// Whether the fighter has a flail spinning around it.