Game/Characters.cs
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>
/// The movement-tuning parameters that a character can vary. Only the curated "feel" values live
/// here (top speed, ground accel/decel, jump/gravity, wall-jump); the many other player constants
/// (hit-stop frames, particle counts, bounce/dash/wall-kick FX) stay fixed in <see cref="Player"/>
/// because they're not character identity. Every field defaults (via <see cref="Original"/>) to the
/// current player's values, so a character that doesn't override anything behaves identically to
/// the original — the abstraction is scaffolded now, ready for genuinely different characters later.
/// </summary>
public sealed class CharacterMovement
{
/// <summary>Top horizontal walk speed (px/s). VelX is clamped to this.</summary>
public float MaxXSpeed { get; init; } = 86.0f;
/// <summary>Absolute cap on UPWARD speed (px/s), applied after gravity each step. Deliberately
/// very high by default so it never affects normal play; a character can lower it to stop jumps /
/// wall-jumps / launches from stacking into ever more rise speed.</summary>
public float MaxRiseSpeed { get; init; } = 1500.0f;
/// <summary>Absolute cap on DOWNWARD (fall) speed (px/s), applied after gravity each step. Very
/// high by default so a normal fall never reaches it; it matters for a character that can fall
/// through the floor and wrap (keeping the fall sane), or one given a deliberately floaty descent.
/// Kept separate from <see cref="MaxRiseSpeed"/> so rise and fall can be tuned independently.</summary>
public float MaxFallSpeed { get; init; } = 1500.0f;
/// <summary>Horizontal acceleration while moving (px/s²).</summary>
public float HorizontalAcceleration { get; init; } = 350.0f;
/// <summary>Horizontal deceleration when not moving / turning (px/s²).</summary>
public float HorizontalDeceleration { get; init; } = 330.0f;
/// <summary>Multiplier on <see cref="HorizontalDeceleration"/> applied ONLY while grounded — a
/// traction knob. 1 = original grip; below 1 = slippery / ice feet (you keep sliding when you let
/// go); above 1 = a grippier, snappier stop. No effect in the air (air braking is unchanged).</summary>
public float GroundFrictionFactor { get; init; } = 1.0f;
/// <summary>Multiplier on <see cref="HorizontalDeceleration"/> applied ONLY while airborne (the air
/// equivalent of <see cref="GroundFrictionFactor"/>). 1 = the original air braking; 0 = NO horizontal
/// air braking at all, so once airborne you keep your horizontal velocity until you land (pressing the
/// opposite direction can't bleed it off either). Pairs with a low <see cref="AirAccelerationFactor"/>
/// to fully commit a character to the trajectory it left the ground with. Stacks with the post-wall-jump
/// momentum window (does not override it).</summary>
public float AirFrictionFactor { get; init; } = 1.0f;
/// <summary>How strongly <c>BlockWind</c> gusts push this character. 1 = original; >1 = a light
/// "sail/kite" that catches gusts harder and rides them faster/further (the wind speed cap scales
/// with it too); 0 = immune to wind; negative values are pushed UPWIND (a contrarian). Only affects the wind
/// channel — gravity / dashes / knockback are unchanged.</summary>
public float WindCatchFactor { get; init; } = 1.0f;
/// <summary>How strongly a singing <c>BlockSiren</c>'s pull drifts this character. 1 = original;
/// >1 = drawn in harder/faster and to a higher combined drift cap (a moth to the flame); 0 =
/// deaf to the song; values below 1 resist it. Only affects the siren drift channel — gravity / dashes /
/// knockback are unchanged. Mirrors <see cref="WindCatchFactor"/> for the siren pull.</summary>
public float SirenCatchFactor { get; init; } = 1.0f;
/// <summary>Upward velocity applied by a jump (px/s).</summary>
public float JumpPower { get; init; } = 112.0f;
/// <summary>Minimum random multiplier applied to floor-jump launch strength. Equal min/max
/// values disable the random draw; 1 preserves the base movement values.</summary>
public float JumpStrengthMinFactor { get; init; } = 1.0f;
/// <summary>Maximum random multiplier applied to floor-jump launch strength. Values at or
/// below <see cref="JumpStrengthMinFactor"/> use the minimum without consuming simulation RNG.</summary>
public float JumpStrengthMaxFactor { get; init; } = 1.0f;
/// <summary>Base downward gravity (px/s²).</summary>
public float Gravity { get; init; } = 300.0f;
/// <summary>Gravity multiplier while holding Up in the air (a lighter, floatier rise/fall).</summary>
public float ActiveUpGravityFactor { get; init; } = 0.5f;
/// <summary>When true, <see cref="ActiveUpGravityFactor"/> only softens gravity while FALLING (a gentle
/// hold-Up glide DOWN), not while rising. Off by default = the original both-ways behaviour. Set it so a
/// character can float DOWN slowly on Up without that same low gravity floating its jumps / wall-jumps
/// way too high on the way UP (the ascent then decelerates at normal rise gravity).</summary>
public bool ActiveUpGravityFallOnly { get; init; } = false;
/// <summary>Gravity multiplier while holding Down in the air (a faster drop).</summary>
public float ActiveDownGravityFactor { get; init; } = 2.0f;
/// <summary>When true, holding Down in the air negates a held Up: with both held, the Up gravity
/// softening is skipped and the Down factor applies (a committed dive can't be floated mid-hold).
/// Off by default = the original Up-wins priority.</summary>
public bool ActiveDownOverridesUp { get; init; } = false;
/// <summary>Gravity multiplier while holding Down in the air AND still RISING (VelY away from the floor),
/// separate from <see cref="ActiveDownGravityFactor"/> which then applies only while falling. Lets a
/// character be pulled down hard on the way DOWN without that same strong factor braking its ASCENT.
/// Negative (the default) = use <see cref="ActiveDownGravityFactor"/> for both directions (the original
/// both-ways behaviour, so it's a no-op for every character that doesn't set it).</summary>
public float ActiveDownGravityRiseFactor { get; init; } = -1.0f;
/// <summary>Gravity multiplier while hugging a wall (a slow slide).</summary>
public float WallGravityFactor { get; init; } = 0.15f;
/// <summary>Gravity multiplier while hugging a wall AND still RISING (VelY > 0) — how much upward
/// momentum is preserved as you slide UP a wall. 1 = original (normal rise gravity on the wall);
/// below 1 = a "slidy" wall that carries your upward momentum far up the surface. Stacks on top of
/// <see cref="RiseGravityFactor"/>. Distinct from <see cref="WallGravityFactor"/>, which only shapes
/// the DOWNWARD slide (non-positive VelY).</summary>
public float WallRiseGravityFactor { get; init; } = 1.0f;
/// <summary>Separate downward speed cap (px/s) applied ONLY while sliding down a wall (hugging +
/// falling), independent of <see cref="WallGravityFactor"/>. Lets a character accelerate normally in
/// free-fall but slide down walls at a controlled terminal speed. Negative (the default) = off (the
/// normal <see cref="MaxFallSpeed"/> applies on walls too).</summary>
public float WallSlideMaxFallSpeed { get; init; } = -1.0f;
/// <summary>Horizontal push away from the wall on a wall jump (px/s).</summary>
public float WallJumpHorizontalPower { get; init; } = 70.0f;
/// <summary>Upward velocity of a wall jump (px/s).</summary>
public float WallJumpVerticalPower { get; init; } = 112.0f;
/// <summary>Minimum random multiplier applied to wall-jump launch strength. Equal min/max
/// values disable the random draw; 1 preserves the base wall-jump powers.</summary>
public float WallJumpStrengthMinFactor { get; init; } = 1.0f;
/// <summary>Maximum random multiplier applied to wall-jump launch strength. Values at or
/// below <see cref="WallJumpStrengthMinFactor"/> use the minimum without consuming simulation RNG.</summary>
public float WallJumpStrengthMaxFactor { get; init; } = 1.0f;
/// <summary>Whether a wall jump adds its vertical power to existing away-from-floor velocity.
/// When false, each wall jump starts with exactly <see cref="WallJumpVerticalPower"/>.</summary>
public bool WallJumpStacksVerticalVelocity { get; init; } = true;
/// <summary>How long (s) after a wall jump the reduced air control/acceleration lasts, to prevent clinging to the wall you just jumped from.</summary>
public float WallJumpTime { get; init; } = 1.0f;
/// <summary>How fast the overflow "extra velocity" horizontal channel (dash / wall-dive / swap
/// launch) decays back to zero (px/s²). Lower = launches/dashes carry much further before petering
/// out. Default matches the original feel; a slippery character can drop it for long glides.</summary>
public float ExtraVelHorizontalDeceleration { get; init; } = 300.0f;
/// <summary>How fast the overflow "extra velocity" vertical channel (vertical dashes / platform
/// flings) decays back to zero (px/s²). Lower values carry vertical launches further.</summary>
public float ExtraVelVerticalDeceleration { get; init; } = 300.0f;
/// <summary>Multiplier on horizontal acceleration WHILE AIRBORNE (1 = same as ground). Below 1 =
/// floatier, less responsive air steering; above 1 = sharper air control. No effect on the ground.</summary>
public float AirAccelerationFactor { get; init; } = 1.0f;
/// <summary>Coyote time: how many fixed steps after leaving the ground a jump still works. Higher =
/// more forgiving. Measured in ticks (frame-rate independent / replay-safe).</summary>
public int GroundedLeniencyFrames { get; init; } = 6;
/// <summary>How many fixed steps after leaving a wall a wall jump still works. Higher = more
/// forgiving. Measured in ticks.</summary>
public int WallJumpLeniencyFrames { get; init; } = 7;
/// <summary>Extra deceleration (px/s²) applied ONLY while reversing direction (holding a direction
/// opposite to current horizontal velocity) — a "skid" brake that snaps turns tighter. 0 = the
/// original feel (turning is driven purely by acceleration in the new direction).</summary>
public float TurnaroundDeceleration { get; init; } = 0.0f;
/// <summary>Fraction of horizontal speed kept on landing (1 = keep all = original; lower = a grippy
/// "thud" that bleeds momentum when you touch down). Applies to VelX and the overflow ExtraVelX on a
/// downward landing.</summary>
public float LandingMomentumRetention { get; init; } = 1.0f;
/// <summary>Multiplier on BASE gravity while RISING (VelY > 0). Values below 1 give a floatier ascent. Stacks with
/// the input-gated ActiveUp/Down/Wall factors. 1 = original.</summary>
public float RiseGravityFactor { get; init; } = 1.0f;
/// <summary>Multiplier on BASE gravity while FALLING (non-positive VelY). >1 = a snappier drop (pair with a low
/// RiseGravityFactor for a classic float-up/fall-fast arc). 1 = original.</summary>
public float FallGravityFactor { get; init; } = 1.0f;
/// <summary>Top horizontal walk speed WHILE AIRBORNE. Negative (the default) = use
/// <see cref="MaxXSpeed"/> (no distinction); set it to run slow on the ground but fly fast in the
/// air, or vice-versa.</summary>
public float MaxAirXSpeed { get; init; } = -1.0f;
/// <summary>Raised air X-speed cap (px/s) while holding Down in the air — a "dive gear" on top of
/// <see cref="MaxAirXSpeed"/> for a wavy flap-up-then-swoop-forward rhythm. Reads the
/// orientation-swapped Down, so it stays the dive key under reverse gravity. Releasing Down drops
/// the cap back and the excess bleeds off at <see cref="OverspeedDecay"/>. Negative (the default)
/// = off (holding Down changes nothing horizontally).</summary>
public float DiveMaxAirXSpeed { get; init; } = -1.0f;
/// <summary>Extra horizontal acceleration (px/s²) applied along the CURRENT heading while the dive
/// gear is engaged and already moving — the swoop itself builds speed toward the raised cap, no
/// left/right hold needed (steering against the motion suspends it so a reversal stays possible).
/// 0 = the raised cap must be earned with ordinary air steering instead. Only used when
/// <see cref="DiveMaxAirXSpeed"/> is set.</summary>
public float DiveHorizontalAcceleration { get; init; } = 0.0f;
/// <summary>How fast (px/s²) horizontal speed that EXCEEDS the current cap bleeds back down to it.
/// Only relevant when <see cref="MaxAirXSpeed"/> differs from <see cref="MaxXSpeed"/>: when the
/// air/ground state flips and the cap drops below current speed, the excess eases off at this rate
/// instead of snapping to the cap in one tick (which felt like hitting glue on landing, or a run
/// being truncated the first airborne tick). Defaults to the ground deceleration so the settle
/// feels like normal braking; raise it for a near-instant clamp, lower it for a long coast-down.</summary>
public float OverspeedDecay { get; init; } = 330.0f;
/// <summary>Jump buffer: press jump up to this many ticks BEFORE landing and it fires on touchdown.
/// 0 = off. Only buffers a press that didn't already trigger a wall/air jump, so it composes cleanly
/// with those.</summary>
public int JumpBufferFrames { get; init; } = 3;
/// <summary>Extra horizontal launch speed per unit of horizontal speed at takeoff. Rewards running
/// into a jump with a farther leap in the current travel direction. 0 = off.</summary>
public float JumpSpeedBonus { get; init; } = 0.0f;
/// <summary>Horizontal "hop-forward" kick (px/s) added on a GROUND jump, always in the direction the
/// player is FACING (the way they last turned) — so it fires even from a standstill and isn't
/// affected by external pushes (wind / platforms). Additive momentum: fills VelX up to the walk cap
/// and spills the remainder into the carrying ExtraVelX channel, so it never brakes a faster run. 0 = off.</summary>
public float JumpHorizExitBoost { get; init; } = 0.0f;
/// <summary>Hold INTO a wall + Up to climb it at this speed (px/s). 0 = off. A pressed jump still
/// wall-jumps (fires over this); a held Up climbs.</summary>
public float WallClimbSpeed { get; init; } = 0.0f;
/// <summary>Hold Up in the air to hover (freeze the fall) for this many ticks, once per airtime
/// (normally refilled on landing). 0 = off.</summary>
public int HoverFrames { get; init; } = 0;
/// <summary>Gravity multiplier near the PEAK of a jump (while airborne with |VelY| below
/// <see cref="ApexHangVelThreshold"/>), for extra "hang time" at the top of the arc. 1 = off
/// (also a no-op when the threshold is 0). Stacks on top of the other gravity factors.</summary>
public float ApexHangGravityFactor { get; init; } = 1.0f;
/// <summary>Vertical speed (px/s) below which the apex-hang reduced gravity applies. 0 = off.</summary>
public float ApexHangVelThreshold { get; init; } = 0.0f;
/// <summary>The original player's movement — the baseline every character inherits. Expression-bodied
/// (a fresh instance each access) rather than a static field so edits to the baseline tuning above
/// take effect on hotload — a static field would be restored to its pre-edit value. Cheap: the type
/// is a small immutable record only touched at character spawn / registry (re)build.</summary>
public static CharacterMovement Original => new();
}
/// <summary>
/// The set of on/off (and simple tunable) abilities a character has. These are the "toggle" tier of
/// the ability system: pure data the shared <see cref="Player"/> physics reads to enable/skip a
/// behaviour. Genuinely-new behaviours that carry their own code + state (edge-wrap, teleport, …)
/// are modelled as <see cref="PlayerAbility"/> modules instead; the two tiers compose freely so a
/// character can mix any subset. Most fields default to the original player's behaviour; the four
/// gated moves (<see cref="CanBounce"/>, <see cref="CanDash"/>, <see cref="CanWallDive"/>,
/// <see cref="CanNeutralWallJump"/>) default OFF so Original opts in and new characters start minimal.
/// </summary>
public sealed record class CharacterAbilities
{
/// <summary>Holding Down on a hard landing converts the impact into an upward bounce.
/// Off by default — Original opts in.</summary>
public bool CanBounce { get; init; } = false;
/// <summary>Minimum downward impact speed for a manual (hold-Down) bounce, so a gentle touchdown
/// doesn't jitter. Only used when <see cref="CanBounce"/> is true.</summary>
public float BounceMinSpeed { get; init; } = 90.0f;
/// <summary>Fraction of downward impact speed returned upward on a manual (hold-Down) bounce.
/// Only used when <see cref="CanBounce"/> is true.</summary>
public float BounceRestitution { get; init; } = 0.45f;
/// <summary>Double-tap a horizontal direction to dash. Off by default — Original opts in.</summary>
public bool CanDash { get; init; } = false;
/// <summary>Allows horizontal double-tap dashes and horizontal components in diagonal dashes.
/// On by default to preserve the baseline dash. Only meaningful when <see cref="CanDash"/> is true.</summary>
public bool CanDashHorizontal { get; init; } = true;
/// <summary>Allows Up double-tap dashes and upward components in diagonal dashes.
/// Off by default. Only meaningful when <see cref="CanDash"/> is true.</summary>
public bool CanDashUp { get; init; } = false;
/// <summary>Allows Down double-tap dashes and downward components in diagonal dashes.
/// Off by default. Only meaningful when <see cref="CanDash"/> is true.</summary>
public bool CanDashDown { get; init; } = false;
/// <summary>When true, a charged dash may fire while standing on the gravity-relative floor.
/// Off by default (airborne only). Only meaningful when <see cref="CanDash"/> is true.</summary>
public bool CanDashOnGround { get; init; } = false;
/// <summary>When true, establishing fresh gravity-relative floor contact refreshes the dash charge.
/// On by default. Only meaningful when <see cref="CanDash"/> is true.</summary>
public bool DashRefreshOnFloor { get; init; } = true;
/// <summary>When true, establishing a fresh wall hug refreshes the dash charge.
/// Off by default. Only meaningful when <see cref="CanDash"/> is true.</summary>
public bool DashRefreshOnWallHug { get; init; } = false;
/// <summary>Distance (px) of self-propelled movement along the gravity-relative floor (the ceiling
/// under reverse gravity) that refreshes a spent dash charge. Being carried by a platform never
/// counts. Progress from the three recharge modes pools into one charge, so mixed movement adds
/// up. 0 = off. Only meaningful when <see cref="CanDash"/> is true.</summary>
public float DashRechargeFloorDistance { get; init; } = 0f;
/// <summary>Distance (px) moved along a hugged wall — climbing OR sliding — that refreshes a spent
/// dash charge. 0 = off. Only meaningful when <see cref="CanDash"/> is true.</summary>
public float DashRechargeWallDistance { get; init; } = 0f;
/// <summary>Distance (px) moved sideways while ceiling clinging that refreshes a spent dash charge.
/// 0 = off. Only meaningful when <see cref="CanDash"/> and <see cref="CanCeilingCling"/> are true.</summary>
public float DashRechargeCeilingDistance { get; init; } = 0f;
/// <summary>Pitch multiplier on the walk / wall-climb / ceiling-climb sfx while the dash is SPENT with
/// no recharge progress yet; it slides toward <see cref="DashRechargeSfxPitchFull"/> as the charge
/// refills and snaps back to 1 (authored pitch) once charged. Only used when a DashRecharge*Distance
/// is set.</summary>
public float DashRechargeSfxPitchEmpty { get; init; } = 0.75f;
/// <summary>Pitch multiplier on the surface-movement sfx just before the dash refills (see
/// <see cref="DashRechargeSfxPitchEmpty"/>).</summary>
public float DashRechargeSfxPitchFull { get; init; } = 1.3f;
/// <summary>Double-tap dash: max time the first tap may be held to still count as a tap (s).
/// Only used when <see cref="CanDash"/> is true.</summary>
public float DashTapReleaseTime { get; init; } = 0.1f;
/// <summary>Double-tap dash: max gap from the first tap's release to the second press (s).
/// Only used when <see cref="CanDash"/> is true.</summary>
public float DashSecondTapTime { get; init; } = 0.07f;
/// <summary>Whether directional double-tap gestures ignore a tap consumed by a WALL jump, so a
/// ground jump chained into an instant wall jump doesn't read as a double tap. Ground/ceiling
/// jump taps still count. Off by default.</summary>
public bool IgnoreWallJumpDoubleTaps { get; init; } = false;
/// <summary>Dash horizontal impulse.
/// Only used when <see cref="CanDash"/> is true.</summary>
public float DashForce { get; init; } = 100.0f;
/// <summary>Force for a dash straight ALONG the effective gravity (down normally, up when reversed);
/// diagonals toward gravity use the midpoint between this and <see cref="DashForce"/>.
/// Null = same force as every other dash.</summary>
public float? DashForceAlongGravity { get; init; } = null;
/// <summary>How long after a dash gravity is suppressed for a flat hang (s).
/// Only used when <see cref="CanDash"/> is true.</summary>
public float DashGravitySuppressTime { get; init; } = 0.16f;
/// <summary>Gravity-suppression window for a dash straight ALONG the effective gravity (down normally,
/// up when reversed); diagonals toward gravity use the midpoint between this and
/// <see cref="DashGravitySuppressTime"/>. Null = same window as every other dash. 0 lets a
/// gravity-ward dash keep falling instead of coasting flat.</summary>
public float? DashGravitySuppressTimeAlongGravity { get; init; } = null;
/// <summary>Extra dash impulse per unit of entry speed along the dash direction, so dashing while
/// already moving that way carries further. Applies to horizontal, vertical, and diagonal dashes.
/// 0 = off (a flat <see cref="DashForce"/>). Only used when <see cref="CanDash"/> is true.</summary>
public float DashMomentumFactor { get; init; } = 0.0f;
/// <summary>Consolidates a horizontal dash with existing VelX and ExtraVelX. Aligned velocity is
/// combined with the dash; opposing velocity is cancelled first so it cannot resist the dash or
/// resurface after the dash overflow decays. Off by default to preserve the standard dash.
/// Only used when <see cref="CanDash"/> is true.</summary>
public bool ConsolidateDashHorizontalVelocity { get; init; } = false;
/// <summary>Preserves existing vertical velocity when dashing, making the vertical dash component
/// additive instead of resetting the rise or fall first. Horizontal dashes therefore leave vertical
/// motion untouched. Off by default to preserve the standard flat dash.</summary>
public bool PreserveDashVerticalVelocity { get; init; } = false;
/// <summary>Rebound off a wall by pressing the opposite direction just after touching it.
/// (Off by default — matches the original's disabled wall-kick.)</summary>
public bool CanWallKick { get; init; } = false;
/// <summary>Leaning away from the wall on a wall jump trades height for a flatter dive.
/// Off by default — Original opts in.</summary>
public bool CanWallDive { get; init; } = false;
/// <summary>Whether a wall jump can be launched straight UP with no horizontal held (the "neutral"
/// wall jump). When false the character must lean into or away from the wall to wall-jump — a bare
/// Up press near the wall does nothing — so they can't ride a wall straight upward.
/// Off by default — Original (and Wrapper) opt in.</summary>
public bool CanNeutralWallJump { get; init; } = false;
/// <summary>Whether the four ARENA walls are solid to this character. When false the character
/// passes straight through non-spiked arena walls (a SPIKED wall is still lethal); BLOCK
/// collisions are unaffected. Usually paired with <see cref="WrapsArenaEdges"/>.</summary>
public bool CollidesWithArenaWalls { get; init; } = true;
/// <summary>Whether the character wraps to the opposite arena edge after passing far enough
/// through a (non-spiked) wall. Requires <see cref="CollidesWithArenaWalls"/> to be false to have
/// any effect. Implemented by the <see cref="WrapArenaEdgesAbility"/> module.</summary>
public bool WrapsArenaEdges { get; init; } = false;
/// <summary>Whether the character spawns a persistent portal at its spawn point and can swap places
/// with it by pressing all four inputs on the same frame. Implemented by the
/// <see cref="SwapPortalAbility"/> module.</summary>
public bool HasSwapPortal { get; init; } = false;
/// <summary>Whether touching the floor refills the character's hover budget.</summary>
public bool HoverRefreshOnFloor { get; init; } = true;
/// <summary>Whether the character carries the Gunner kit: a small ammo reserve reloaded by holding
/// Down while grounded, and a hold-Down-to-fire aim (the other direction keys point the shot) that
/// fires a fast <see cref="Bullet"/> (or coughs sparks when empty). Implemented by the
/// <see cref="GunnerAbility"/> module.</summary>
public bool HasGunner { get; init; } = false;
/// <summary>Whether falling past an exposed block or obstacle corner can snap into a ledge hang,
/// followed by a fresh Up press to jump from the hang. Off by default.</summary>
public bool CanLedgeGrab { get; init; } = false;
/// <summary>Whether the character periodically creates shared-input copies and can continue from a
/// surviving body after death. Implemented by <see cref="SwarmAbility"/>.</summary>
public bool HasSwarm { get; init; } = false;
/// <summary>Whether the character records its personal movement history and can return to its state
/// from two seconds ago. Implemented by <see cref="RewindAbility"/>.</summary>
public bool HasRewind { get; init; } = false;
/// <summary>Whether double-tapping any cardinal or diagonal direction fires a grappling line.
/// Implemented by <see cref="GrapplerAbility"/>.</summary>
public bool HasGrappler { get; init; } = false;
/// <summary>Whether the character can prepare and fire a directional blink.
/// Implemented by <see cref="BlinkerAbility"/>.</summary>
public bool HasBlinker { get; init; } = false;
/// <summary>Whether sunlight powers this character's movement.
/// Implemented by <see cref="SolarAbility"/>.</summary>
public bool HasSolar { get; init; } = false;
/// <summary>Whether this run identity cycles through temporary character forms.
/// Implemented by the persistent <see cref="MimicAbility"/>.</summary>
public bool HasMimic { get; init; } = false;
/// <summary>Whether holding Down charges a temporary immovable obstacle form.
/// Implemented by <see cref="HardenAbility"/>.</summary>
public bool HasHarden { get; init; } = false;
/// <summary>Whether this body has Twin1's committed, hazard-phasing directional dash.
/// Implemented by <see cref="TwinDashAbility"/>.</summary>
public bool HasTwinDash { get; init; } = false;
/// <summary>How many mid-air jumps the character has (0 = none, the original; 1 = a double jump,
/// etc.). Refilled on landing. A bare Up press in the air with jumps remaining fires one.</summary>
public int MaxAirJumps { get; init; } = 0;
/// <summary>Upward velocity of a mid-air jump (px/s). Negative (the default) = use the ground
/// <see cref="CharacterMovement.JumpPower"/>; set it to make air jumps weaker or stronger than the
/// ground jump.</summary>
public float AirJumpPower { get; init; } = -1.0f;
/// <summary>Whether an air jump adds its power to existing away-from-floor velocity instead of replacing it.
/// Any stacked rise above <see cref="CharacterMovement.MaxRiseSpeed"/> spills into ExtraVelY.</summary>
public bool AirJumpStacksVerticalVelocity { get; init; } = false;
/// <summary>Whether an air jump can fire while holding toward the floor (normally a held Down swallows
/// the press). For characters whose Down-hold is a travel mode (the owl's swoop) rather than a
/// fast-fall intent — a flap mid-dive should still work.</summary>
public bool AirJumpWhileHoldingDown { get; init; } = false;
/// <summary>When true, releasing the jump button while still rising cuts the remaining upward speed
/// (<see cref="VariableJumpCutFactor"/>) for a tap-for-short / hold-for-tall jump. Off = fixed-height
/// jump (the original).</summary>
public bool VariableJumpHeight { get; init; } = false;
/// <summary>Fraction of upward speed KEPT when a rising jump is cut short (see
/// <see cref="VariableJumpHeight"/>). Only used when that's enabled; 0.4 = a brisk short-hop.</summary>
public float VariableJumpCutFactor { get; init; } = 0.4f;
/// <summary>How many fixed steps a fresh wall hug sticks with ZERO slide before the normal
/// <see cref="CharacterMovement.WallGravityFactor"/> slide begins (0 = no cling, the original). A
/// brief grip that makes wall play more deliberate. Measured in ticks.</summary>
public int WallClingFrames { get; init; } = 0;
/// <summary>Hang from an OVERHEAD surface when you hit it with Up held (false = off, the original).
/// Like <see cref="WallClingFrames"/> but for ceilings, and with NO time limit: the fall freezes for as
/// long as Up is held and you stay in contact, then normal gravity resumes on release. Horizontal
/// movement is unaffected, so you can shimmy along the ceiling while hanging, and a moving ceiling block
/// carries you along with it.</summary>
public bool CanCeilingCling { get; init; } = false;
/// <summary>Downward closing speed relative to the floor that kills this character on impact (px/s).
/// 0 = disabled. This is measured at contact rather than inferred from fall height, so wind or another
/// force that brakes the fall before landing can save the player, while a downward shove still counts.</summary>
public float FallDamageImpactSpeed { get; init; } = 0.0f;
/// <summary>Mario-style LONG JUMP: hold Down while RUNNING and jump for a low, far leap (reduced
/// height + a big forward horizontal launch). Off by default. Shares the Down+jump input with
/// <see cref="CanBackFlip"/> — running triggers the long jump, a standstill triggers the back-flip.</summary>
public bool CanLongJump { get; init; } = false;
/// <summary>Fraction of <see cref="CharacterMovement.JumpPower"/> used for a long jump's height
/// (lower = flatter). Only used when <see cref="CanLongJump"/> is true.</summary>
public float LongJumpHeightFactor { get; init; } = 0.6f;
/// <summary>Forward horizontal launch speed (px/s) added on a long jump. Fills VelX to the walk cap
/// and spills the rest into the carrying ExtraVelX channel so it flies far. Used only with
/// <see cref="CanLongJump"/>.</summary>
public float LongJumpHorizontalBoost { get; init; } = 120.0f;
/// <summary>Mario-style BACK-FLIP: hold Down from a STANDSTILL and jump for a high leap that launches
/// BACKWARD (opposite the facing direction). Off by default. Shares the Down+jump input with
/// <see cref="CanLongJump"/>.</summary>
public bool CanBackFlip { get; init; } = false;
/// <summary>Fraction of <see cref="CharacterMovement.JumpPower"/> used for a back-flip's height
/// (higher = taller). Only used when <see cref="CanBackFlip"/> is true.</summary>
public float BackFlipHeightFactor { get; init; } = 1.4f;
/// <summary>Initial FORWARD horizontal pop (px/s) on a back-flip, in the facing direction (a small
/// hop off the take-off before the backward arc pulls you back). Used only with
/// <see cref="CanBackFlip"/>.</summary>
public float BackFlipHorizontalBoost { get; init; } = 60.0f;
/// <summary>PEAK backward acceleration (px/s²) of the back-flip push, applied opposite the direction
/// faced at take-off — the momentum arc that carries you back over where you jumped from. Its strength
/// eases 0%→100%→0% over <see cref="BackFlipBackwardDuration"/> (peaking mid-way) so the arc swells in
/// then tapers off. Runs for the FULL duration regardless of whether Up is held; ends early only if you
/// land or hug a wall (then the drift coasts to a stop). Used only with <see cref="CanBackFlip"/>.</summary>
public float BackFlipBackwardForce { get; init; } = 500.0f;
/// <summary>How long (seconds) the back-flip backward push lasts — the period over which its strength
/// eases 0%→100%→0% (see <see cref="BackFlipBackwardForce"/>). Keep it short. Once elapsed the force
/// ends and the drift coasts to a stop. Used only with <see cref="CanBackFlip"/>.</summary>
public float BackFlipBackwardDuration { get; init; } = 0.45f;
/// <summary>Tap Down in the air to slam straight down (ground pound) at
/// <see cref="GroundPoundSpeed"/>. Off by default.</summary>
public bool CanGroundPound { get; init; } = false;
/// <summary>Downward speed of a ground pound (see <see cref="CanGroundPound"/>).</summary>
public float GroundPoundSpeed { get; init; } = 400.0f;
/// <summary>Always bounce on a hard landing WITHOUT holding Down (a pogo). Off by default.</summary>
public bool AutoBounceGround { get; init; } = false;
/// <summary>Restitution for the auto ground bounce (fraction of downward impact speed returned up).</summary>
public float AutoBounceGroundRestitution { get; init; } = 0.65f;
/// <summary>Minimum downward impact speed to trigger the auto ground bounce (below this you just land
/// normally). Keep it above the resting re-land speed (~5) to avoid idle jitter.</summary>
public float AutoBounceGroundMinSpeed { get; init; } = 90.0f;
/// <summary>Floor for the auto ground bounce's upward speed: if the computed bounce is weaker than
/// this it's boosted up to it (0 = no floor). Still subject to <see cref="CharacterMovement.MaxRiseSpeed"/>.</summary>
public float AutoBounceGroundMinStrength { get; init; } = 0.0f;
/// <summary>Multiplier for screenshake and hit-stop caused by an automatic ground bounce.
/// 1 preserves the standard impact feedback; 0 disables both.</summary>
public float AutoBounceGroundImpactFeedbackFactor { get; init; } = 1.0f;
/// <summary>Fixed ticks after an automatic ground bounce that use a compressed charged-jump pose.
/// 0 disables the pose override.</summary>
public int AutoBounceGroundCompressionFrames { get; init; } = 0;
/// <summary>Bounce off a wall on a horizontal impact (reverse + scale VelX) instead of stopping
/// dead. Off by default.</summary>
public bool AutoBounceWall { get; init; } = false;
/// <summary>Restitution for the auto wall bounce (fraction of horizontal speed reversed).</summary>
public float AutoBounceWallRestitution { get; init; } = 0.6f;
/// <summary>Minimum horizontal impact speed to trigger the auto wall bounce (below this you just
/// stop at the wall). Avoids jitter when barely touching a wall.</summary>
public float AutoBounceWallMinSpeed { get; init; } = 20.0f;
/// <summary>Floor for the auto wall bounce's rebound speed: if the reversed speed is weaker than
/// this it's boosted up to it (0 = no floor). Overflow past the walk cap rides ExtraVelX (like a
/// dash), so a strong floor actually launches instead of being clamped.</summary>
public float AutoBounceWallMinStrength { get; init; } = 0.0f;
/// <summary>Bounce off an OVERHEAD surface on an upward impact (reverse the rise into a fall) instead
/// of stopping dead against the ceiling. The vertical twin of <see cref="AutoBounceWall"/>. Off by
/// default. Gravity-sign aware (reverses the "away from floor" impact), so it also bounces off the
/// floor-that-is-a-ceiling in a reverse-gravity field.</summary>
public bool AutoBounceCeiling { get; init; } = false;
/// <summary>Restitution for the auto ceiling bounce (fraction of upward impact speed returned as a
/// downward fall). Only used when <see cref="AutoBounceCeiling"/> is true.</summary>
public float AutoBounceCeilingRestitution { get; init; } = 0.6f;
/// <summary>Minimum upward impact speed to trigger the auto ceiling bounce (below this you just
/// stop against the ceiling). Only used when <see cref="AutoBounceCeiling"/> is true.</summary>
public float AutoBounceCeilingMinSpeed { get; init; } = 30.0f;
/// <summary>CHARGE JUMP: hold the toward-floor key (Down normally, Up in a reverse field) while
/// grounded to wind up a big directional jump. While charging, the
/// away-from-floor jump key does nothing and the Left/Right keys steer the launch angle (see
/// <see cref="Player.HandleChargeJump"/>). Off by default.</summary>
public bool HasChargeJump { get; init; } = false;
/// <summary>Holding into a side wall winds up a charged jump away from it. Releasing the wall-hug
/// direction fires; Up/Down steer along the wall. Full charge normally remains held until release.
/// This does not require <see cref="WallClingFrames"/>.</summary>
public bool HasChargeWallJump { get; init; } = false;
/// <summary>Holding Up against an overhead surface attaches to it and winds up a charged jump away
/// from the ceiling. Left/Right aim exactly like a floor charge. Releasing Up fires; full charge remains
/// held until then unless auto-fire is enabled. Uses the floor charge time and speed tuning.</summary>
public bool HasChargeCeilingJump { get; init; } = false;
/// <summary>Automatically launch a floor, wall, ceiling, or sticky charged jump upon reaching maximum
/// charge instead of waiting for its hold input to be released. Off by default.</summary>
public bool ChargeJumpAutoFireAtMax { get; init; } = false;
/// <summary>Seconds for a wall charge to reach maximum power. A value at or below zero
/// uses <see cref="ChargeJumpMaxTime"/>.</summary>
public float ChargeWallJumpMaxTime { get; init; } = -1.0f;
/// <summary>Launch speed of an uncharged wall jump. A value at or below zero uses
/// <see cref="ChargeJumpMinSpeed"/>.</summary>
public float ChargeWallJumpMinSpeed { get; init; } = -1.0f;
/// <summary>Launch speed of a fully charged wall jump. A value at or below zero uses
/// <see cref="ChargeJumpMaxSpeed"/>.</summary>
public float ChargeWallJumpMaxSpeed { get; init; } = -1.0f;
/// <summary>Seconds after a charged wall jump during which gravity is not applied. Collision and
/// external forces remain active. Zero disables the hang.</summary>
public float ChargeWallJumpGravitySuppressTime { get; init; } = 0.0f;
/// <summary>Charging against a PHASE-1 sticky block's glue (see <see cref="Player.HandleStickyChargeJump"/>):
/// the fraction of the face's max charge time a wind-up must reach before releasing the hold tears the
/// player out and fires. Below it the goo wins — the player just shakes (harder the longer it was
/// wound) and stays stuck, the wind-up reset. Needs the matching charge ability for the stuck face
/// (floor / wall / ceiling).</summary>
public float StickyChargeReleaseFraction { get; init; } = 0.35f;
/// <summary>The <see cref="StickyChargeReleaseFraction"/> for a PHASE-2 sticky block — its stronger
/// goo takes a (near-)full wind to tear out of. At the 0.99 default only a release inside the last
/// sliver of the wind-up or after reaching full charge escapes.</summary>
public float StickyChargeReleaseFractionPhase2 { get; init; } = 0.99f;
/// <summary>Launch-speed factor applied to a charged jump fired out of PHASE-1 sticky goo — tearing
/// free costs some power. 1 = full strength.</summary>
public float StickyChargePowerFactor { get; init; } = 0.75f;
/// <summary>The <see cref="StickyChargePowerFactor"/> for a PHASE-2 sticky block — its stronger goo
/// saps more of the launch on the way out.</summary>
public float StickyChargePowerFactorPhase2 { get; init; } = 0.6f;
/// <summary>Seconds of holding the charge to reach MAXIMUM jump power. Power caps here until the charge
/// input is released unless <see cref="ChargeJumpAutoFireAtMax"/> is enabled. Only used when
/// <see cref="HasChargeJump"/> is true.</summary>
public float ChargeJumpMaxTime { get; init; } = 1.25f;
/// <summary>Launch speed (px/s) of a charge jump released at ZERO charge — a tap. Only used when
/// <see cref="HasChargeJump"/> is true.</summary>
public float ChargeJumpMinSpeed { get; init; } = 130.0f;
/// <summary>Launch speed (px/s) of a FULLY-charged jump. The launch magnitude lerps from
/// <see cref="ChargeJumpMinSpeed"/> to this over <see cref="ChargeJumpMaxTime"/>. Keep the character's
/// <see cref="CharacterMovement.MaxRiseSpeed"/> at or above this so a straight-up max jump isn't clamped.
/// Only used when <see cref="HasChargeJump"/> is true.</summary>
public float ChargeJumpMaxSpeed { get; init; } = 340.0f;
/// <summary>The launch angle (degrees measured FROM the surface, i.e. from horizontal) when a
/// horizontal key was held for the WHOLE charge — a shallow, far leap. Straight up (no net horizontal
/// hold) is always 90°; the angle lerps toward this as the net horizontal-hold fraction reaches 1.
/// ~25° ≈ a low, ranging jump. Only used when <see cref="HasChargeJump"/> is true.</summary>
public float ChargeJumpMinAngleDeg { get; init; } = 25.0f;
/// <summary>Minimum magnitude of the accumulated horizontal charge aim, normalised to 0..1, before
/// directional charge art replaces the neutral pose. Only affects visuals.</summary>
public float ChargeJumpDirectionalPoseThreshold { get; init; } = 0.25f;
/// <summary>An air (double) jump also flings this fast in the currently-held horizontal direction —
/// a directional leap rather than a pure vertical reset. 0 = off (straight-up air jump). Overflow
/// past the walk cap rides ExtraVelX so it carries.</summary>
public float AirJumpHorizontalBoost { get; init; } = 0.0f;
/// <summary>Pressing a NOT-yet-pressed BLOCK face flings the player straight away from it at this
/// flat speed — a trampoline/hazard on any of the four faces (incl. tops/bottoms). Only un-pressed
/// faces launch, so a fully-pressed block top can still be stood on. Arena walls / obstacles are
/// unaffected. Flat (not speed-scaled). 0 = off.</summary>
public float BlockPressBoost { get; init; } = 0.0f;
/// <summary>"Magnet feet": stay stuck to a moving block instead of being flung off when it slams to
/// a stop (suppresses the inertia fling). Off by default (the flingy original feel).</summary>
public bool StickToMovingBlocks { get; init; } = false;
/// <summary>FLIPPER: the character has NO jump — a fresh jump/up press while standing on a floor flips
/// its OWN gravity instead (persistently), and pressing down while hanging upside-down flips it back.
/// Implemented in <see cref="Player.HandleGravityFlip"/>; it also gates the normal ground jump off (the
/// same press can't both flip and jump). A self-flip XORs with a reverse-gravity FIELD, so entering a
/// field while self-flipped cancels back to normal gravity. Off by default.</summary>
public bool CanFlipGravity { get; init; } = false;
/// <summary>Adopts any newly touched surface as the gravity-relative floor. Movement and jump input
/// rotate with that floor, and Reverse fields invert the resulting cardinal gravity direction.</summary>
public bool HasSurfaceGravity { get; init; } = false;
/// <summary>Whether this character can wall-jump at all. True for the original moveset.
/// Independent of <see cref="CanNeutralWallJump"/> (which only gates the straight-up variant) —
/// false disables every wall jump.</summary>
public bool CanWallJump { get; init; } = true;
/// <summary>Whether holding into a side wall engages wall slide/grip and moving-block side carry.
/// True for the original moveset. False leaves the wall physically solid but prevents wall hugging.</summary>
public bool CanWallHug { get; init; } = true;
/// <summary>The original player's ability set — the baseline every character inherits. Expression-bodied
/// (a fresh instance each access) rather than a static field so edits to the baseline toggles above
/// take effect on hotload — a static field would be restored to its pre-edit value.</summary>
public static CharacterAbilities Original => new();
}
/// <summary>Movement sounds selected by a character. Keep cue identity here so adding character-specific
/// footsteps, landings, or jumps does not require character checks in <see cref="Player"/>.</summary>
public sealed class CharacterAudio
{
public SfxType Footstep { get; init; } = SfxType.PlayerWalk;
public SfxType Land { get; init; } = SfxType.PlayerLand;
public SfxType Jump { get; init; } = SfxType.PlayerJump;
public SfxType AirJump { get; init; } = SfxType.PlayerAirJump;
public SfxType Death { get; init; } = SfxType.PlayerSquish;
public bool PlayLandSfx { get; init; } = true;
/// <summary>Air-jump pitch when the jump leaves zero charges, and when it leaves the greatest
/// possible number of charges. Equal values produce a fixed pitch.</summary>
public float AirJumpPitchEmpty { get; init; } = 1.2f;
public float AirJumpPitchFull { get; init; } = 1.2f;
public float ResolveAirJumpPitch( int remaining, int maximum )
{
if ( maximum <= 1 ) return AirJumpPitchEmpty;
float fraction = Math.Clamp( remaining / (float)(maximum - 1), 0f, 1f );
return AirJumpPitchEmpty + (AirJumpPitchFull - AirJumpPitchEmpty) * fraction;
}
}
public enum DeathFxStyle
{
Blood,
Solar,
Spring,
Bird,
Wrapper,
Gunner,
}
public sealed class CharacterHelpRow
{
/// <summary>Short ability name shown above its instructions.</summary>
public string Name { get; set; }
/// <summary>Concise character-specific instructions.</summary>
public string Text { get; set; }
/// <summary>Whether the instruction text needs extra separation from an oversized illustration.</summary>
public bool ExtraTextTopPadding { get; set; }
/// <summary>Direction keys shown before the instructions, in display order.</summary>
public IReadOnlyList<Direction> Directions { get; set; } = Array.Empty<Direction>();
/// <summary>Whether direction keys are packed into one visual chord instead of spaced taps.</summary>
public bool CompactDirections { get; set; }
/// <summary>Whether this row shows Spring's animated charge poses and surface-relative controls.</summary>
public bool ShowChargeJumpSequence { get; set; }
/// <summary>Whether this row alternates between normal and vertically flipped gravity controls.</summary>
public bool ShowGravityFlipSequence { get; set; }
/// <summary>Whether this row alternates between rightward and mirrored leftward dash controls.</summary>
public bool ShowHorizontalDashSequence { get; set; }
/// <summary>Whether this row alternates grounded-to-air long jumps and their directional chords.</summary>
public bool ShowLongJumpSequence { get; set; }
/// <summary>Whether this row cycles through every cardinal and diagonal dash input.</summary>
public bool ShowDashSequence { get; set; }
/// <summary>Whether this row alternates between mirrored wall-kick poses and side controls.</summary>
public bool ShowWallKickSequence { get; set; }
/// <summary>Whether this row cycles through every eight-way blink and post-blink force input.</summary>
public bool ShowTeleportSequence { get; set; }
/// <summary>Whether this row cycles through repeated cardinal and diagonal thread inputs.</summary>
public bool ShowThreadInputSequence { get; set; }
/// <summary>Whether this row cycles through Swapper's repeated cardinal and diagonal portal-shot inputs.</summary>
public bool ShowPortalShotSequence { get; set; }
/// <summary>Whether this row cycles through Gunner's repeated cardinal and diagonal shooting inputs.</summary>
public bool ShowGunnerShootSequence { get; set; }
/// <summary>Whether this row alternates Climber's upward and downward wall movement.</summary>
public bool ShowClimberWallSequence { get; set; }
/// <summary>Whether this row cycles through Climber's stationary and lateral ceiling movement.</summary>
public bool ShowClimberCeilingSequence { get; set; }
/// <summary>Whether this row cycles from Climber's fast fall through its impact frames.</summary>
public bool ShowClimberFallSequence { get; set; }
/// <summary>Whether this row cycles through Rewind's explosion frames.</summary>
public bool ShowRewindExplosionSequence { get; set; }
/// <summary>Whether this row walks around each surface orientation.</summary>
public bool ShowShifterGravityGripSequence { get; set; }
/// <summary>Whether this row shows Twin 1's translucent phase-dash shake.</summary>
public bool ShowTwinPhaseDashSequence { get; set; }
/// <summary>Whether this row cycles through Mimic's shaking transformation frames.</summary>
public bool ShowMimicTransformSequence { get; set; }
/// <summary>Whether this row cycles through Mimic's horizontal and vertical squash recovery.</summary>
public bool ShowMimicSquashSequence { get; set; }
/// <summary>Optional text shown between direction keys.</summary>
public string DirectionSeparator { get; set; }
/// <summary>Direction index after which the separator is shown, or -1 for none.</summary>
public int DirectionSeparatorAfterIndex { get; set; } = -1;
/// <summary>How many copies of the character preview to show before the instructions.</summary>
public int CharacterPreviewCount { get; set; }
/// <summary>Explicit preview images shown before the description, used for mixed-character rows.</summary>
public IReadOnlyList<string> CharacterPreviewImages { get; set; } = Array.Empty<string>();
/// <summary>Optional per-image opacity values for explicit character previews.</summary>
public IReadOnlyList<float> CharacterPreviewOpacities { get; set; } = Array.Empty<float>();
/// <summary>Optional per-image vertical offsets for explicit character previews.</summary>
public IReadOnlyList<float> CharacterPreviewYOffsets { get; set; } = Array.Empty<float>();
/// <summary>Whether row character previews are packed tightly together.</summary>
public bool CompactCharacterPreviews { get; set; }
/// <summary>Whether explicit previews overlap to depict direct character contact.</summary>
public bool OverlapCharacterPreviews { get; set; }
/// <summary>Explicit preview index that receives the active-control marker, or -1 for none.</summary>
public int CharacterPreviewIndicatorIndex { get; set; } = -1;
/// <summary>Optional second preview index that crossfades with the active-control marker.</summary>
public int CharacterPreviewAlternateIndicatorIndex { get; set; } = -1;
/// <summary>Short text rendered immediately before the direction keys.</summary>
public string DirectionPrefix { get; set; }
/// <summary>Optional character pose rendered before this row's direction keys.</summary>
public string IllustrationImage { get; set; }
/// <summary>Optional second pose crossfaded with the row illustration.</summary>
public string IllustrationAlternateImage { get; set; }
/// <summary>Whether the row illustration cycles clockwise through all four surface orientations.</summary>
public bool CycleIllustrationQuarterTurns { get; set; }
/// <summary>Whether the row illustration uses the same soft glow as character previews.</summary>
public bool IllustrationGlow { get; set; }
/// <summary>Whether the row illustration is mirrored horizontally.</summary>
public bool IllustrationFlipHorizontal { get; set; }
}
public sealed class CharacterHelpDef
{
/// <summary>Whether a fresh profile opens this help automatically on the first eligible run.</summary>
public bool AutoShowOnFirstPlay { get; set; } = true;
/// <summary>Ordered ability instructions rendered by the in-game help overlay.</summary>
public IReadOnlyList<CharacterHelpRow> Rows { get; set; } = Array.Empty<CharacterHelpRow>();
/// <summary>Header name override; null shows the character's <see cref="CharacterDef.Name"/>.</summary>
public string Title { get; set; }
}
/// <summary>
/// One selectable character: a stable id, a display name, the baked sprite sheet it renders with,
/// a single-frame preview image for menus, and its <see cref="CharacterMovement"/>. Characters
/// share the same animation names (idle/walk/air_*/wall_*/squish/explode) and the same collision
/// size and interactions (crush, block-side presses) — only the art and the movement feel differ.
/// The <see cref="Id"/> is baked into replay payloads, so never rename/reuse one once runs exist.
/// </summary>
public sealed class CharacterDef
{
/// <summary>Stable identity, lowercase kebab-case. Baked into replay payloads.</summary>
public string Id { get; set; }
/// <summary>Display name shown on the title-screen character picker.</summary>
public string Name { get; set; }
/// <summary>Path to the baked .sprite sheet this character renders with (see <see cref="Player"/>).</summary>
public string SpritePath { get; set; }
/// <summary>The quad size (logical px) the sprite renders at, defaulting to the shared baseline
/// <see cref="Player.ART_SIZE"/>. The engine treats this as a SQUARE bounding box that the texture's
/// aspect ratio is fit inside (scaled by its LONGER edge), so it only renders 1:1 when this square
/// equals the sheet's longer edge. A character whose sheet is taller/wider than the baseline (e.g.
/// extra headroom for horns) sets this to match its own longer edge so its body still renders 1:1 and
/// stays flush with the walls/hitbox — WITHOUT forcing every other character's sheet to be re-padded.
/// The collision box (<see cref="Player.COLLISION_SIZE"/>) is independent of this and never changes.</summary>
public Vector2 ArtSize { get; set; } = Player.ART_SIZE;
/// <summary>A single static frame (raw PNG path) used as the menu preview thumbnail.</summary>
public string PreviewImage { get; set; }
/// <summary>Screen-pixel nudge for this character's icon in the HUD "?" button's lower-left corner
/// (+x right, +y down), so art with uneven padding sits visually flush with the others.</summary>
public Vector2 HelpButtonIconOffset { get; set; }
/// <summary>Preview-area scale that preserves the baseline character's source-pixel size.</summary>
public float PreviewScale
=> MathF.Max( ArtSize.x, ArtSize.y ) / MathF.Max( Player.ART_SIZE.x, Player.ART_SIZE.y );
/// <summary>An independently configured second body spawned with this selectable character.
/// The partner is not itself registered in <see cref="Characters.All"/>.</summary>
public CharacterDef Partner { get; set; }
/// <summary>This character's movement tuning. Uses the Original baseline unless overridden.</summary>
public CharacterMovement Movement { get; set; } = CharacterMovement.Original;
/// <summary>This character's abilities (toggles). Uses the Original set unless overridden.</summary>
public CharacterAbilities Abilities { get; set; } = CharacterAbilities.Original;
/// <summary>This character's movement sound cues and air-jump pitch progression.</summary>
public CharacterAudio Audio { get; set; } = new();
/// <summary>Material-specific debris emitted by every player death path.</summary>
public DeathFxStyle DeathFx { get; set; } = DeathFxStyle.Blood;
/// <summary>Blood tint used by organic death effects.</summary>
public Color BloodColor { get; set; } = new( 200f / 255f, 30f / 255f, 25f / 255f );
/// <summary>Optional player-facing ability help. Null disables the in-game help button.</summary>
public CharacterHelpDef Help { get; set; }
/// <summary>Help shown while a Mimic wears this shape (always a single body, so a paired character
/// teaches only the one twin's ability). Null falls back to <see cref="Help"/>.</summary>
public CharacterHelpDef MimicFormHelp { get; set; }
}
/// <summary>
/// The static character registry. Ordered as shown on the title-screen picker; <see cref="Original"/>
/// is always first and is what a run uses when no character is chosen
/// or a replay names an unknown character.
/// </summary>
public static class Characters
{
public const string OriginalId = "original";
// The definitions below are (re)built by Reload(), NOT initialised inline — same reasoning as
// Levels.Reload(): s&box hotload restores the previous values of static fields instead of re-running
// their initialisers, so inline-initialised defs keep their stale art/tuning after a code edit until
// a full editor restart. Building them in a method the "reload_characters" ConCmd can re-invoke
// lets character edits take effect live. The static ctor runs Reload() once on first access.
/// <summary>The original player.</summary>
public static CharacterDef Original { get; private set; }
/// <summary>A purple recolour of the original. Same size/collisions with blocks and the same
/// movement feel, but it can't bounce and it phases through the arena walls (unless spiked),
/// wrapping to the opposite edge instead.</summary>
public static CharacterDef Wrapper { get; private set; }
/// <summary>A cyan recolour of the original. Can't dash/bounce/wall-dive (like Wrapper) but collides
/// with the arena walls normally. On spawn it drops a persistent portal, and pressing all four
/// inputs on the same frame swaps its position with that portal.</summary>
public static CharacterDef Swapper { get; private set; }
/// <summary>An orange gunner. Has NONE of the special movement abilities (no dash / bounce /
/// wall-kick / wall-dive) — just plain running, jumping and wall-jumping — but carries a unique kit:
/// it reloads ammo by crouching (hold the toward-floor key grounded) and fires a fast bullet by
/// DOUBLE-TAPPING a direction (the dash's gesture; the gunner has no dash, so it's unambiguous).
/// See <see cref="GunnerAbility"/> and <see cref="Bullet"/>.</summary>
public static CharacterDef Gunner { get; private set; }
/// <summary>A magenta flipper. It has NO jump and CAN'T wall-jump — instead, pressing jump/up while
/// standing on a floor flips its own gravity (it "falls" up to the ceiling), and pressing down while
/// hanging upside-down flips it back. Entering a Reverse block's field while already self-flipped
/// cancels out to normal gravity. It also has an eight-way dash, recharged only by inverting.
/// See <see cref="CharacterAbilities.CanFlipGravity"/>.</summary>
public static CharacterDef Flipper { get; private set; }
/// <summary>A white bird. A light, floaty flyer: low gravity, an almost-halted descent while holding
/// Up, and four air jumps to stay aloft. It's a poor walker (very low ground speed with grippy, snappy
/// footing) but glides well (little air braking). Catches the wind and a siren's song far harder than
/// any other character.</summary>
public static CharacterDef Bird { get; private set; }
/// <summary>A lime-green spring with firm ground traction and committed airborne trajectories. Its
/// ground charge aims with Left/Right; holding into a wall charges a jump whose vertical angle is aimed
/// with Up/Down. It can cling briefly but cannot bounce or perform a conventional wall jump.</summary>
public static CharacterDef Spring { get; private set; }
/// <summary>A coordinated group that periodically creates shared-input copies and continues from a
/// random survivor whenever the currently authoritative body dies.</summary>
public static CharacterDef Swarm { get; private set; }
/// <summary>A momentum-heavy aerial mover that can return to its position and movement state from
/// two seconds ago by pressing all four directions.</summary>
public static CharacterDef Rewind { get; private set; }
/// <summary>An ammo-limited eight-way grappler that can steer while firing and being pulled, with
/// up to three extending hooks plus three pulling lines (acceleration currently disabled) anchored
/// to blocks, obstacles, or arena walls. Repeating an anchored line's launch gesture removes that
/// line; pressing all four directions within the standard leniency window destroys every active
/// line.</summary>
public static CharacterDef Grappler { get; private set; }
/// <summary>A cautious surface specialist with weak air control, long wall grips, wall climbing,
/// ceiling clinging, and low, strongly horizontal wall jumps. Hard floor impacts are lethal.</summary>
public static CharacterDef Climber { get; private set; }
/// <summary>A floaty aerial mover that can charge one directional blink per airtime.</summary>
public static CharacterDef Blinker { get; private set; }
/// <summary>A cardinal-gravity runner that turns every newly contacted surface into its floor.</summary>
public static CharacterDef Shifter { get; private set; }
/// <summary>A sunlight-powered robot whose energy controls its movement strength.</summary>
public static CharacterDef Solar { get; private set; }
/// <summary>A slow, ability-less shape-shifter that temporarily assumes other character forms.</summary>
public static CharacterDef Mimic { get; private set; }
/// <summary>The selectable first twin. Its partner is <see cref="TwinTwo"/>.</summary>
public static CharacterDef Twins { get; private set; }
/// <summary>The independently configured second twin. Not selectable on its own.</summary>
public static CharacterDef TwinTwo { get; private set; }
/// <summary>Every character, in title-screen picker order (Original first).</summary>
public static IReadOnlyList<CharacterDef> All { get; private set; }
static Characters() => Reload();
// Reorder this list to change the character picker's rows without moving definition blocks.
private static IReadOnlyList<CharacterDef> BuildPickerOrder() => new[]
{
Original,
Flipper,
Blinker,
Grappler,
Bird,
Swapper,
Spring,
Gunner,
Twins,
Shifter,
Climber,
Rewind,
Solar,
Wrapper,
Swarm,
Mimic,
};
/// <summary>
/// Rebuild every static character definition from source. Runs once via the static constructor and
/// again whenever the <c>reload_characters</c> ConCmd fires, so edits to a character's art or
/// tuning take effect without restarting the editor (hotload otherwise restores the pre-edit
/// instances).
///
/// NOTE: consumers that cache the registry at construction won't auto-refresh — <see cref="TitleStage"/>
/// reads the selected character on enter, so re-enter the title/picker to pick up a renamed character
/// or new preview art. A character's movement/ability edits apply to the next run that spawns them.
/// </summary>
public static void Reload()
{
Original = new()
{
Id = OriginalId,
Name = "NORMIE",
SpritePath = "sprites/player.sprite",
PreviewImage = "sprites/ui/characters/player/player_walk_0.png",
HelpButtonIconOffset = new Vector2( 0, 3 ),
Movement = CharacterMovement.Original,
// The baseline moveset opts INTO every gated ability (they now default off so new
// characters start minimal and add only what they need).
Abilities = new CharacterAbilities
{
CanBounce = true,
//CanDash = true,
CanWallDive = true,
CanNeutralWallJump = true,
},
Help = new CharacterHelpDef
{
AutoShowOnFirstPlay = false,
Rows = new CharacterHelpRow[]
{
new() { Name = "BOUNCE", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player/player_air_fall_fast.png", IllustrationGlow = true },
//new() { Name = "DASH", IllustrationImage = "sprites/ui/characters/player/player_air_up.png", IllustrationGlow = true, ShowHorizontalDashSequence = true },
// actually WallDive ability, but kick is a better description
new() { Name = "WALL KICK", IllustrationImage = "sprites/ui/characters/player/player_wall_down.png", IllustrationGlow = true, IllustrationFlipHorizontal = true, CompactDirections = true, ShowWallKickSequence = true },
},
},
};
Wrapper = new()
{
Id = "wrapper",
Name = "WRAITH",
SpritePath = "sprites/player_wrapper.sprite",
PreviewImage = "sprites/ui/characters/player_wrapper/player_walk_0.png",
ArtSize = new Vector2( 14f, 14f ),
DeathFx = DeathFxStyle.Wrapper,
Movement = new CharacterMovement
{
MaxRiseSpeed = 500.0f,
MaxFallSpeed = 300.0f,
JumpPower = 140.0f,
Gravity = 250.0f,
ActiveDownGravityRiseFactor = 3f,
WallJumpHorizontalPower = 105.0f,
WallJumpVerticalPower = 130.0f,
WallJumpTime = 0.4f,
WallGravityFactor = 0.025f,
ActiveUpGravityFactor = 0.1f,
ActiveUpGravityFallOnly = true,
},
Abilities = new CharacterAbilities
{
WrapsArenaEdges = true,
CollidesWithArenaWalls = false,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "PHASE THROUGH WALLS", IllustrationImage = "sprites/ui/characters/player_wrapper/player_air_up.png", IllustrationGlow = true },
},
},
};
Swapper = new()
{
Id = "swapper",
Name = "MAGICIAN",
SpritePath = "sprites/player_swapper.sprite",
PreviewImage = "sprites/ui/characters/player_swapper/player_walk_0.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
// A light, airy, slippery feel to set it apart from Original — punchy single jump with fixed-height
// wall jumps, so it can't chain jumps / wall-jumps into ever more rise speed. Add
// hang-time (a lighter hold-up gravity), lower ground friction (keeps horizontal momentum),
// a faster/less-grippy wall slide, a slightly lower top run speed (it's already strong with
// the swap), and a light "kick" wall-jump so the swap — not wall-jumping — is its main mobility
// tool. Movement differs only for this new character, so existing Original/Wrapper replays are
// unaffected and no Sim.VERSION bump is needed (movement is a pure fn of CharacterId).
Movement = new CharacterMovement
{
MaxXSpeed = 55.0f,
JumpPower = 100.0f,
MaxFallSpeed = 185.0f, // controlled descent without making the character overly floaty
ActiveUpGravityFactor = 0.40f, // hang-time: holding Up drifts down slowly (0.5 default)
HorizontalDeceleration = 340.0f,
WallGravityFactor = 0.25f, // less wall grip (0.15 default) — slides down faster
WallJumpHorizontalPower = 25.0f, // light "kick" wall-jump (70 default) — swap is the real mobility
WallJumpVerticalPower = 95.0f, // weaker rise than its ground jump — can't out-climb by wall-jumping
WallJumpStacksVerticalVelocity = false,
WallJumpTime = 1.5f,
ApexHangGravityFactor = 0.4f,
ApexHangVelThreshold = 10f,
HoverFrames = 80,
},
Abilities = new CharacterAbilities
{
// CanBounce/CanDash/CanWallDive/CanNeutralWallJump all stay at their new false default.
HasSwapPortal = true,
HoverRefreshOnFloor = false,
IgnoreWallJumpDoubleTaps = true,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "PORTAL SWAP", Directions = new[] { Direction.Left, Direction.Down, Direction.Up, Direction.Right }, CompactDirections = true, IllustrationImage = "sprites/ui/swapper_portal_swap.png" },
new() { Name = "PORTAL SHOT", IllustrationImage = "sprites/ui/swapper_portal_shot.png", ShowPortalShotSequence = true },
new() { Name = "HOVER", Text = "Recharge with Portal Swap", Directions = new[] { Direction.Up }, IllustrationImage = "sprites/ui/characters/player_swapper/player_air_down_active.png", IllustrationGlow = true },
},
},
};
Gunner = new()
{
Id = "gunner",
Name = "GUNNER",
SpritePath = "sprites/player_gunner.sprite",
PreviewImage = "sprites/ui/characters/player_gunner/player_walk_0.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
DeathFx = DeathFxStyle.Gunner,
// Ordinary movement feel (the shared baseline). Its traversal identity comes from ledge grabs
// and recoil from its own shots rather than wall movement.
Movement = new CharacterMovement
{
HorizontalAcceleration = 400f,
HorizontalDeceleration = 400f,
JumpPower = 100f,
AirFrictionFactor = 0.1f,
RiseGravityFactor = 0.75f,
FallGravityFactor = 1.5f,
Gravity = 210f,
ActiveUpGravityFactor = 1f,
ActiveDownGravityFactor = 1.5f,
ActiveDownGravityRiseFactor = 5f,
LandingMomentumRetention = 0.5f,
TurnaroundDeceleration = 200f,
//MaxXSpeed = 60f,
//MaxAirXSpeed = 78f,
//MaxRiseSpeed = 180f,
//MaxFallSpeed = 120f,
//HorizontalAcceleration = 320f,
//HorizontalDeceleration = 110f,
//AirAccelerationFactor = 0.8f,
//AirFrictionFactor = 0.5f,
//JumpPower = 86f,
//WallJumpHorizontalPower = 55f,
//WallJumpVerticalPower = 85f,
//Gravity = 135f,
//ActiveDownGravityFactor = 1f,
//ActiveDownGravityRiseFactor = 1f,
//ActiveUpGravityFactor = 1f,
//WallJumpTime = 0.5f,
},
Abilities = new CharacterAbilities
{
// No wall hug or wall jump. CanBounce/CanDash/CanWallKick/CanWallDive stay at their false
// defaults; dash in particular would collide with the double-tap fire input.
HasGunner = true,
CanLedgeGrab = true,
CanWallHug = false,
CanWallJump = false,
VariableJumpHeight = true,
VariableJumpCutFactor = 0.4f,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "SHOOT", ShowGunnerShootSequence = true, IllustrationGlow = true },
new() { Name = "RELOAD", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player_gunner/gun_reload_0.png", IllustrationAlternateImage = "sprites/ui/characters/player_gunner/gun_reload_1.png", IllustrationGlow = true },
new() { Name = "LEDGE GRAB", IllustrationImage = "sprites/ui/characters/player_gunner/player_wall_down.png", IllustrationGlow = true },
},
},
};
Flipper = new()
{
Id = "flipper",
Name = "TURVY",
SpritePath = "sprites/player_flipper.sprite",
PreviewImage = "sprites/ui/characters/player_flipper/player_idle.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
BloodColor = new Color( 72f / 255f, 212f / 255f, 68f / 255f ),
Movement = new CharacterMovement
{
Gravity = 220.0f,
MaxXSpeed = 75.0f,
HorizontalAcceleration = 350,
HorizontalDeceleration = 180,
AirAccelerationFactor = 0.8f,
AirFrictionFactor = 0.3f,
// Vertical control (these read the orientation-SWAPPED up/down, so "up" is physical down
// when self-flipped / in a reverse field — the fast/slow directions stay relative to the
// current floor). Base gravity is a touch gentle so the normal fall drifts
ActiveUpGravityFactor = 0.15f,
ActiveDownGravityFactor = 2.5f,
// More wall friction than the baseline (0.15)
WallGravityFactor = 0.03f,
WallSlideMaxFallSpeed = 35f,
JumpBufferFrames = 6,
//HoverFrames = 12,
},
Abilities = new CharacterAbilities
{
CanFlipGravity = true,
CanDash = true,
CanDashOnGround = true,
DashRefreshOnFloor = false,
DashForce = 100f,
DashForceAlongGravity = 300f,
DashGravitySuppressTime = 0.25f,
DashGravitySuppressTimeAlongGravity = 0f,
// All eight directions, like the Climber/Rewind. Up/down read PHYSICAL directions, so a
// self-flipped Flipper dashing "up" still goes up the screen.
CanDashHorizontal = true,
CanDashUp = true,
CanDashDown = true,
ConsolidateDashHorizontalVelocity = true,
CanWallJump = false,
//MaxAirJumps = 1,
//AirJumpPower = 30f,
//AirJumpHorizontalBoost = 110f,
//CanLongJump = true,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "INVERT", IllustrationImage = "sprites/ui/characters/player_flipper/player_idle.png", IllustrationGlow = true, ShowGravityFlipSequence = true },
new() { Name = "DASH", Text = "Recharge with Invert", IllustrationImage = "sprites/ui/characters/player_flipper/player_air_up.png", IllustrationAlternateImage = "sprites/ui/characters/player_flipper/player_air_down.png", IllustrationGlow = true, ShowDashSequence = true },
},
},
};
Bird = new()
{
Id = "bird",
Name = "OWL",
SpritePath = "sprites/player_bird.sprite",
PreviewImage = "sprites/ui/characters/player_bird/player_walk_0.png",
ArtSize = new Vector2( 14f, 14f ),
Audio = new CharacterAudio
{
AirJump = SfxType.BirdAirJump,
AirJumpPitchEmpty = 1.05f,
AirJumpPitchFull = 1.45f,
},
DeathFx = DeathFxStyle.Bird,
// A light, floaty flyer.
Movement = new CharacterMovement
{
HorizontalAcceleration = 85f,
HorizontalDeceleration = 140f,
Gravity = 188.0f, // lower gravity — floaty (300 default)
JumpPower = 70.0f, // weak jump — height comes from flapping (112 default)
WallJumpVerticalPower = 65.0f, // weak wall-jump rise (112 default) — no floating up walls
WallJumpHorizontalPower = 135.0f, // (70 default)
MaxXSpeed = 40.0f, // very low GROUND move speed (86 default)
MaxAirXSpeed = 50.0f,
DiveMaxAirXSpeed = 105.0f, // hold Down to swoop faster — flap Up for height, dive for distance
DiveHorizontalAcceleration = 125.0f, // the swoop builds toward the dive cap on its own
OverspeedDecay = 270f,
MaxFallSpeed = 105.0f, // slow glide-down terminal, even while actively diving
MaxRiseSpeed = 77.0f, // safety cap so flaps/wall-jumps can't stack into a huge rise
FallGravityFactor = 0.7f, // descending takes longer than climbing
ActiveUpGravityFactor = 0.27f, // much lower fall speed while holding Up (0.5 default)
ActiveDownGravityFactor = 1.2f, // Down still dives faster, but cannot defeat the slow descent
ActiveDownOverridesUp = true, // holding Down cancels a held Up — a dive can't be floated
//ActiveUpGravityFallOnly = true, // …but ONLY the DESCENT — so holding Up can't float jumps sky-high
AirAccelerationFactor = 0.9f, // ordinary air steering cannot quickly reverse a glide
AirFrictionFactor = 0.04f, // almost no natural air braking — redirect with a flap
GroundFrictionFactor = 5f, // very high GROUND x-deceleration — snappy stop (1.0 default)
ApexHangGravityFactor = 0.4f, // extra hang-time at the top of the arc (1.0 = off)
ApexHangVelThreshold = 12.0f, // …while |VelY| < 16 px/s
//HoverFrames = 14, // hold Up in the air to hover/glide briefly (once per airtime)
//WindCatchFactor = 1.9f, // more affected by wind (1.0 default)
//SirenCatchFactor = 1.6f, // more affected by a siren's song (1.0 default)
WallJumpTime = 2.0f,
WallRiseGravityFactor = 2.5f,
},
Abilities = new CharacterAbilities
{
// A pure flyer: no dash / wall-dive — its mobility is flapping (four air jumps) and gliding.
// Weak flaps but four of them, each with a small directional nudge.
MaxAirJumps = 6,
AirJumpPower = 70.0f,
AirJumpWhileHoldingDown = true, // a flap mid-swoop still fires (Down is a travel mode here)
AirJumpHorizontalBoost = 65.0f, // each flap nudges the held way — birdlike directional flap
CanLongJump = true,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "FLAP", Text = "Recharge by landing", Directions = new[] { Direction.Up }, IllustrationImage = "sprites/ui/characters/player_bird/player_air_up.png", IllustrationAlternateImage = "sprites/ui/characters/player_bird/player_air_up_active.png", IllustrationGlow = true },
new() { Name = "SWOOP", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player_bird/player_air_fall_fast.png", IllustrationGlow = true },
new() { Name = "LONG JUMP", IllustrationImage = "sprites/ui/characters/player_bird/player_walk_0.png", IllustrationAlternateImage = "sprites/ui/characters/player_bird/player_air_up.png", IllustrationGlow = true, ShowLongJumpSequence = true },
},
},
};
Spring = new()
{
Id = "spring",
Name = "JACK",
SpritePath = "sprites/player_spring.sprite",
PreviewImage = "sprites/ui/characters/player_spring/player_walk_0.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
Audio = new CharacterAudio { Death = SfxType.SpringDeath },
DeathFx = DeathFxStyle.Spring,
// A fast charge-jumper with firm ground traction and no control once airborne. MaxRiseSpeed is
// left at its high default (1500), comfortably above the fully-charged launch speeds
// (ChargeJumpMaxSpeed 340, wall 370), so a straight-up max charge jump is never clamped.
Movement = new CharacterMovement
{
MaxXSpeed = 45.0f,
MaxAirXSpeed = 385.0f,
JumpPower = 90f,
HorizontalAcceleration = 220.0f,
HorizontalDeceleration = 400.0f,
AirFrictionFactor = 0.0f,
AirAccelerationFactor = 0.15f,
//MaxRiseSpeed = 420.0f,
//MaxFallSpeed = 420.0f,
Gravity = 320.0f,
ActiveDownGravityRiseFactor = 1.1f, // holding Down barely brakes the ASCENT (still drops hard — 2.0 — on the way down)
WallJumpLeniencyFrames = 1,
},
Abilities = new CharacterAbilities
{
AutoBounceGround = true,
AutoBounceGroundRestitution = 0.7f,
AutoBounceGroundMinSpeed = 120.0f,
AutoBounceGroundImpactFeedbackFactor = 0.5f,
AutoBounceGroundCompressionFrames = 6,
AutoBounceWall = true,
AutoBounceWallRestitution = 0.7f,
AutoBounceWallMinSpeed = 120.0f,
//AutoBounceWallMinStrength = 10f,
AutoBounceCeiling = true,
AutoBounceCeilingMinSpeed = 120.0f,
AutoBounceCeilingRestitution = 0.7f,
CanWallJump = false,
WallClingFrames = 90,
// The signature move (see Player.HandleChargeJump).
HasChargeJump = true,
HasChargeWallJump = true,
HasChargeCeilingJump = true,
ChargeJumpAutoFireAtMax = false,
ChargeJumpMaxTime = 0.5f,
ChargeJumpMinSpeed = 130.0f,
ChargeJumpMaxSpeed = 380.0f,
ChargeJumpMinAngleDeg = 20.0f,
ChargeJumpDirectionalPoseThreshold = 0.25f,
ChargeWallJumpMaxTime = 0.75f,
ChargeWallJumpMinSpeed = 130.0f,
ChargeWallJumpMaxSpeed = 380.0f,
ChargeWallJumpGravitySuppressTime = 0.12f,
// Sticky blocks: the charge can be wound against the glue itself and tears free at a cost.
// Only a COMPLETELY full wind tears out of either phase (the wind-up clamps at max, so holding
// past it and releasing always qualifies), and the goo saps most of the launch on the way out.
StickyChargeReleaseFraction = 1.0f,
StickyChargeReleaseFractionPhase2 = 1.0f,
StickyChargePowerFactor = 0.4f,
StickyChargePowerFactorPhase2 = 0.4f,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "SPRING JUMP", Text = "Press away from surface to cancel", DirectionPrefix = "Hold", CompactDirections = true, ShowChargeJumpSequence = true },
},
},
};
Swarm = new()
{
Id = "swarm",
Name = "SWARM",
SpritePath = "sprites/player_swarm.sprite",
PreviewImage = "sprites/ui/characters/player_swarm/player_walk_0.png",
HelpButtonIconOffset = new Vector2( -3, -3 ),
ArtSize = new Vector2( 14f, 14f ),
Movement = new CharacterMovement
{
MaxXSpeed = 55.0f,
HorizontalAcceleration = 165.0f,
HorizontalDeceleration = 260.0f,
MaxAirXSpeed = 120f,
JumpPower = 90.0f,
Gravity = 350.0f,
JumpStrengthMinFactor = 0.4f,
JumpStrengthMaxFactor = 1.4f,
WallJumpStrengthMinFactor = 0.7f,
WallJumpStrengthMaxFactor = 1.5f,
WallJumpHorizontalPower = 35.0f,
WallJumpVerticalPower = 95.0f,
AirAccelerationFactor = 0.75f,
AirFrictionFactor = 0.4f,
//HoverFrames = 34,
WallJumpTime = 0.55f,
JumpBufferFrames = 1,
},
Abilities = new CharacterAbilities
{
//CanBounce = true,
HasSwarm = true,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "MITOSIS", CharacterPreviewCount = 7 },
new() { Name = "UNPREDICTABLE JUMP", CharacterPreviewImages = new[] { "sprites/ui/characters/player_swarm/player_air_up.png", "sprites/ui/characters/player_swarm/player_air_up.png" }, CharacterPreviewYOffsets = new[] { 10f, -10f } },
//new() { Name = "BOUNCE", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player_swarm/player_air_fall_fast.png", IllustrationGlow = true },
},
},
};
Grappler = new()
{
Id = "grappler",
Name = "SPIDER",
SpritePath = "sprites/player_grappler.sprite",
PreviewImage = "sprites/ui/characters/player_grappler/player_walk_0.png",
ArtSize = new Vector2( 14f, 14f ),
BloodColor = new Color( 10f / 255f, 5f / 255f, 60f / 255f ),
Movement = new CharacterMovement
{
MaxXSpeed = 66.0f,
MaxAirXSpeed = 74.0f,
AirAccelerationFactor = 0.3f,
HorizontalAcceleration = 310.0f,
JumpPower = 70.0f,
WallJumpVerticalPower = 66.0f,
WallJumpHorizontalPower = 90f,
Gravity = 220.0f,
ApexHangGravityFactor = 0.7f,
ApexHangVelThreshold = 10f,
},
Abilities = new CharacterAbilities
{
HasGrappler = true,
WallClingFrames = 20,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "CREATE OR DESTROY THREAD", ShowThreadInputSequence = true },
new() { Name = "DESTROY ALL THREADS", Directions = new[] { Direction.Left, Direction.Down, Direction.Up, Direction.Right }, CompactDirections = true },
},
},
};
Climber = new()
{
Id = "climber",
Name = "CLIMBER",
SpritePath = "sprites/player_climber.sprite",
PreviewImage = "sprites/ui/characters/player_climber/player_walk_0.png",
HelpButtonIconOffset = new Vector2( 3, 3 ),
Movement = new CharacterMovement
{
MaxXSpeed = 78.0f,
MaxAirXSpeed = 72.0f,
HorizontalAcceleration = 360.0f,
HorizontalDeceleration = 300.0f,
AirAccelerationFactor = 0.12f,
AirFrictionFactor = 0.18f,
JumpPower = 92.0f,
Gravity = 340.0f,
MaxFallSpeed = 370.0f,
ActiveDownGravityFactor = 1.1f,
WallGravityFactor = 0.12f,
WallSlideMaxFallSpeed = 32.0f,
WallClimbSpeed = 74.0f,
WallJumpHorizontalPower = 175.0f,
WallJumpVerticalPower = 28.0f,
WallJumpTime = 1.15f,
WallJumpLeniencyFrames = 10,
},
Abilities = new CharacterAbilities
{
WallClingFrames = 90,
CanCeilingCling = true,
FallDamageImpactSpeed = 255.0f,
CanDash = true,
CanDashOnGround = true,
DashForce = 150f,
DashGravitySuppressTime = 0.35f,
CanDashHorizontal = true,
CanDashUp = true,
CanDashDown = true,
//DashMomentumFactor = 0.1f,
ConsolidateDashHorizontalVelocity = true,
PreserveDashVerticalVelocity = true,
// Recharged by moving on surfaces, never by merely landing.
DashRefreshOnFloor = false,
DashRechargeFloorDistance = 60f,
DashRechargeWallDistance = 60f,
DashRechargeCeilingDistance = 60f,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "WALL CLIMB", DirectionPrefix = "Hold", IllustrationGlow = true, ShowClimberWallSequence = true },
new() { Name = "CEILING HANG", DirectionPrefix = "Hold", IllustrationGlow = true, ShowClimberCeilingSequence = true },
new() { Name = "DASH", Text = "Recharge by walking or climbing", IllustrationImage = "sprites/ui/characters/player_climber/player_air_up.png", IllustrationAlternateImage = "sprites/ui/characters/player_climber/player_air_down.png", IllustrationGlow = true, ShowDashSequence = true },
new() { Name = "HARD LANDING", IllustrationGlow = true, ShowClimberFallSequence = true },
},
},
};
Blinker = new()
{
Id = "blinker",
Name = "WITCH",
SpritePath = "sprites/player_blinker.sprite",
PreviewImage = "sprites/ui/characters/player_blinker/player_walk_0.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
Movement = new CharacterMovement
{
MaxXSpeed = 57.0f,
MaxAirXSpeed = 92.0f,
HorizontalAcceleration = 330.0f,
HorizontalDeceleration = 260.0f,
GroundFrictionFactor = 2.4f,
AirFrictionFactor = 0.5f,
AirAccelerationFactor = 1.8f,
JumpPower = 88.0f,
Gravity = 170.0f,
MaxRiseSpeed = 165.0f,
MaxFallSpeed = 185.0f,
ActiveUpGravityFactor = 0.38f,
ActiveUpGravityFallOnly = true,
WallGravityFactor = 0.05f,
WallRiseGravityFactor = 0.5f,
WallSlideMaxFallSpeed = 24.0f,
WallJumpHorizontalPower = 82.0f,
WallJumpVerticalPower = 82.0f,
WallJumpTime = 0.3f,
GroundedLeniencyFrames = 12,
JumpBufferFrames = 5,
},
Abilities = new CharacterAbilities
{
IgnoreWallJumpDoubleTaps = true,
HasBlinker = true,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "TELEPORT", Text = "Recharge by landing or pressing block side", ShowTeleportSequence = true },
},
},
};
Rewind = new()
{
Id = "rewind",
Name = "ECHO",
SpritePath = "sprites/player_rewind.sprite",
PreviewImage = "sprites/ui/characters/player_rewind/player_walk_0.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
Movement = new CharacterMovement
{
MaxXSpeed = 65.0f,
MaxAirXSpeed = 108.0f,
HorizontalAcceleration = 260.0f,
HorizontalDeceleration = 145.0f,
GroundFrictionFactor = 2.2f,
AirFrictionFactor = 0.12f,
AirAccelerationFactor = 1.35f,
JumpPower = 82.0f,
Gravity = 215.0f,
MaxRiseSpeed = 290.0f,
MaxFallSpeed = 205.0f,
ActiveUpGravityFactor = 0.4f,
ActiveUpGravityFallOnly = true,
WallGravityFactor = 0.35f,
WallJumpHorizontalPower = 40.0f,
WallJumpVerticalPower = 95.0f,
WallJumpTime = 0.2f,
ApexHangGravityFactor = 0.5f,
ApexHangVelThreshold = 12.0f,
},
Abilities = new CharacterAbilities
{
HasRewind = true,
CanDash = true,
DashForce = 215f,
DashGravitySuppressTime = 0.15f,
CanDashHorizontal = true,
CanDashUp = true,
CanDashDown = true,
CanDashOnGround = true,
DashRefreshOnFloor = false,
DashMomentumFactor = 0.1f,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "AFTERIMAGES", CharacterPreviewImages = new[] { "sprites/ui/characters/player_rewind/player_walk_3.png", "sprites/ui/characters/player_rewind/player_crouched.png", "sprites/ui/characters/player_rewind/player_walk_0.png" }, CharacterPreviewOpacities = new[] { 0.6f, 0.8f, 1f }, /*CharacterPreviewIndicatorIndex = 2*/ },
new() { Name = "PARADOX BOOST", CharacterPreviewImages = new[] { "sprites/ui/characters/player_rewind/player_air_up.png", "sprites/ui/characters/player_rewind/player_air_up_active.png" }, CharacterPreviewOpacities = new[] { 0.8f, 1f }, CharacterPreviewYOffsets = new[] { 10.5f, -15f }, /*CharacterPreviewIndicatorIndex = 1,*/ OverlapCharacterPreviews = true },
new() { Name = "DASH", Text = "Recharge with Paradox Boost", IllustrationImage = "sprites/ui/characters/player_rewind/player_air_up.png", IllustrationAlternateImage = "sprites/ui/characters/player_rewind/player_air_down.png", IllustrationGlow = true, ShowDashSequence = true },
new() { Name = "REWRITE HISTORY", ShowRewindExplosionSequence = true },
},
},
};
Shifter = new()
{
Id = "shifter",
Name = "GYRO",
SpritePath = "sprites/player_shifter.sprite",
PreviewImage = "sprites/ui/characters/player_shifter/player_idle.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
Movement = new CharacterMovement
{
MaxXSpeed = 58.0f,
MaxAirXSpeed = 66.0f,
HorizontalAcceleration = 400.0f,
HorizontalDeceleration = 320.0f,
JumpPower = 92.0f,
Gravity = 275.0f,
MaxRiseSpeed = 220.0f,
MaxFallSpeed = 260.0f,
AirAccelerationFactor = 0.9f,
AirFrictionFactor = 0.85f,
GroundedLeniencyFrames = 4,
ActiveUpGravityFactor = 0.4f,
},
Abilities = new CharacterAbilities
{
HasSurfaceGravity = true,
CanWallJump = false,
//StickToMovingBlocks = true,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "GRAVITY GRIP", ShowShifterGravityGripSequence = true },
},
},
};
Solar = new()
{
Id = "solar",
Name = "SOLAR",
SpritePath = "sprites/player_solar.sprite",
PreviewImage = "sprites/ui/characters/player_solar/player_idle.png",
HelpButtonIconOffset = new Vector2( -6, -6 ),
ArtSize = new Vector2( 16f, 16f ),
Audio = new CharacterAudio { Death = SfxType.SolarDeath },
DeathFx = DeathFxStyle.Solar,
Movement = new CharacterMovement
{
MaxXSpeed = 125f,
JumpPower = 100,
AirAccelerationFactor = 1.15f,
GroundFrictionFactor = 0.015f,
AirFrictionFactor = 0.125f,
//JumpSpeedBonus = 1.1f,
},
Abilities = new CharacterAbilities
{
CanBounce = true,
BounceRestitution = 0.65f,
//CanDash = true,
//CanDashHorizontal = false,
//CanDashUp = true,
//CanDashDown = true,
//DashForce = 105f,
//DashMomentumFactor = 0.6f,
//CanWallDive = true,
MaxAirJumps = 1,
AirJumpPower = 95f,
AirJumpStacksVerticalVelocity = true,
AirJumpHorizontalBoost = 50f,
HasSolar = true,
//WallClingFrames = 8,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "SOLAR POWERED", Text = "Energy recharges in sunlight", ExtraTextTopPadding = true, IllustrationImage = "sprites/ui/solar_powered.png" },
new() { Name = "BOUNCE", Text = "Use full energy to stun block", ExtraTextTopPadding = true, DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/solar_shock_bounce_outline.png" },
new() { Name = "ROCKET BOOST", Text = "Use half energy to air jump", Directions = new[] { Direction.Up }, IllustrationImage = "sprites/ui/characters/player_solar/player_air_up_active.png", IllustrationGlow = true },
//new() { Name = "WALL THRUST", IllustrationImage = "sprites/ui/characters/player_solar/player_wall_down.png", IllustrationGlow = true, IllustrationFlipHorizontal = true, CompactDirections = true, ShowWallKickSequence = true },
},
},
};
Mimic = new()
{
Id = "mimic",
Name = "MIMIC",
SpritePath = "sprites/player_mimic.sprite",
PreviewImage = "sprites/ui/characters/player_mimic/player_walk_0.png",
ArtSize = new Vector2( 14f, 14f ),
BloodColor = new Color( 214f / 255f, 88f / 255f, 154f / 255f ),
Movement = new CharacterMovement
{
MaxXSpeed = 76.0f,
MaxAirXSpeed = 80.0f,
HorizontalAcceleration = 310.0f,
HorizontalDeceleration = 310.0f,
JumpPower = 120.0f,
WallJumpHorizontalPower = 75.0f,
WallJumpVerticalPower = 110.0f,
Gravity = 300.0f,
AirAccelerationFactor = 0.8f,
AirFrictionFactor = 5f,
WallGravityFactor = 0.05f,
},
Abilities = new CharacterAbilities
{
HasMimic = true,
StickToMovingBlocks = true,
//BlockPressBoost = 200f,
WallClingFrames = 20,
CanNeutralWallJump = true,
CanBounce = true,
BounceRestitution = 0.55f,
},
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "STOLEN SHAPE", Directions = new[] { Direction.Left, Direction.Right }, DirectionSeparator = "/", DirectionSeparatorAfterIndex = 0, ShowMimicTransformSequence = true },
new() { Name = "BOUNCE", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player_mimic/player_air_fall_fast.png", IllustrationGlow = true },
new() { Name = "UNCRUSHABLE", ShowMimicSquashSequence = true },
},
},
};
TwinTwo = new()
{
Id = "twin-2",
Name = "TWIN 2",
SpritePath = "sprites/player_twin_2.sprite",
PreviewImage = "sprites/ui/characters/player_twin_2/player_walk_0.png",
ArtSize = new Vector2( 14f, 14f ),
Movement = TwinMovement(),
Abilities = new CharacterAbilities
{
HasHarden = true,
StickToMovingBlocks = true,
CanNeutralWallJump = true,
},
MimicFormHelp = new CharacterHelpDef
{
Title = "TWIN",
Rows = new CharacterHelpRow[]
{
new() { Name = "STONE FORM", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player_twin_2/player_crouched.png", IllustrationAlternateImage = "sprites/ui/characters/player_twin_2/player_hardened.png", IllustrationGlow = true },
},
},
};
Twins = new()
{
Id = "twins",
Name = "TWINS",
SpritePath = "sprites/player_twin_1.sprite",
PreviewImage = "sprites/ui/characters/player_twin_1/player_walk_0.png",
ArtSize = new Vector2( 14f, 14f ),
Movement = TwinMovement(),
Abilities = new CharacterAbilities
{
HasTwinDash = true,
IgnoreWallJumpDoubleTaps = true,
CanNeutralWallJump = true,
},
Partner = TwinTwo,
Help = new CharacterHelpDef
{
Rows = new CharacterHelpRow[]
{
new() { Name = "TAG TEAM", Text = "Release all keys", CharacterPreviewImages = new[] { "sprites/ui/characters/player_twin_1/player_walk_0.png", "sprites/ui/characters/player_twin_2/player_walk_0.png" }, CompactCharacterPreviews = true, CharacterPreviewIndicatorIndex = 1, CharacterPreviewAlternateIndicatorIndex = 0 },
new() { Name = "PHASE DASH", Directions = new[] { Direction.Right, Direction.Right }, ShowTwinPhaseDashSequence = true },
new() { Name = "STONE FORM", DirectionPrefix = "Hold", Directions = new[] { Direction.Down }, IllustrationImage = "sprites/ui/characters/player_twin_2/player_crouched.png", IllustrationAlternateImage = "sprites/ui/characters/player_twin_2/player_hardened.png", IllustrationGlow = true },
},
},
MimicFormHelp = new CharacterHelpDef
{
Title = "TWIN",
Rows = new CharacterHelpRow[]
{
new() { Name = "PHASE DASH", Directions = new[] { Direction.Right, Direction.Right }, ShowTwinPhaseDashSequence = true },
},
},
};
All = BuildPickerOrder();
}
private static CharacterMovement TwinMovement() => new()
{
MaxXSpeed = 70f,
MaxAirXSpeed = 78f,
MaxRiseSpeed = 180f,
MaxFallSpeed = 120f,
HorizontalAcceleration = 320f,
HorizontalDeceleration = 110f,
AirAccelerationFactor = 0.8f,
AirFrictionFactor = 0.5f,
JumpPower = 90f,
WallJumpHorizontalPower = 60f,
WallJumpVerticalPower = 87f,
Gravity = 135f,
ActiveDownGravityFactor = 1f,
ActiveDownGravityRiseFactor = 1f,
ActiveUpGravityFactor = 1f,
WallJumpTime = 0.3f,
};
/// <summary>Console command: rebuild the static character definitions from source so art/tuning
/// edits apply without an editor restart. Run <c>reload_characters</c> from the console.</summary>
[ConCmd( "reload_characters" )]
public static void ReloadCharactersCmd()
{
if ( !Game.IsEditor ) return;
Reload();
Log.Info( $"[BlockParty] Reloaded {All.Count} character definitions." );
}
/// <summary>Resolve a character id to its definition. Null/empty/unknown maps to
/// <see cref="Original"/> so a missing or removed character never breaks a run or replay.</summary>
public static CharacterDef Get( string id )
{
if ( string.IsNullOrEmpty( id ) )
return Original;
return All.FirstOrDefault( c => c.Id == id ) ?? Original;
}
/// <summary>Try to resolve a known, non-empty character id without falling back to Original.</summary>
public static bool TryGet( string id, out CharacterDef character )
{
character = string.IsNullOrEmpty( id ) ? null : All.FirstOrDefault( c => c.Id == id );
return character is not null;
}
/// <summary>Like <see cref="TryGet"/> but also resolves partner defs (a Mimic can wear Twin 2's
/// shape, which is never selectable and so never in <see cref="All"/>).</summary>
public static bool TryGetIncludingPartners( string id, out CharacterDef character )
{
if ( TryGet( id, out character ) ) return true;
character = string.IsNullOrEmpty( id ) ? null : All.Select( c => c.Partner ).FirstOrDefault( p => p?.Id == id );
return character is not null;
}
/// <summary>Resolve a character id or display name without requiring matching case.</summary>
public static bool TryGetByIdOrName( string value, out CharacterDef character )
{
character = string.IsNullOrWhiteSpace( value )
? null
: All.FirstOrDefault( c =>
string.Equals( c.Id, value.Trim(), StringComparison.OrdinalIgnoreCase )
|| string.Equals( c.Name, value.Trim(), StringComparison.OrdinalIgnoreCase ) );
return character is not null;
}
/// <summary>Index of a character id in <see cref="All"/> (for the picker); unknown → 0.</summary>
public static int IndexOf( string id )
{
var def = Get( id );
for ( int i = 0; i < All.Count; i++ )
if ( All[i] == def )
return i;
return 0;
}
}