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

+73 -57
View File
@@ -3,7 +3,7 @@
#include <algorithm>
constexpr float AIR_ACCELERATION = 400;
constexpr float BOUNCE_SHAKE = 0.0005f;
constexpr float BOUNCE_SHAKE = 0.00025f;
constexpr float SLAM_DAMAGE = 0.06f;
constexpr float KNOCK_BASE = 60;
constexpr float KNOCK_LIFT = 0.35f;
@@ -17,11 +17,12 @@ constexpr float CLING_SPEED = 60;
constexpr float COYOTE_TIME = 0.1f;
constexpr float DASH_COOLDOWN = 0.8f;
constexpr float DASH_TIME = 0.12f;
constexpr float DEATH_SHAKE = 1.5f;
constexpr float DEATH_SHAKE = 0.75f;
constexpr float FACING_DEADZONE = 4;
constexpr float FAST_FALL_GRAVITY = 2;
constexpr float FLASH_DECAY = 8;
constexpr float FAST_FALL_SPEED = 700;
constexpr float FALL_EASE = 2000;
constexpr float GROUND_ACCELERATION = 500;
constexpr float GROUND_FRICTION = 3000;
constexpr float JUMP_BUFFER = 0.1f;
@@ -34,13 +35,13 @@ constexpr float ROPE_MIN = 24;
constexpr float SLIDE_DUST_INTERVAL = 0.08f;
constexpr float SWING_ACCELERATION = 500;
constexpr float SWING_SPEED_MAX = 420;
constexpr float POUND_AREAS[LEVEL_COUNT] = {30, 40, 50};
constexpr float POUND_AREAS[LEVEL_COUNT] = {15, 20, 25};
constexpr float POUND_DAMAGES[LEVEL_COUNT] = {20, 35, 50};
constexpr float POUND_SCALE_MAX = 3;
constexpr float POUND_SCALE_MIN = 0.3f;
constexpr float POUND_SPEED = 700;
constexpr Vec2 HAND_OFFSET = {4, 2};
constexpr Vec2 OFFHAND_OFFSET = {-3, 4};
constexpr Vec2 OFFHAND_FAN = {3, 2};
constexpr float WALL_IMPACT_SPEED = 200;
constexpr float WALL_LIFT = 0.25f;
constexpr float WALL_MEMORY = 0.6f;
@@ -118,15 +119,14 @@ void Fighter::state_reset(Sim& sim, float maxHealth)
std::fill(std::begin(cooldowns), std::end(cooldowns), 0.0f);
flash = 0;
health = maxHealth;
offSlot = -1;
std::fill(std::begin(slotSwitches), std::end(slotSwitches), 0);
slot = 0;
isAlive = true;
}
// Loses health with a flash and hitstop, and gets knocked away from where the hit came from (and a little upward),
// up to a speed that grows with the damage. Knocks top up rather than stack, so rapid small hits can't juggle. A hard
// enough knock launches it. Shock Absorber softens it. Running out of health kills it in a burst, and its attacks
// vanish with it.
// up to a speed that grows with the damage (see knock_take). Shock Absorber softens it. Running out of health kills it
// in a burst, and its attacks vanish with it.
void Fighter::damage_take(Sim& sim, Damage damage)
{
// Races and the sandbox never cost health.
@@ -138,11 +138,8 @@ void Fighter::damage_take(Sim& sim, Damage damage)
Vec2 away = center_get() - damage.source;
Vec2 direction = vec2_normalize(vec2_normalize(vec2_length(away) > 1 ? away : Vec2{0, -1}) + Vec2{0, -KNOCK_LIFT});
float knock = std::min(KNOCK_MAX, KNOCK_BASE + damage.amount * KNOCK_PER_DAMAGE) * damage.knock *
(isCrouching ? CROUCH_DAMP : 1) * (isRacing ? RACE_KNOCK : 1) * knock_absorb_get();
float along = vec2_dot(body.velocity, direction);
impulse_add(direction * std::max(0.0f, knock - along), false);
launcher = damage.owner;
isLaunched |= knock >= LAUNCH_START_SPEED;
(isRacing ? RACE_KNOCK : 1) * knock_absorb_get();
knock_take(direction * knock, damage.owner);
if (health > 0)
return;
@@ -212,12 +209,12 @@ void Fighter::update(Sim& sim)
rope_update(*hook);
bool wasGrounded = body.isGrounded;
bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS);
float fallSpeed = body.velocity.y;
float runSpeed = body.velocity.x;
sim.world.obstacles = sim.weapons.solids_get(*this);
sim.weapons.solids_add(*this, sim.world);
sim.world.body_move(body);
sim.world.obstacles.clear();
sim.world.ledges.clear();
if (isLaunched)
bounce_resolve(sim, {runSpeed, fallSpeed});
safe_update(sim);
@@ -240,13 +237,14 @@ void Fighter::update(Sim& sim)
sim.cues->sound_play(SOUND_LAND);
sim.cues->burst_spawn(LAND_DUST, feet, 0, index);
}
// Iron Weights slam: the harder the landing, the harder the blast. Damage follows the landing speed, and the
// radius its square root.
if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED && isPounding)
// Iron Weights slam: landing faster than a plain fall can go (on anything, platforms too), and the harder the
// landing, the harder the blast. Every copy adds to its damage and radius. Damage follows the landing speed, and
// the radius its square root.
if (body.isGrounded && !wasGrounded && fallSpeed > MAX_FALL && is_trinket_equipped(TRINKET_WEIGHTS))
{
float impact = std::clamp(fallSpeed / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX);
float impact = std::min(fallSpeed / POUND_SPEED, POUND_SCALE_MAX);
sim.weapons.blast_spawn(sim, {.position = feet,
.area = POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact),
.area = trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact),
.damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * impact,
.owner = index,
.sound = SOUND_POUND});
@@ -295,9 +293,10 @@ void Fighter::update(Sim& sim)
buttonsPrevious = controls.buttons;
}
// Horizontal control and gravity. Holding S in the air falls faster, holding W floats with the Feather, and a spinning
// flail's pull lightens gravity. Slopes change the top speed, and crouch-slides answer only to the slope. Crouching
// on flat ground waddles along slowly.
// Horizontal control and gravity. Holding S in the air falls faster (Iron Weights speeding up both how fast it gets
// there and how fast it goes), and letting go eases back to a plain fall rather than stopping short. Holding W floats
// with the Feather, and a spinning flail's pull lightens gravity. Slopes change the top speed, and crouch-slides answer
// only to the slope. Crouching on flat ground waddles along slowly.
// Airborne on a rope, moving left and right pumps the swing instead.
// Above run speed in the air, launch momentum bleeds off slowly unless steered against. Launched by a hit, there's no
// control at all, just gravity and the knock dying down.
@@ -320,11 +319,15 @@ void Fighter::run_update(Attack* hook)
bool isAirborne = !body.isGrounded && !hook;
bool isFastFalling = isAirborne && is_down(CONTROL_DOWN) && !isSliding;
bool isFloating = isAirborne && is_down(CONTROL_UP);
float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY : 1);
float weights = isFastFalling ? trinket_get(TRINKET_WEIGHTS, 1) : 1;
float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY * weights : 1);
gravity *= isFloating ? trinket_get(TRINKET_FEATHER, 1) : 1;
gravity *= isSpinning ? SPIN_GRAVITY : 1;
float maxFall = isFastFalling ? FAST_FALL_SPEED * trinket_get(TRINKET_WEIGHTS, 1) : MAX_FALL;
body.velocity.y = std::min(body.velocity.y + gravity * TICK, maxFall);
float maxFall = isFastFalling ? FAST_FALL_SPEED * weights : MAX_FALL;
if (body.velocity.y > maxFall)
body.velocity.y = std::max(maxFall, body.velocity.y - FALL_EASE * TICK);
else
body.velocity.y = std::min(body.velocity.y + gravity * TICK, maxFall);
// Sliding: the slope pulls it downhill and little else slows it.
if (isSliding)
@@ -402,7 +405,7 @@ void Fighter::slot_update(Sim& sim)
sim.cues->sound_play(SOUND_SELECT);
if (slot != previous && weaponLoadout[previous] >= 0 && weapon_key(previous) != weapon_key(slot))
{
offSlot = previous;
slotSwitches[previous] = ++switches;
sim.weapons.attacks_release(*this, ATTACK_FLAIL);
}
@@ -654,15 +657,14 @@ float Fighter::trinket_cooldown_get(int trinket)
return 0;
}
// The radius an Iron Weights slam would blast if the fighter, pounding, landed at its current speed, or 0 if it isn't
// pounding down fast enough to slam at all.
// The radius an Iron Weights slam would blast if the fighter landed at its current speed, or 0 if it isn't falling
// fast enough to slam at all.
float Fighter::pound_area_get()
{
bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS);
if (!isPounding || body.isGrounded || body.velocity.y <= LAND_SPEED)
if (!is_trinket_equipped(TRINKET_WEIGHTS) || body.isGrounded || body.velocity.y <= MAX_FALL)
return 0;
float impact = std::clamp(body.velocity.y / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX);
return POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact);
float impact = std::min(body.velocity.y / POUND_SPEED, POUND_SCALE_MAX);
return trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact);
}
// Moves the fighter's middle to centre, keeping its momentum, without drawing it sliding across (teleports).
@@ -681,6 +683,19 @@ void Fighter::impulse_add(Vec2 impulse, bool isDamped)
isJumping = false;
}
// A knock from another fighter's hit or blast, softened while crouching. It tops up the speed already going its way
// rather than stacking, so rapid small knocks can't juggle, and a hard enough one launches it (that fighter getting
// the credit for whatever it bounces into).
void Fighter::knock_take(Vec2 knock, int owner)
{
knock = knock * (isCrouching ? CROUCH_DAMP : 1);
float strength = vec2_length(knock);
Vec2 direction = vec2_normalize(knock);
impulse_add(direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction)), false);
launcher = owner;
isLaunched |= strength >= LAUNCH_START_SPEED;
}
// A push held briefly without gravity or friction, so it carries even along the ground. Used by dashes and lunges.
void Fighter::burst_add(Vec2 impulse)
{
@@ -827,28 +842,27 @@ bool Fighter::is_released(Control control)
return !is_down(control) && buttonsPrevious >> control & 1;
}
// Dual Wield's off-hand weapon slot: the last one switched away from if it still holds a weapon that isn't the one in
// hand, otherwise any other slot that does, or -1 if there is none. When weapons stack, a copy of the weapon in hand
// is another weapon; otherwise it's the same one.
int Fighter::off_slot_get()
// Dual Wield's off-hand weapon slots, one per copy, written to slots: the other slots holding weapons, the ones most
// recently switched away from first, then any never switched to, in slot order. Returns how many.
int Fighter::off_slots_get(int* slots)
{
int held = weapon_key(slot);
int fallback = -1;
int count = 0;
for (int i = 0; i < weaponSlots; i++)
{
if (weaponLoadout[i] < 0 || weapon_key(i) == held)
continue;
if (i == offSlot)
return i;
fallback = fallback < 0 ? i : fallback;
}
return fallback;
if (weaponLoadout[i] >= 0 && weapon_key(i) != held)
slots[count++] = i;
std::stable_sort(slots, slots + count, [&](int a, int b) { return slotSwitches[a] > slotSwitches[b]; });
return std::min(count, trinket_count(TRINKET_DUAL));
}
int Fighter::off_weapon_get()
// The weapon slot a hand holds: the selected one in the main hand (0), and Dual Wield's in the off-hands (1 up), or -1
// for an off-hand holding nothing.
int Fighter::hand_slot_get(int hand)
{
int off = off_slot_get();
return off < 0 ? -1 : weaponLoadout[off];
if (hand == 0)
return slot;
int slots[SLOT_MAX];
return hand <= off_slots_get(slots) ? slots[hand - 1] : -1;
}
// Moves the box alpha of the way through the last tick. Only for drawing.
@@ -863,15 +877,17 @@ Vec2 Fighter::center_get()
return {body.box.x + body.box.w / 2, body.box.y + body.box.h / 2};
}
Vec2 Fighter::hand_get()
// Where a hand holds its weapon: the main hand (0) in front, and Dual Wield's off-hands (1 up) behind the body, the
// first just behind it and the rest fanned out further back, alternating up and down.
Vec2 Fighter::hand_get(int hand)
{
return center_get() + Vec2{is_left() ? -HAND_OFFSET.x : HAND_OFFSET.x, HAND_OFFSET.y} * scale;
}
// The far hand, just behind the body, where Dual Wield holds the off-hand weapon.
Vec2 Fighter::offhand_get()
{
return center_get() + Vec2{is_left() ? -OFFHAND_OFFSET.x : OFFHAND_OFFSET.x, OFFHAND_OFFSET.y} * scale;
Vec2 offset = HAND_OFFSET;
if (hand > 0)
{
int step = hand / 2;
offset = OFFHAND_OFFSET + Vec2{-OFFHAND_FAN.x * step, (hand % 2 == 0 ? -1 : 1) * OFFHAND_FAN.y * step};
}
return center_get() + Vec2{is_left() ? -offset.x : offset.x, offset.y} * scale;
}
// Full charge pulses white on and off, shared by the charge bar and the held weapon.