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

+103 -77
View File
@@ -30,13 +30,13 @@ constexpr float JUMP_RELEASE_SPEED = 160;
constexpr float KICK_COOLDOWN = 1.2f;
constexpr float LAUNCH_DRAG = 160;
constexpr float LAND_SPEED = 150;
// The most charge a normal attack can carry, just short of a full charge's charged attack.
constexpr float CHARGE_NORMAL_MAX = 0.999f;
constexpr float MAX_FALL = 520;
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] = {15, 20, 25};
constexpr float POUND_DAMAGES[LEVEL_COUNT] = {20, 35, 50};
constexpr float POUND_SCALE_MAX = 3;
constexpr float POUND_SPEED = 700;
constexpr Vec2 HAND_OFFSET = {4, 2};
@@ -47,7 +47,9 @@ constexpr float WALL_LIFT = 0.25f;
constexpr float WALL_MEMORY = 0.6f;
constexpr float WALL_REFLECT = 1.2f;
constexpr float WALL_POINT_HEIGHT = 0.4f;
constexpr Vec2 KICK_VELOCITY = {220, -330};
constexpr Vec2 KICK_VELOCITY = {380, -260};
constexpr float KICK_STACK_BOOST = 0.25f;
constexpr float HITSTOP_RUN_MAX = 1;
constexpr Vec2 WALL_JUMP_VELOCITY = {180, -JUMP_SPEED};
constexpr float CROUCH_HEIGHT = 0.75f;
constexpr float CROUCH_DAMP = 0.5f;
@@ -108,6 +110,7 @@ void Fighter::position_set(Sim& sim, Vec2 spawn)
safe = spawn;
echoes.clear();
hitstop = 0;
hitstopRun = 0;
isLaunched = false;
}
@@ -133,12 +136,12 @@ void Fighter::damage_take(Sim& sim, Damage damage)
bool isRacing = sim.match.is_racing();
health -= isRacing || sim.match.state == MATCH_PRACTICE ? 0 : damage.amount;
flash = 1;
hitstop = std::max(hitstop, damage.hitstop);
hitstop_add(damage.hitstop);
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 *
(isRacing ? RACE_KNOCK : 1) * knock_absorb_get();
(isRacing ? RACE_KNOCK : 1) * stat_get(STAT_KNOCKBACK_TAKEN);
knock_take(direction * knock, damage.owner);
if (health > 0)
return;
@@ -155,19 +158,24 @@ void Fighter::damage_take(Sim& sim, Damage damage)
void Fighter::update(Sim& sim)
{
// Out of the round, it still keeps track of its buttons, so a held one (like Ready on the results) counts once.
// Its hitstop runs out too, or the attacks it left out would stay frozen with it.
if (!isAlive)
{
hitstop = std::max(0.0f, hitstop - TICK);
buttonsPrevious = controls.buttons;
return;
}
cornerPrevious = {body.box.x, body.box.y};
flash = std::max(0.0f, flash - FLASH_DECAY * TICK);
shockCooldown -= TICK;
donkWindow -= TICK;
if (hitstop > 0)
{
hitstop -= TICK;
hitstopRun += TICK;
return;
}
hitstopRun = 0;
for (float& cooldown : cooldowns)
cooldown = std::max(0.0f, cooldown - TICK);
@@ -248,8 +256,8 @@ void Fighter::update(Sim& sim)
{
float impact = std::min(fallSpeed / POUND_SPEED, POUND_SCALE_MAX);
sim.weapons.blast_spawn(sim, {.position = feet,
.area = trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact),
.damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * impact,
.area = stat_get(STAT_SLAM_AREA) * std::sqrt(impact),
.damage = stat_get(STAT_SLAM_DAMAGE) * impact,
.owner = index,
.sound = SOUND_POUND});
}
@@ -309,8 +317,8 @@ void Fighter::run_update(Attack* hook)
float direction = (float)is_down(CONTROL_RIGHT) - (float)is_down(CONTROL_LEFT);
float speed = std::abs(body.velocity.x);
float sign = body.velocity.x < 0 ? -1 : 1;
float boots = trinket_get(TRINKET_BOOTS, 1);
float runSpeed = RUN_SPEED * boots * (1 + trinket_get(TRINKET_SPEEDOMETER, 0)) * (isCrouching ? CRAWL_SPEED : 1);
float boots = stat_get(STAT_RUN_SPEED);
float runSpeed = RUN_SPEED * boots * (isCrouching ? CRAWL_SPEED : 1);
if (dashTimer > 0)
return;
if (isLaunched)
@@ -323,9 +331,9 @@ 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 weights = isFastFalling ? trinket_get(TRINKET_WEIGHTS, 1) : 1;
float weights = isFastFalling ? stat_get(STAT_FAST_FALL) : 1;
float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY * weights : 1);
gravity *= isFloating ? trinket_get(TRINKET_FEATHER, 1) : 1;
gravity *= isFloating ? stat_get(STAT_FLOAT_GRAVITY) : 1;
gravity *= isSpinning ? SPIN_GRAVITY : 1;
float maxFall = isFastFalling ? FAST_FALL_SPEED * weights : MAX_FALL;
if (body.velocity.y > maxFall)
@@ -382,7 +390,7 @@ void Fighter::rope_update(Attack& hook)
float reel = (float)is_down(CONTROL_DOWN) - (float)is_down(CONTROL_UP);
reel *= WEAPON_DEFS[hook.weapon].levels[hook.key >= 0 ? slot_level_get(hook.key) : 0].reel;
if (hook.isPulling)
reel = -hook.stats.speed * trinket_get(TRINKET_ABSORBER, 1);
reel = -hook.stats.speed * stat_get(STAT_MOVEMENT);
hook.length = std::clamp(hook.length + reel * TICK, ROPE_MIN, hook.stats.range);
hook.isPulling &= hook.length > ROPE_MIN;
@@ -418,7 +426,7 @@ void Fighter::slot_update(Sim& sim)
// Shrinker, Embiggener and crouching: resizes the box around the feet to the fighter's current size.
void Fighter::size_update()
{
scale = trinket_get(TRINKET_SHRINKER, 1) * trinket_get(TRINKET_EMBIGGENER, 1);
scale = stat_get(STAT_SIZE);
float height = BOX_HEIGHT * scale * (isCrouching || isSliding ? CROUCH_HEIGHT : 1);
Vec2 feet = {center_get().x, body.box.y + body.box.h};
body.box = {feet.x - BOX_WIDTH * scale / 2, feet.y - height, BOX_WIDTH * scale, height};
@@ -430,7 +438,7 @@ void Fighter::size_update()
// works while there's a dash's worth left to build up before all of them are spent.
void Fighter::dash_update(Sim& sim, bool isJumpPressed)
{
float speed = trinket_best_get(TRINKET_WINGS, 0);
float speed = stat_get(STAT_DASH_SPEED);
dashTimer -= TICK;
dashCooldown = std::max(0.0f, dashCooldown - TICK);
@@ -450,7 +458,7 @@ void Fighter::dash_update(Sim& sim, bool isJumpPressed)
// off, on a cooldown so it can't climb forever. 3: Jump wall jumps freely.
void Fighter::climb_update(Sim& sim)
{
int level = trinket_get(TRINKET_CLIMBING, 0);
int level = stat_get(STAT_CLIMB);
int wall = wall_get(sim);
kickTimer -= TICK;
slideWall = 0;
@@ -469,9 +477,10 @@ void Fighter::climb_update(Sim& sim)
}
Control away = wall > 0 ? CONTROL_LEFT : CONTROL_RIGHT;
// Each copy past the first kicks off a quarter harder.
if (level >= 2 && kickTimer <= 0 && is_pressed(away))
{
wall_push(sim, wall, KICK_VELOCITY);
wall_push(sim, wall, KICK_VELOCITY * (1 + KICK_STACK_BOOST * (trinket_count(TRINKET_CLIMBING) - 1)));
kickTimer = KICK_COOLDOWN;
isJumping = false;
}
@@ -600,9 +609,9 @@ void Fighter::charge_update(Sim& sim)
bool isLeftPressed = is_pressed(CONTROL_ATTACK);
bool isRightPressed = is_pressed(CONTROL_CHARGE);
if (is_released(CONTROL_ATTACK))
sim.weapons.wisps_release(*this);
sim.weapons.orbits_release(*this);
// A latched grapple lets go when clicked with the grapple in hand; another weapon fires as it hangs on.
// A latched grapple lets go when clicked with the grapple in hand. Another weapon fires as it hangs on.
if (hook && hook->key == weapon_key(slot) && (isLeftPressed || isRightPressed))
{
sim.weapons.attacks_release(*this, ATTACK_HOOK);
@@ -615,8 +624,28 @@ void Fighter::charge_update(Sim& sim)
return;
}
// A full charge let go of during a cooldown goes first, the moment the weapon in hand is ready (see below).
// Attacking while charging spends the charge on the normal attack (as strong as the charge built, short of the
// charged one) once it's ready. A weapon whose charge just strengthens its shot lets it go as if released instead,
// and a Mine's detonator leaves the charge (the next mine) be.
int key = weapon_key(slot);
int weapon = weapon_get();
bool isDetonator = weapon >= 0 && WEAPON_DEFS[weapon].normal.kind == ATTACK_DETONATE;
bool isReady = key < 0 || cooldowns[key] <= 0;
if (isChargeQueued && isReady)
{
sim.weapons.fire(sim, *this, 1);
isChargeQueued = false;
isReady = false;
}
isFireBlocked &= is_down(CONTROL_ATTACK);
if (is_down(CONTROL_ATTACK) && !isFireBlocked)
if (is_down(CONTROL_ATTACK) && !isFireBlocked && isCharging && isReady && !isDetonator)
{
bool isScaled = weapon >= 0 && WEAPON_DEFS[weapon].isChargeScaled;
sim.weapons.fire(sim, *this, isScaled ? charge : std::min(charge, CHARGE_NORMAL_MAX));
charge_cancel(sim);
}
else if (is_down(CONTROL_ATTACK) && !isFireBlocked)
sim.weapons.fire(sim, *this, 0);
if (isRightPressed && weapon_get() >= 0)
@@ -625,7 +654,7 @@ void Fighter::charge_update(Sim& sim)
if (isCharging && is_down(CONTROL_CHARGE) && charge < 1)
{
float previous = charge;
charge = std::min(1.0f, charge + TICK / CHARGE_TIME * trinket_get(TRINKET_DRINK, 1));
charge = std::min(1.0f, charge + TICK / CHARGE_TIME * stat_get(STAT_CHARGE_RATE));
if (previous < CHARGE_VISIBLE && charge >= CHARGE_VISIBLE)
sim.cues->charge_play(*this);
if (charge >= 1)
@@ -636,29 +665,44 @@ void Fighter::charge_update(Sim& sim)
}
}
// Let go, the charge drops back to nothing and fires. A full charge let go of while the weapon in hand is cooling
// down waits, going the moment it's ready (see above).
if (isCharging && is_released(CONTROL_CHARGE))
{
sim.weapons.fire(sim, *this, charge);
sim.cues->charge_stop(*this);
bool isCooling = key >= 0 && cooldowns[key] > 0;
if (charge >= 1 && isCooling)
isChargeQueued = true;
else
sim.weapons.fire(sim, *this, charge);
charge = 0;
isCharging = false;
}
}
// How much of a trinket's own cooldown is left, from 1 just used to 0 ready. Wings dashes and Climb Claw kicks
// have one, and every other trinket is always 0.
float Fighter::trinket_cooldown_get(int trinket)
// have one, and every other trinket is always 0. Each Wings copy holds a dash of its own, the last copy's spent first
// and refilled first, and with no copy given it's whether the next dash is ready.
float Fighter::trinket_cooldown_get(int trinket, int copy)
{
if (trinket == TRINKET_WINGS)
{
int spare = std::max(0, trinket_count(TRINKET_WINGS) - 1);
return std::clamp((dashCooldown - DASH_COOLDOWN * spare) / DASH_COOLDOWN, 0.0f, 1.0f);
copy = copy >= 0 ? copy : std::max(0, trinket_count(TRINKET_WINGS) - 1);
return std::clamp((dashCooldown - DASH_COOLDOWN * copy) / DASH_COOLDOWN, 0.0f, 1.0f);
}
if (copy > 0)
return 0;
if (trinket == TRINKET_CLIMBING)
return std::max(0.0f, kickTimer) / KICK_COOLDOWN;
return 0;
}
// Freezes it in hitstop for at least amount, though never for more than a second in a row however much it's hit.
void Fighter::hitstop_add(float amount)
{
hitstop = std::max(hitstop, std::min(amount, HITSTOP_RUN_MAX - hitstopRun));
}
// 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()
@@ -666,7 +710,7 @@ float Fighter::pound_area_get()
if (!is_trinket_equipped(TRINKET_WEIGHTS) || body.isGrounded || body.velocity.y <= MAX_FALL)
return 0;
float impact = std::min(body.velocity.y / POUND_SPEED, POUND_SCALE_MAX);
return trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact);
return stat_get(STAT_SLAM_AREA) * std::sqrt(impact);
}
// Moves the fighter's middle to centre, keeping its momentum, without drawing it sliding across (teleports).
@@ -679,9 +723,9 @@ void Fighter::teleport_to(Vec2 centre)
// An outside push, like a blast, recoil or a lunge. It counts as a launch, so releasing jump won't cut it short.
// Crouching halves it (knockback, already halved before it gets here, isn't damped again).
void Fighter::impulse_add(Vec2 impulse, bool isDamped)
void Fighter::impulse_add(Vec2 impulse)
{
body.velocity = body.velocity + impulse * (isDamped && isCrouching ? CROUCH_DAMP : 1);
body.velocity = body.velocity + impulse * (isCrouching ? CROUCH_DAMP : 1);
isJumping = false;
}
@@ -693,7 +737,8 @@ 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);
body.velocity = body.velocity + direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction));
isJumping = false;
launcher = owner;
isLaunched |= strength >= LAUNCH_START_SPEED;
}
@@ -711,6 +756,7 @@ void Fighter::charge_cancel(Sim& sim)
sim.cues->charge_stop(*this);
charge = 0;
isCharging = false;
isChargeQueued = false;
}
// The weapon in the selected hotbar slot, or -1 if the slot is empty.
@@ -738,12 +784,11 @@ const LevelDef& Fighter::level_get(int weaponSlot)
}
// Puts an item in a slot (or empties it, with -1) at the item's level.
void Fighter::item_set(bool isTrinket, int itemSlot, int item)
void Fighter::item_set(ItemKind kind, int itemSlot, int item)
{
(isTrinket ? trinketLoadout : weaponLoadout)[itemSlot] = item;
loadout_get(kind)[itemSlot] = item;
if (item >= 0)
(isTrinket ? trinketSlotLevels : weaponSlotLevels)[itemSlot] =
(isTrinket ? trinketItemLevels : weaponItemLevels)[item];
slot_levels_get(kind)[itemSlot] = item_levels_get(kind)[item];
}
// Every item and every copy at one level, as a match sets them each round.
@@ -755,63 +800,44 @@ void Fighter::levels_fill(int weaponLevel, int trinketLevel)
std::fill(std::begin(trinketSlotLevels), std::end(trinketSlotLevels), trinketLevel);
}
// What a trinket does, from its equipped copies, or the neutral value without any. Copies add what each does beyond
// neutral (Boots 1.3x and 1.3x make 1.6x, Pebble Helmets' damages add), except that ones taking something away (below
// a neutral of 1, like Shrinker or Feather) multiply, so they never pass zero; the trinket's cap holds the total.
float Fighter::trinket_get(TrinketId trinket, float neutral)
// A trinket stat from the equipped copies (across trinkets, like Shrinker and Embiggener's sizes), or its neutral
// value without any. A lone copy counts exactly as it is. Otherwise, by the stat's stacking, the best copy counts, or
// what each adds beyond neutral adds up (Boots 1.3x and 1.3x make 1.6x, Pebble Helmets' damages add) while what each
// takes away below it multiplies in (so Shrinkers never pass zero). The stat's cap holds the total.
float Fighter::stat_get(TrinketStat stat)
{
const TrinketDef& def = TRINKET_DEFS[trinket];
float total = neutral;
bool isFirst = true;
const TrinketStatDef& def = TRINKET_STATS[stat];
float best = 0;
float gained = 0;
float kept = 1;
float only = 0;
int count = 0;
for (int i = 0; i < trinketSlots; i++)
{
if (trinketLoadout[i] != trinket)
int trinket = trinketLoadout[i];
float value = trinket >= 0 ? TRINKET_DEFS[trinket].levels[trinketSlotLevels[i]].*def.field : 0;
if (value == 0)
continue;
// The first copy counts exactly as it is (adding and taking back the neutral value would round it).
float value = def.values[trinketSlotLevels[i]];
total = isFirst ? value : neutral != 0 && value < neutral ? total * value / neutral : total + value - neutral;
isFirst = false;
only = value;
count++;
best = std::max(best, value);
if (value < def.neutral)
kept *= value / def.neutral;
else
gained += value - def.neutral;
}
float total = count == 0 ? def.neutral
: count == 1 ? only
: def.stack == STACK_BEST ? best
: (def.neutral + gained) * kept;
return def.cap > 0 ? std::min(total, def.cap) : total;
}
// The best equipped copy's value, or the neutral value without any.
float Fighter::trinket_best_get(TrinketId trinket, float neutral)
{
int level = trinket_level_get(trinket);
return level < 0 ? neutral : TRINKET_DEFS[trinket].values[level];
}
// A per-level number for a trinket (like Iron Weights' slam damage): the copies' added up, or 0 without any.
float Fighter::trinket_sum_get(int trinket, const float* table)
{
float total = 0;
for (int i = 0; i < trinketSlots; i++)
total += trinketLoadout[i] == trinket ? table[trinketSlotLevels[i]] : 0;
return total;
}
// The best equipped copy's level, or -1 without any.
int Fighter::trinket_level_get(int trinket)
{
int level = -1;
for (int i = 0; i < trinketSlots; i++)
if (trinketLoadout[i] == trinket)
level = std::max(level, trinketSlotLevels[i]);
return level;
}
int Fighter::trinket_count(int trinket)
{
return std::count(trinketLoadout, trinketLoadout + trinketSlots, trinket);
}
// What Shock Absorber leaves of knockback taken: part of the way to what it leaves of weapon movement.
float Fighter::knock_absorb_get()
{
return 1 - (1 - trinket_get(TRINKET_ABSORBER, 1)) * ABSORB_KNOCK_SHARE;
}
bool Fighter::is_trinket_equipped(int trinket)
{
for (int item : trinketLoadout)