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

+59 -38
View File
@@ -26,13 +26,12 @@ 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 float ARROW_FADE = 0.6f;
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 = 4;
constexpr float BOLT_JITTER = 2;
constexpr float BOLT_SEGMENT = 8;
constexpr float BOLT_WIDTH = 2;
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.
@@ -238,29 +237,43 @@ static float kick_get(Fighter& fighter)
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, float cooldown, Draw& draw)
static void reload_pose(Fighter& fighter, int key, Draw& draw)
{
if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || cooldown <= 0)
if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || fighter.cooldowns[key] <= 0)
return;
float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1);
float t = std::clamp((1 - cooldown / total - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f);
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 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.
static float charge_fill_get(Game& game, Fighter& fighter)
// 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 game.scene.is_flashing(fighter) ? 1.0f : buff;
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];
@@ -313,6 +326,12 @@ 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)
@@ -674,7 +693,7 @@ void Scene::attacks_draw()
if (kind == ATTACK_HOOK)
{
// The rope runs from the end of the grapple's base in the hand.
Vec2 hand = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get();
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});
}
@@ -890,7 +909,7 @@ Pose Scene::attack_pose(Attack& attack)
// 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;
draw.angle = isFlying ? attack.angle - PI / 2 : 0;
pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps};
break;
}
@@ -905,21 +924,21 @@ Pose Scene::attack_pose(Attack& attack)
}
// 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 harder the further the charge has got. A charging mine weapon holds up the
// mine it's about to place instead.
// 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, false)))
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() + jitter_get(fighter.charge * CHARGE_SHAKE),
.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(game, fighter),
.flash = charge_fill_get(fighter),
};
if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE)
{
@@ -928,36 +947,38 @@ void Scene::held_draw(Fighter& fighter)
game.sprites.effects.draw(game.renderer, "Mine", 0, draw);
return;
}
reload_pose(fighter, held, fighter.cooldowns[held], draw);
hand_draw(fighter, held, false, draw);
reload_pose(fighter, held, draw);
hand_draw(fighter, held, 0, draw);
}
// Dual Wield: the off-hand weapon, a darker shade of the fighter's colour, held in the far hand behind it.
// 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 off = fighter.off_slot_get();
off = off < 0 ? -1 : fighter.weapon_key(off);
if (!fighter.is_trinket_equipped(TRINKET_DUAL) || off < 0 ||
(game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, true)))
return;
int offs[SLOT_MAX];
float side = fighter.is_left() ? -1 : 1;
Draw draw = {
.color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE),
.position = fighter.offhand_get() + jitter_get(fighter.charge * CHARGE_SHAKE),
.scale = {1, side},
.angle = vec2_angle(fighter.aim),
.flash = charge_fill_get(game, fighter),
};
reload_pose(fighter, off, fighter.cooldowns[off], draw);
hand_draw(fighter, off, true, draw);
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, bool isOffhand, Draw draw)
void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw)
{
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, isOffhand);
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand);
std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name;
if (tether)
{