{"TotalCount":229,"Files":[{"Ident":"facepunch.blockparty","Path":"Entities/Abilities/GunnerAbility.cs","FileName":"GunnerAbility.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\n\r\nnamespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// The Gunner character\u0027s kit: Ability 1 fires in the held direction, with a small recoil kick\r\n/// opposite the shot. Neutral shots use recent aim, then facing, and briefly allow late directional\r\n/// input. Pre-v45 recordings retain the original directional double-tap firing scheme.\r\n///\r\n/// \u003Clist type=\u0022bullet\u0022\u003E\r\n/// \u003Citem\u003E\u003Cb\u003EPress Ability 1 with a cardinal or diagonal direction\u003C/b\u003E: fire one round in that WORLD\r\n/// direction. With ammo, spend a round \u002B spawn a \u003Csee cref=\u0022Bullet\u0022/\u003E; empty, a harmless dry-fire spark cough.\u003C/item\u003E\r\n/// \u003Citem\u003E\u003Cb\u003EHold crouch (the toward-floor key, see \u003Csee cref=\u0022Player.CrouchHeld\u0022/\u003E) on a reload\r\n/// surface, not full\u003C/b\u003E: reload one round per \u003Csee cref=\u0022RELOAD_TIME\u0022/\u003E while moving at half speed.\r\n/// Valid surfaces are the current floor, sticky glue, or a ledge grabbed while crouch was already\r\n/// held and remains held. Crouching alone never fires.\u003C/item\u003E\r\n/// \u003C/list\u003E\r\n///\r\n/// Runs in \u003Csee cref=\u0022PreTick\u0022/\u003E (fixed step, before the player moves). Deterministic / replay-safe:\r\n/// reads only the recorded \u003Csee cref=\u0022InputState\u0022/\u003E and the player\u0027s fixed-step state, advances its\r\n/// timers on the tick \u003Cc\u003Edt\u003C/c\u003E, and draws only from the cosmetic RNG stream for the sparks. Firing \u002B\r\n/// reloading are pure functions of the recorded input, so a Gunner run reproduces exactly on replay.\r\n/// \u003C/summary\u003E\r\npublic sealed class GunnerAbility : PlayerAbility\r\n{\r\n\tpublic const int MAX_AMMO = 3;\r\n\r\n\t// Hold crouch on a valid surface (not full) this long to gain one round.\r\n\tprivate const float RELOAD_TIME = 0.4f;\r\n\tprivate const float RELOAD_MOVEMENT_FACTOR = 0.5f;\r\n\tprivate const float RELOAD_SHAKE_HORIZONTAL_MAX = 0.18f;\r\n\tprivate const float RELOAD_SHAKE_VERTICAL_MIN = 0.8f;\r\n\tprivate const float RELOAD_SHAKE_VERTICAL_MAX = 1.1f;\r\n\tprivate const int RELOAD_SHAKE_FRAMES = 4;\r\n\tprivate const int SHOT_HITSTOP_FRAMES = 7;\r\n\tprivate const float AIRBORNE_DOWN_FRICTION_FACTOR = 1.05f;\r\n\tprivate const float DRY_FIRE_SHAKE_STRENGTH = 1.1f;\r\n\tprivate const int DRY_FIRE_SHAKE_FRAMES = 4;\r\n\tprivate const float AIM_RELEASE_GRACE_TIME = 0.08f;\r\n\tprivate const int NEUTRAL_AIM_WAIT_TICKS = 2;\r\n\r\n\t// How long the shoot pose lingers after a shot. Visual only \u2014 a tap-fire is instantaneous, and the\r\n\t// pose override resets every tick, so without this linger the pose would never be seen.\r\n\tprivate const float SHOOT_POSE_TIME = 0.22f;\r\n\r\n\t// How far in front of the player\u0027s centre a bullet / its muzzle sparks spawn (clears the body). The\r\n\t// bullet\u0027s first collision step sweeps back from the player\u0027s centre, so a solid inside this gap is hit.\r\n\tprivate const float MUZZLE_OFFSET = 8f;\r\n\r\n\t// Recoil opposite the shot (live rounds only \u2014 a dry fire has nothing to recoil). It consolidates\r\n\t// motion on the recoil axis into the player\u0027s base velocity. From sim v49, shooting into the\r\n\t// supporting glass queues one fallback lift unless the bullet delivers at least as much lift on impact.\r\n\tprivate const float KICKBACK_FORCE = 150f;\r\n\t// Cancel the full component of total movement opposing recoil before applying the base kick.\r\n\tprivate const float KICKBACK_COUNTER_MOMENTUM_FACTOR = 1f;\r\n\r\n\tprivate int _ammo = MAX_AMMO;\r\n\tprivate float _reloadTimer;\r\n\r\n\tprivate readonly DirectionalDoubleTapGesture _fireGesture = new();\r\n\tprivate Vector2 _recentAim;\r\n\tprivate float _recentAimTime;\r\n\t// At most two requests can be waiting: one edge per tick, each resolved two ticks later.\r\n\t// Keep them separate so consecutive recorded taps cannot overwrite one another.\r\n\tprivate readonly List\u003C(Vector2 Fallback, int Ticks)\u003E _pendingShots = new( NEUTRAL_AIM_WAIT_TICKS );\r\n\r\n\t// Lingering shoot-pose state (see SHOOT_POSE_TIME).\r\n\tprivate Vector2 _poseDirection;\r\n\tprivate float _poseTimer;\r\n\tprivate bool _poseEmpty;\r\n\r\n\t/// \u003Csummary\u003ECurrent rounds in reserve (for any future HUD / debug read-out).\u003C/summary\u003E\r\n\tpublic int Ammo =\u003E _ammo;\r\n\r\n\tpublic override void OnDeath( Player player ) =\u003E ClearBufferedAim();\r\n\tpublic override void OnSquashed( Player player ) =\u003E ClearBufferedAim();\r\n\tpublic override void OnRemoved( Player player ) =\u003E ClearBufferedAim();\r\n\r\n\tprivate void ClearBufferedAim()\r\n\t{\r\n\t\t_recentAim = Vector2.Zero;\r\n\t\t_recentAimTime = 0f;\r\n\t\t_pendingShots.Clear();\r\n\t}\r\n\r\n\tpublic override void PreTick( Player player, float dt )\r\n\t{\r\n\t\tif ( player.CrouchHeld \u0026\u0026 player.IsGunnerAirborne )\r\n\t\t\tplayer.SetGunnerAirFrictionFactor( AIRBORNE_DOWN_FRICTION_FACTOR );\r\n\r\n\t\tTickTapGesture( player, dt );\r\n\t\tTickReload( player, dt );\r\n\t\t// Last, so a fresh shot\u0027s pose wins over the reload pose for its brief linger.\r\n\t\tTickPose( player, dt );\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003EFire on Ability 1, using facing for neutral aim from v47. Older simulations\r\n\t/// retain the shared gesture path, including v45/v46\u0027s away-from-floor neutral aim.\u003C/summary\u003E\r\n\tprivate void TickTapGesture( Player player, float dt )\r\n\t{\r\n\t\tif ( player.Stage.SimVersion \u003E= Sim.GUNNER_AIM_GRACE )\r\n\t\t{\r\n\t\t\tTickBufferedFire( player, dt );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( player.Stage.SimVersion \u003E= Sim.GUNNER_NEUTRAL_FACING )\r\n\t\t{\r\n\t\t\tif ( !player.Ability1Just ) return;\r\n\t\t\tVector2 aim = player.ReadAbilityDirection( neutralFallback: false );\r\n\t\t\tif ( aim.LengthSquared \u003C= 0f )\r\n\t\t\t\taim = new Vector2( player.FacingDirection == Direction.Left ? -1f : 1f, 0f );\r\n\t\t\tFire( player, aim );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _fireGesture.TryTrigger( player, dt, out Vector2 direction ) )\r\n\t\t\tFire( player, direction );\r\n\t}\r\n\r\n\tprivate void TickBufferedFire( Player player, float dt )\r\n\t{\r\n\t\tif ( player.Ability1InputSuppressed || player.IsDead || player.IsSquashed )\r\n\t\t{\r\n\t\t\tClearBufferedAim();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tVector2 aim = ReadBufferedAim( player, out bool hasHeldAim );\r\n\t\tbool hasAim = aim.LengthSquared \u003E 0f;\r\n\t\tif ( hasAim )\r\n\t\t{\r\n\t\t\t_recentAim = aim;\r\n\t\t\t_recentAimTime = AIM_RELEASE_GRACE_TIME;\r\n\t\t}\r\n\t\telse\r\n\t\t\t_recentAimTime = Math.Max( 0f, _recentAimTime - dt );\r\n\r\n\t\t// Resolve older requests before accepting this tick\u0027s press. A direction arriving on the\r\n\t\t// deadline still wins. Releasing the fire button does not cancel an already accepted tap.\r\n\t\tfor ( int i = 0; i \u003C _pendingShots.Count; )\r\n\t\t{\r\n\t\t\tvar pending = _pendingShots[i];\r\n\t\t\tpending.Ticks--;\r\n\t\t\tif ( hasAim || pending.Ticks \u003C= 0 )\r\n\t\t\t{\r\n\t\t\t\t_pendingShots.RemoveAt( i );\r\n\t\t\t\tFire( player, hasAim ? aim : pending.Fallback );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\t_pendingShots[i] = pending;\r\n\t\t\t\ti\u002B\u002B;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !player.Ability1Just ) return;\r\n\t\tif ( hasHeldAim )\r\n\t\t\tFire( player, aim );\r\n\t\telse\r\n\t\t{\r\n\t\t\t// Freeze the fallback at the fire press: recent aim must not expire while we wait,\r\n\t\t\t// and an unrelated facing change during the wait must not redirect this shot.\r\n\t\t\tVector2 fallback = _recentAimTime \u003E 0f ? _recentAim\r\n\t\t\t\t: new Vector2( player.FacingDirection == Direction.Left ? -1f : 1f, 0f );\r\n\t\t\t_pendingShots.Add( (fallback, NEUTRAL_AIM_WAIT_TICKS) );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static Vector2 ReadBufferedAim( Player player, out bool hasHeldAim )\r\n\t{\r\n\t\tVector2 aim = player.ReadAbilityDirection( neutralFallback: false );\r\n\t\thasHeldAim = aim.LengthSquared \u003E 0f;\r\n\t\tvar input = player.InputSource;\r\n\t\t// Current held directions are authoritative, including an opposing chord that cancels out.\r\n\t\t// Only with every direction released can a recorded short tap become remembered/late aim.\r\n\t\tif ( input.Left || input.Right || input.Up || input.Down ) return aim;\r\n\t\treturn Utils.Normalized( new Vector2(\r\n\t\t\t(input.RightJust ? 1f : 0f) - (input.LeftJust ? 1f : 0f),\r\n\t\t\t(input.UpJust ? 1f : 0f) - (input.DownJust ? 1f : 0f) ) );\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003EReload while CROUCHING on a valid surface. Releasing crouch, leaving the surface, or\r\n\t/// reaching full ammo immediately ends reloading and resets progress.\u003C/summary\u003E\r\n\tprivate void TickReload( Player player, float dt )\r\n\t{\r\n\t\tif ( !player.CrouchHeld || !player.IsGunnerReloadSupported || _ammo \u003E= MAX_AMMO )\r\n\t\t{\r\n\t\t\t_reloadTimer = 0f;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tplayer.SetGunnerReloadMovementFactor( RELOAD_MOVEMENT_FACTOR );\r\n\t\t_reloadTimer \u002B= dt;\r\n\t\tif ( _reloadTimer \u003C RELOAD_TIME )\r\n\t\t{\r\n\t\t\tplayer.SetGunnerPose( PlayerAnimType.GunReload, Direction.None );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_reloadTimer -= RELOAD_TIME;\r\n\t\t_ammo = Math.Min( _ammo \u002B 1, MAX_AMMO );\r\n\t\tVector2 reloadShake = new(\r\n\t\t\tRng.CosmeticFloat( -RELOAD_SHAKE_HORIZONTAL_MAX, RELOAD_SHAKE_HORIZONTAL_MAX ),\r\n\t\t\tRng.CosmeticFloat( RELOAD_SHAKE_VERTICAL_MIN, RELOAD_SHAKE_VERTICAL_MAX ) );\r\n\t\tplayer.Shake( reloadShake, RELOAD_SHAKE_FRAMES );\r\n\r\n\t\t// A crisp mechanical \u0022chamber\u0022 cue \u002B a small puff of sparks at the player\u0027s feet.\r\n\t\tAudio.PlaySfx( SfxType.SpikesRetract, player.Position, volume: 1.8f, pitch: Rng.CosmeticFloat( 1.1f, 1.3f ) );\r\n\t\tif ( player.DrivesHaptics ) Haptics.Pulse( 0.3f, 0.05f, 0f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );\r\n\t\tfor ( int i = 0; i \u003C 4; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tVector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( 0.2f, 1f ) ) * Rng.CosmeticFloat( 20f, 45f );\r\n\t\t\tplayer.Stage.AddParticle( new Vector2( player.X, player.Bottom \u002B 1 ), vel, Rng.CosmeticFloat( 0.9f, 0.95f ),\r\n\t\t\t\tGlobals.GRAVITY_STR_DUST, ParticleKind.GunSpark1, Rng.CosmeticFloat( 0.2f, 0.35f ), Rng.CosmeticInt( 2, 4 ) );\r\n\t\t}\r\n\r\n\t\tif ( _ammo \u003E= MAX_AMMO )\r\n\t\t{\r\n\t\t\t_reloadTimer = 0f;\r\n\t\t\tplayer.SetGunnerReloadMovementFactor( 1f );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tplayer.SetGunnerPose( PlayerAnimType.GunReload, Direction.None );\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003EKeep the shoot pose up for a beat after a tap-fire (visual only; the override is\r\n\t/// re-set each tick while the linger runs).\u003C/summary\u003E\r\n\tprivate void TickPose( Player player, float dt )\r\n\t{\r\n\t\tif ( _poseTimer \u003C= 0f ) return;\r\n\t\t_poseTimer -= dt;\r\n\r\n\t\tbool vertical = _poseDirection.x == 0f;\r\n\t\tPlayerAnimType pose = _poseEmpty\r\n\t\t\t? (vertical\r\n\t\t\t\t? (_poseDirection.y \u003E 0f ? PlayerAnimType.GunShootUpEmpty : PlayerAnimType.GunShootDownEmpty)\r\n\t\t\t\t: PlayerAnimType.GunShootSideEmpty)\r\n\t\t\t: (vertical\r\n\t\t\t\t? (_poseDirection.y \u003E 0f ? PlayerAnimType.GunShootUp : PlayerAnimType.GunShootDown)\r\n\t\t\t\t: PlayerAnimType.GunShootSide);\r\n\t\tDirection face = _poseDirection.x \u003C 0f ? Direction.Left\r\n\t\t\t: _poseDirection.x \u003E 0f ? Direction.Right\r\n\t\t\t: Direction.None;\r\n\t\tplayer.SetGunnerPose( pose, face );\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003EFire in \u003Cparamref name=\u0022direction\u0022/\u003E: spend a round \u002B spawn a bullet if any ammo, otherwise a\r\n\t/// harmless spray of sparks (a dry fire). Either way the shoot pose lingers briefly.\u003C/summary\u003E\r\n\tprivate void Fire( Player player, Vector2 direction )\r\n\t{\r\n\t\t_poseDirection = direction;\r\n\t\t_poseTimer = SHOOT_POSE_TIME;\r\n\t\t_poseEmpty = _ammo \u003C= 0;\r\n\r\n\t\tVector2 muzzle = player.Pos \u002B direction * MUZZLE_OFFSET;\r\n\r\n\t\tif ( _ammo \u003E 0 )\r\n\t\t{\r\n\t\t\t_ammo--;\r\n\t\t\tBullet bullet = player.Stage.AddBullet( muzzle, direction, sweepOrigin: player.Pos );\r\n\t\t\tplayer.ApplyGunKickback( direction, KICKBACK_FORCE, KICKBACK_COUNTER_MOMENTUM_FACTOR, bullet );\r\n\t\t\t// A short gunshot crack leads; the original fireball sound adds a quieter trailing texture.\r\n\t\t\tAudio.PlaySfx( SfxType.GunnerShoot, player.Position, 1.2f );\r\n\t\t\tAudio.PlaySfx( SfxType.FireballShoot, player.Position, 0.5f );\r\n\t\t\t// The heaviest one-shot in the kit bar death: a live round is the Gunner\u0027s whole point, and\r\n\t\t\t// the recoil it applies is real. A dry fire deliberately gets nothing.\r\n\t\t\tif ( player.DrivesHaptics ) Haptics.Pulse( 0.85f, 0.13f, direction.x * 0.5f, Haptics.TONE_HEAVY );\r\n\t\t\tSpawnMuzzle( player, muzzle, direction, live: true );\r\n\t\t\tplayer.Stage.RequestHitStop( SHOT_HITSTOP_FRAMES );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tplayer.Shake( direction * DRY_FIRE_SHAKE_STRENGTH, DRY_FIRE_SHAKE_FRAMES );\r\n\t\t\tAudio.PlaySfx( SfxType.GunnerDryFire, player.Position, 1.4f );\r\n\t\t\tSpawnMuzzle( player, muzzle, direction, live: false );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECosmetic muzzle spray in the fire direction \u2014 brighter/faster on a live shot, a weak\r\n\t/// cough on a dry fire. Cosmetic RNG only.\u003C/summary\u003E\r\n\tprivate static void SpawnMuzzle( Player player, Vector2 muzzle, Vector2 direction, bool live )\r\n\t{\r\n\t\tVector2 perp = new Vector2( -direction.y, direction.x );\r\n\t\tint count = live ? 6 : 3;\r\n\t\tfloat speedMin = live ? 60f : 20f;\r\n\t\tfloat speedMax = live ? 140f : 55f;\r\n\r\n\t\tfor ( int i = 0; i \u003C count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tVector2 vel = direction * Rng.CosmeticFloat( speedMin, speedMax )\r\n\t\t\t\t\u002B perp * Rng.CosmeticFloat( -35f, 35f );\r\n\t\t\tParticleKind kind = Rng.CosmeticFloat( 0f, 1f ) \u003C 0.5f ? ParticleKind.GunSpark0 : ParticleKind.GunSpark1;\r\n\t\t\tplayer.Stage.AddParticle( muzzle, vel, Rng.CosmeticFloat( 0.86f, 0.93f ), 0f, kind,\r\n\t\t\t\tRng.CosmeticFloat( 0.15f, live ? 0.35f : 0.25f ), Rng.CosmeticInt( 2, live ? 5 : 4 ) );\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/BackgroundParticle.cs","FileName":"BackgroundParticle.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\n\n/// \u003Csummary\u003E\n/// Resolved runtime settings for a level\u0027s background particle emitter (see\n/// \u003Csee cref=\u0022LevelDef.BackgroundParticlesEnabled\u0022/\u003E): the per-level authored values with defaults\n/// applied and everything clamped to sane ranges. Built by the stage\u0027s\n/// \u003Cc\u003EBuildBackgroundParticleSettings\u003C/c\u003E hook (null = the feature is off) and cached until the\n/// editor invalidates it, so the emitter never re-reads the level mid-tick.\n/// \u003C/summary\u003E\npublic sealed class BackgroundParticleSettings\n{\n\t/// \u003Csummary\u003EDefault square colour when the level doesn\u0027t set one \u2014 a soft rain-like blue-grey.\u003C/summary\u003E\n\tpublic static readonly Color DefaultColor = new( 188f / 255f, 216f / 255f, 226f / 255f );\n\n\tpublic Direction Edge = Direction.Up;\n\t/// \u003Csummary\u003EResolved square tint; the level\u0027s opacity rides in the alpha.\u003C/summary\u003E\n\tpublic Color Color = DefaultColor;\n\tpublic int SizeMin = 1;\n\tpublic int SizeMax = 2;\n\t/// \u003Csummary\u003EDegrees the emit direction tilts from straight-inward (positive = counter-clockwise).\u003C/summary\u003E\n\tpublic float Angle;\n\t/// \u003Csummary\u003ERandom tilt added per particle, in degrees either side of \u003Csee cref=\u0022Angle\u0022/\u003E.\u003C/summary\u003E\n\tpublic float AngleRange;\n\tpublic float SpeedMin = 90f;\n\tpublic float SpeedMax = 140f;\n\t/// \u003Csummary\u003EWorld-vertical acceleration in px/s\u00B2, positive = downward (negative keeps bottom-edge\n\t/// embers rising).\u003C/summary\u003E\n\tpublic float Gravity = 60f;\n\t/// \u003Csummary\u003EParticles spawned per second.\u003C/summary\u003E\n\tpublic float SpawnRate = 12f;\n\t/// \u003Csummary\u003EExtra sprites rendered in the grid cells the square most recently vacated, forming a\n\t/// streak behind it (rain lines). 0 = no trail.\u003C/summary\u003E\n\tpublic int TrailLength;\n\t/// \u003Csummary\u003EResolved trail tint (falls back to the square colour, like the impact colour).\u003C/summary\u003E\n\tpublic Color TrailColor = DefaultColor;\n\t/// \u003Csummary\u003ETrue = segments fade toward the tail (comet look); false = the whole streak renders\n\t/// at the particle\u0027s own opacity (solid line).\u003C/summary\u003E\n\tpublic bool TrailFade = true;\n\t/// \u003Csummary\u003EWhether destroyed particles burst into impact-spray fragments at all.\u003C/summary\u003E\n\tpublic bool ImpactEnabled;\n\t/// \u003Csummary\u003EFragments per destroyed particle, rolled in [Min, Max] per impact (a 0 roll = none).\u003C/summary\u003E\n\tpublic int ImpactCountMin = 2;\n\tpublic int ImpactCountMax = 4;\n\t/// \u003Csummary\u003EFragment starting speed range in px/s.\u003C/summary\u003E\n\tpublic float ImpactSpeedMin = 15f;\n\tpublic float ImpactSpeedMax = 40f;\n\t/// \u003Csummary\u003EFragment square side length range in logical pixels.\u003C/summary\u003E\n\tpublic int ImpactSizeMin = 1;\n\tpublic int ImpactSizeMax = 2;\n\tpublic float ImpactGravity = 150f;\n\t/// \u003Csummary\u003ERandom spray tilt, in degrees either side of the struck surface\u0027s outward normal.\u003C/summary\u003E\n\tpublic float ImpactAngleRange = 60f;\n\t/// \u003Csummary\u003EResolved spray tint (falls back to the square colour).\u003C/summary\u003E\n\tpublic Color ImpactColor = DefaultColor;\n\n\tpublic static BackgroundParticleSettings FromLevel( LevelDef d )\n\t{\n\t\tif ( d is null || !d.BackgroundParticlesEnabled ) return null;\n\t\treturn Build( d.BackgroundParticleEdge, d.BackgroundParticleColor, d.BackgroundParticleOpacity,\n\t\t\td.BackgroundParticleSizeMin, d.BackgroundParticleSizeMax,\n\t\t\td.BackgroundParticleAngle, d.BackgroundParticleAngleRange,\n\t\t\td.BackgroundParticleSpeedMin, d.BackgroundParticleSpeedMax,\n\t\t\td.BackgroundParticleGravity, d.BackgroundParticleSpawnRate,\n\t\t\td.BackgroundParticleTrailLength, d.BackgroundParticleTrailColor, d.BackgroundParticleTrailFade,\n\t\t\td.BackgroundParticleImpactEnabled, d.BackgroundParticleImpactCountMin, d.BackgroundParticleImpactCountMax,\n\t\t\td.BackgroundParticleImpactSpeedMin, d.BackgroundParticleImpactSpeedMax,\n\t\t\td.BackgroundParticleImpactSizeMin, d.BackgroundParticleImpactSizeMax,\n\t\t\td.BackgroundParticleImpactGravity, d.BackgroundParticleImpactAngleRange,\n\t\t\td.BackgroundParticleImpactColor );\n\t}\n\n\tpublic static BackgroundParticleSettings FromEditorLevel( EditorLevel e )\n\t{\n\t\tif ( e is null || !e.BackgroundParticlesEnabled ) return null;\n\t\treturn Build( e.BackgroundParticleEdge, e.BackgroundParticleColor, e.BackgroundParticleOpacity,\n\t\t\te.BackgroundParticleSizeMin, e.BackgroundParticleSizeMax,\n\t\t\te.BackgroundParticleAngle, e.BackgroundParticleAngleRange,\n\t\t\te.BackgroundParticleSpeedMin, e.BackgroundParticleSpeedMax,\n\t\t\te.BackgroundParticleGravity, e.BackgroundParticleSpawnRate,\n\t\t\te.BackgroundParticleTrailLength, e.BackgroundParticleTrailColor, e.BackgroundParticleTrailFade,\n\t\t\te.BackgroundParticleImpactEnabled, e.BackgroundParticleImpactCountMin, e.BackgroundParticleImpactCountMax,\n\t\t\te.BackgroundParticleImpactSpeedMin, e.BackgroundParticleImpactSpeedMax,\n\t\t\te.BackgroundParticleImpactSizeMin, e.BackgroundParticleImpactSizeMax,\n\t\t\te.BackgroundParticleImpactGravity, e.BackgroundParticleImpactAngleRange,\n\t\t\te.BackgroundParticleImpactColor );\n\t}\n\n\t// Defensive clamps (mirrors EditorLevel.FromJson) so a hand-edited level file can\u0027t produce a\n\t// runaway emitter (e.g. a thousand-per-second spawn rate or an outward emit angle).\n\tprivate static BackgroundParticleSettings Build( Direction edge, Color? color, float opacity,\n\t\tint sizeMin, int sizeMax, float angle, float angleRange, float speedMin, float speedMax,\n\t\tfloat gravity, float rate, int trailLength, Color? trailColor, bool trailFade,\n\t\tbool impactEnabled, int impactCountMin, int impactCountMax,\n\t\tfloat impactSpeedMin, float impactSpeedMax, int impactSizeMin, int impactSizeMax,\n\t\tfloat impactGravity, float impactAngleRange, Color? impactColor )\n\t{\n\t\tvar s = new BackgroundParticleSettings\n\t\t{\n\t\t\tEdge = edge is Direction.Down or Direction.Left or Direction.Right ? edge : Direction.Up,\n\t\t\tSizeMin = Math.Clamp( sizeMin, 1, 8 ),\n\t\t\tAngle = Math.Clamp( angle, -75f, 75f ),\n\t\t\tAngleRange = Math.Clamp( angleRange, 0f, 90f ),\n\t\t\tSpeedMin = Math.Clamp( speedMin, 5f, 300f ),\n\t\t\tGravity = Math.Clamp( gravity, -400f, 400f ),\n\t\t\tSpawnRate = Math.Clamp( rate, 0f, 60f ),\n\t\t\tTrailLength = Math.Clamp( trailLength, 0, 8 ),\n\t\t\tTrailFade = trailFade,\n\t\t\tImpactEnabled = impactEnabled,\n\t\t\tImpactCountMin = Math.Clamp( impactCountMin, 0, 10 ),\n\t\t\tImpactSpeedMin = Math.Clamp( impactSpeedMin, 5f, 150f ),\n\t\t\tImpactSizeMin = Math.Clamp( impactSizeMin, 1, 8 ),\n\t\t\tImpactGravity = Math.Clamp( impactGravity, -400f, 400f ),\n\t\t\tImpactAngleRange = Math.Clamp( impactAngleRange, 0f, 90f ),\n\t\t};\n\t\ts.SizeMax = Math.Clamp( sizeMax, s.SizeMin, 8 );\n\t\ts.SpeedMax = Math.Clamp( speedMax, s.SpeedMin, 300f );\n\t\ts.ImpactCountMax = Math.Clamp( impactCountMax, s.ImpactCountMin, 10 );\n\t\ts.ImpactSpeedMax = Math.Clamp( impactSpeedMax, s.ImpactSpeedMin, 150f );\n\t\ts.ImpactSizeMax = Math.Clamp( impactSizeMax, s.ImpactSizeMin, 8 );\n\t\tfloat alpha = Math.Clamp( opacity, 0.05f, 1f );\n\t\ts.Color = (color ?? DefaultColor).WithAlpha( alpha );\n\t\ts.TrailColor = (trailColor ?? color ?? DefaultColor).WithAlpha( alpha );\n\t\ts.ImpactColor = (impactColor ?? color ?? DefaultColor).WithAlpha( alpha );\n\t\treturn s;\n\t}\n}\n\n/// \u003Csummary\u003E\n/// One square of a level\u0027s authored background ambience (rain / snow / embers \u2014 see\n/// \u003Csee cref=\u0022LevelDef.BackgroundParticlesEnabled\u0022/\u003E). Emitted from an arena edge by the stage\n/// (\u003Csee cref=\u0022StageBase.TickBackgroundParticles\u0022/\u003E), it flies under constant world-vertical\n/// gravity and is DESTROYED by the first solid it meets \u2014 interior obstacles, glass and live\n/// blocks (never fences), or any arena wall (its emit edge only counts once it has first cleared\n/// it) \u2014 optionally bursting into short-lived spray fragments along the struck surface\u0027s normal. Purely\n/// decorative: it renders between the drifting backdrop and the fences, never touches gameplay\n/// entities, and draws all randomness from the BACKGROUND Rng stream (like\n/// \u003Csee cref=\u0022BackgroundBlock\u0022/\u003E), so level ambience can\u0027t perturb the sim or the\n/// player-triggered cosmetic stream.\n/// \u003C/summary\u003E\npublic sealed class BackgroundParticle : Entity2D\n{\n\t/// \u003Csummary\u003ESafety net for a near-stationary configuration (tiny speed, zero gravity): nothing\n\t/// should live this long \u2014 a particle normally dies crossing the arena or striking a solid.\u003C/summary\u003E\n\tprivate const float MAX_LIFETIME = 30f;\n\n\t/// \u003Csummary\u003ELongest distance a main square may move per collision sample. The overlap test only\n\t/// sees positions the square actually visits, so a fast particle (gravity accumulates well past\n\t/// its starting speed) covering several px per tick would otherwise TUNNEL straight through a\n\t/// thin obstacle or glass pane. 2px guarantees a sample inside anything at least that thin.\u003C/summary\u003E\n\tprivate const float MAX_MOVE_STEP = 2f;\n\n\tpublic StageBase Stage { get; set; }\n\n\t/// \u003Csummary\u003ETrue for an impact-spray fragment (no collision, short fading lifetime). The stage\u0027s\n\t/// spawn cap counts only the emitted squares, not these.\u003C/summary\u003E\n\tpublic bool IsBurst =\u003E _isBurst;\n\n\tprivate Color _color;\n\tprivate float _gravity;          // px/s\u00B2, positive = downward (applied as -Y; the play plane is \u002BY up)\n\tprivate Direction _spawnEdge;    // main squares only: graced until first cleared, then a wall like any other\n\tprivate bool _leftSpawnEdge;     // set once the square has fully cleared its emit edge (see CheckArenaWalls)\n\tprivate bool _spraysOnImpact;\n\tprivate bool _isBurst;           // spray fragment: no collision, fades out over its short lifetime\n\tprivate float _totalLifetime = MAX_LIFETIME;\n\tprivate float _lifetime = MAX_LIFETIME;\n\n\tprivate SpriteRenderer _sprite;\n\n\t// Trail (main squares only): extra sprites rendered in the grid cells the square most recently\n\t// vacated \u2014 a rain-streak line. _trailCells is a ring buffer of vacated cell centres, newest at\n\t// _trailHead; sprites are children of this GameObject positioned by LOCAL offset from the\n\t// current cell each SyncTransform.\n\tprivate int _trailLength;\n\tprivate Color _trailColor;\n\tprivate bool _trailFade;\n\tprivate SpriteRenderer[] _trailSprites;\n\tprivate Vector2[] _trailCells;\n\tprivate int _trailHead = -1;\n\tprivate int _trailCount;\n\tprivate Vector2 _lastCell;\n\tprivate bool _hasCell;\n\n\t/// \u003Csummary\u003EConfigure a main emitted square.\u003C/summary\u003E\n\tpublic void Setup( Vector2 velocity, float gravity, Color color, Direction spawnEdge, bool spraysOnImpact )\n\t{\n\t\tVelocity = velocity;\n\t\t_gravity = gravity;\n\t\t_color = color;\n\t\t_spawnEdge = spawnEdge;\n\t\t_spraysOnImpact = spraysOnImpact;\n\t}\n\n\t/// \u003Csummary\u003EEnable the streak behind a main square. \u003Cparamref name=\u0022fade\u0022/\u003E thins the segments\n\t/// toward the tail; false keeps the whole line at the trail colour\u0027s own opacity. Call before\n\t/// \u003Csee cref=\u0022CreateVisuals\u0022/\u003E (which builds the segment sprites).\u003C/summary\u003E\n\tpublic void SetupTrail( int length, Color color, bool fade )\n\t{\n\t\t_trailLength = length;\n\t\t_trailColor = color;\n\t\t_trailFade = fade;\n\t}\n\n\t/// \u003Csummary\u003EConfigure an impact-spray fragment: collisionless, fading out over \u003Cparamref name=\u0022lifetime\u0022/\u003E.\u003C/summary\u003E\n\tpublic void SetupBurst( Vector2 velocity, float gravity, Color color, float lifetime )\n\t{\n\t\tVelocity = velocity;\n\t\t_gravity = gravity;\n\t\t_color = color;\n\t\t_isBurst = true;\n\t\t_totalLifetime = lifetime;\n\t\t_lifetime = lifetime;\n\t}\n\n\tpublic void CreateVisuals( int childOrder )\n\t{\n\t\t_sprite = SpriteLayer.Add( GameObject, \u0022sprites/pixel.sprite\u0022, Size, \u0022idle\u0022, childOrder );\n\t\t_sprite.Color = _color;\n\t\t// The level\u0027s particle opacity rides in the tint\u0027s alpha (and bursts fade theirs out), so\n\t\t// these must alpha-blend: opt out of the opaque default and zero the cutoff (which DISCARDS\n\t\t// pixels under 0.5) \u2014 the same pairing as BackgroundBlock / lane overlays / field motes.\n\t\t_sprite.Opaque = false;\n\t\t_sprite.AlphaCutoff = 0f;\n\n\t\tif ( _trailLength \u003C= 0 ) return;\n\t\t_trailSprites = new SpriteRenderer[_trailLength];\n\t\t_trailCells = new Vector2[_trailLength];\n\t\tfor ( int i = 0; i \u003C _trailLength; i\u002B\u002B )\n\t\t{\n\t\t\t// Sprite i is the segment nearest the head; deeper segments stack under it, and the\n\t\t\t// caller passes the head a childOrder above _trailLength so it always tops its own trail.\n\t\t\tvar segment = SpriteLayer.Add( GameObject, \u0022sprites/pixel.sprite\u0022, Size, \u0022idle\u0022, _trailLength - 1 - i );\n\t\t\tsegment.Color = Color.Transparent;   // hidden until its ring-buffer slot has data\n\t\t\tsegment.Opaque = false;\n\t\t\tsegment.AlphaCutoff = 0f;\n\t\t\t_trailSprites[i] = segment;\n\t\t}\n\t}\n\n\tpublic override void Tick( float dt )\n\t{\n\t\tif ( _isBurst )\n\t\t{\n\t\t\tPos \u002B= Velocity * dt;\n\t\t\tVelocity \u002B= new Vector2( 0f, -_gravity * dt );\n\t\t\t// Fragments have no wall collision (a splash may straddle the wall band on purpose),\n\t\t\t// so reclaim any that leave the arena square entirely rather than letting them fade\n\t\t\t// out invisibly behind the edge blockers.\n\t\t\tif ( ReclaimIfOutsideArena() )\n\t\t\t\treturn;\n\t\t\t_lifetime -= dt;\n\t\t\tif ( _sprite is not null )\n\t\t\t\t_sprite.Color = _color.WithAlpha( _color.a * Math.Clamp( _lifetime / _totalLifetime, 0f, 1f ) );\n\t\t\tif ( _lifetime \u003C= 0f )\n\t\t\t\tDead = true;\n\t\t\treturn;\n\t\t}\n\n\t\t// Substep the move so the collision tests sample every MAX_MOVE_STEP px of travel \u2014 a\n\t\t// single move-then-test would let a fast square skip clean through a thin solid.\n\t\tVector2 move = Velocity * dt;\n\t\tint substeps = Math.Max( 1, (int)MathF.Ceiling( move.Length / MAX_MOVE_STEP ) );\n\t\tVector2 step = move / substeps;\n\t\tfor ( int i = 0; i \u003C substeps; i\u002B\u002B )\n\t\t{\n\t\t\tPos \u002B= step;\n\t\t\tif ( CheckSolids() )\n\t\t\t\treturn;\n\t\t\tif ( CheckArenaWalls() )\n\t\t\t\treturn;\n\t\t\t// Belt-and-braces: the wall checks make escape impossible by construction, but if a\n\t\t\t// square somehow ends up fully outside the arena anyway, reclaim it silently.\n\t\t\tif ( ReclaimIfOutsideArena() )\n\t\t\t\treturn;\n\t\t}\n\t\tVelocity \u002B= new Vector2( 0f, -_gravity * dt );\n\n\t\t_lifetime -= dt;\n\t\tif ( _lifetime \u003C= 0f )\n\t\t\tDead = true;\n\t}\n\n\t/// \u003Csummary\u003EDie (with the optional spray) on the first overlapped solid. The stage rebuilt the\n\t/// shared snapshot at the top of the tick: interior obstacles \u002B glass \u002B live blocks, never\n\t/// fences (the same solid set Particle\u0027s bounce path uses).\u003C/summary\u003E\n\tprivate bool CheckSolids()\n\t{\n\t\tforeach ( RectF rect in Stage.BackgroundParticleSolids )\n\t\t{\n\t\t\tif ( !GetRect().Intersects( rect ) ) continue;\n\t\t\t// Min-penetration eject snaps us flush against the struck face; the centre comparison\n\t\t\t// turns the resolved axis into a signed outward normal (the BlockBulletProjectile idiom).\n\t\t\tswitch ( ResolveMinPenetration( rect ) )\n\t\t\t{\n\t\t\t\tcase ReflectAxis.Horizontal:\n\t\t\t\t\tImpact( new Vector2( X \u003C (rect.Left \u002B rect.Right) * 0.5f ? -1f : 1f, 0f ) );\n\t\t\t\t\treturn true;\n\t\t\t\tcase ReflectAxis.Vertical:\n\t\t\t\t\tImpact( new Vector2( 0f, Y \u003C (rect.Bottom \u002B rect.Top) * 0.5f ? -1f : 1f ) );\n\t\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\t/// \u003Csummary\u003EKill (silently) anything fully outside the 240x240 arena square. The area just past\n\t/// the walls is masked by the edge blockers, so nothing out there is ever visible anyway.\u003C/summary\u003E\n\tprivate bool ReclaimIfOutsideArena()\n\t{\n\t\tfloat halfW = Width * 0.5f;\n\t\tfloat halfH = Height * 0.5f;\n\t\tif ( X \u002B halfW \u003C 0f || X - halfW \u003E Arena.WIDTH || Y \u002B halfH \u003C 0f || Y - halfH \u003E Arena.HEIGHT )\n\t\t\tDead = true;\n\t\treturn Dead;\n\t}\n\n\t/// \u003Csummary\u003EArena walls destroy the particle like any solid. The spawn edge gets a ONE-TIME\n\t/// grace: no burst while the square has never yet cleared it (it was emitted flush with it) \u2014\n\t/// but once it\u0027s genuinely in flight, returning to that edge bursts like any other wall, so a\n\t/// bottom-edge ember pulled back down by gravity splashes on the floor it rose from. The\n\t/// non-spawn walls are checked FIRST so a particle grazing a corner during the grace bursts on\n\t/// the perpendicular wall rather than slipping past it.\u003C/summary\u003E\n\tprivate bool CheckArenaWalls()\n\t{\n\t\tfloat halfW = Width * 0.5f;\n\t\tfloat halfH = Height * 0.5f;\n\n\t\tif ( (_leftSpawnEdge || _spawnEdge != Direction.Left) \u0026\u0026 X - halfW \u003C Arena.WALL_SIZE )\n\t\t\treturn WallImpact( new Vector2( 1f, 0f ) );\n\t\tif ( (_leftSpawnEdge || _spawnEdge != Direction.Right) \u0026\u0026 X \u002B halfW \u003E Arena.WIDTH - Arena.WALL_SIZE )\n\t\t\treturn WallImpact( new Vector2( -1f, 0f ) );\n\t\tif ( (_leftSpawnEdge || _spawnEdge != Direction.Down) \u0026\u0026 Y - halfH \u003C Arena.WALL_SIZE )\n\t\t\treturn WallImpact( new Vector2( 0f, 1f ) );\n\t\tif ( (_leftSpawnEdge || _spawnEdge != Direction.Up) \u0026\u0026 Y \u002B halfH \u003E Arena.HEIGHT - Arena.WALL_SIZE )\n\t\t\treturn WallImpact( new Vector2( 0f, -1f ) );\n\n\t\tif ( _leftSpawnEdge )\n\t\t\treturn false;\n\n\t\t// Still in the spawn grace: has the square cleared its emit edge yet?\n\t\tbool touchingSpawnEdge = _spawnEdge switch\n\t\t{\n\t\t\tDirection.Left =\u003E X - halfW \u003C Arena.WALL_SIZE,\n\t\t\tDirection.Right =\u003E X \u002B halfW \u003E Arena.WIDTH - Arena.WALL_SIZE,\n\t\t\tDirection.Down =\u003E Y - halfH \u003C Arena.WALL_SIZE,\n\t\t\t_ =\u003E Y \u002B halfH \u003E Arena.HEIGHT - Arena.WALL_SIZE,\n\t\t};\n\t\tif ( !touchingSpawnEdge )\n\t\t{\n\t\t\t_leftSpawnEdge = true;   // in flight \u2014 from now on the spawn edge bursts like any wall\n\t\t\treturn false;\n\t\t}\n\n\t\t// Overlapping its emit edge without ever having cleared it (a steep angle skimming along\n\t\t// it): keep the lenient path \u2014 never burst against its own source, just quietly despawn\n\t\t// once it has drifted ALL the way back out. The edge blockers mask the sliver rendered\n\t\t// outside the arena meanwhile.\n\t\tbool fullyOutside = _spawnEdge switch\n\t\t{\n\t\t\tDirection.Left =\u003E X \u002B halfW \u003C Arena.WALL_SIZE,\n\t\t\tDirection.Right =\u003E X - halfW \u003E Arena.WIDTH - Arena.WALL_SIZE,\n\t\t\tDirection.Down =\u003E Y \u002B halfH \u003C Arena.WALL_SIZE,\n\t\t\t_ =\u003E Y - halfH \u003E Arena.HEIGHT - Arena.WALL_SIZE,\n\t\t};\n\t\tif ( fullyOutside )\n\t\t\tDead = true;\n\t\treturn Dead;\n\t}\n\n\tprivate bool WallImpact( Vector2 inwardNormal )\n\t{\n\t\tClampToBounds( X, Y );   // snap flush so the burst spawns on the wall surface\n\t\tImpact( inwardNormal );\n\t\treturn true;\n\t}\n\n\tprivate void Impact( Vector2 normal )\n\t{\n\t\tDead = true;\n\t\tif ( _spraysOnImpact )\n\t\t\tStage.AddBackgroundParticleBurst( Pos, normal );\n\t}\n\n\t/// \u003Csummary\u003EBesides the base transform push, maintain and lay out the trail: record every grid\n\t/// cell the square vacated since last frame (interpolated, so a fast square still paints a\n\t/// CONTINUOUS streak instead of dashes), then position each segment sprite by its LOCAL offset\n\t/// from the current cell \u2014 fading linearly toward the tail, or held at uniform opacity when the\n\t/// trail\u0027s fade is off.\u003C/summary\u003E\n\tpublic override void SyncTransform()\n\t{\n\t\tbase.SyncTransform();\n\t\tif ( _trailSprites is null ) return;\n\n\t\tVector2 cell = SnapToCell( Pos );\n\t\tif ( !_hasCell )\n\t\t{\n\t\t\t_hasCell = true;\n\t\t\t_lastCell = cell;\n\t\t}\n\t\telse if ( cell != _lastCell )\n\t\t{\n\t\t\t// One step per particle-size along the move keeps consecutive segments edge-to-edge.\n\t\t\tVector2 delta = cell - _lastCell;\n\t\t\tint steps = Math.Max( 1, (int)MathF.Ceiling( delta.Length / MathF.Max( 1f, Width ) ) );\n\t\t\tfor ( int k = 0; k \u003C steps; k\u002B\u002B )\n\t\t\t\tPushTrailCell( _lastCell \u002B delta * (k / (float)steps) );   // excludes the current cell \u2014 the head occupies it\n\t\t\t_lastCell = cell;\n\t\t}\n\n\t\tfor ( int i = 0; i \u003C _trailSprites.Length; i\u002B\u002B )\n\t\t{\n\t\t\tvar segment = _trailSprites[i];\n\t\t\tif ( i \u003E= _trailCount )\n\t\t\t{\n\t\t\t\tsegment.Color = Color.Transparent;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tVector2 p = _trailCells[(_trailHead - i \u002B _trailLength * 2) % _trailLength];\n\t\t\tvar go = segment.GameObject;\n\t\t\tgo.LocalPosition = new Vector3( p.x - cell.x, p.y - cell.y, go.LocalPosition.z );\n\t\t\tfloat alpha = _trailFade ? _trailColor.a * (1f - (i \u002B 1f) / (_trailLength \u002B 1f)) : _trailColor.a;\n\t\t\tsegment.Color = _trailColor.WithAlpha( alpha );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EThe rendered pixel-grid centre for a logical position (same corner-aligned snap as\n\t/// \u003Csee cref=\u0022Entity2D.SyncTransform\u0022/\u003E, so trail cells land exactly where the head rendered).\u003C/summary\u003E\n\tprivate Vector2 SnapToCell( Vector2 raw )\n\t{\n\t\tRectF r = GetPixelRect( raw.x, raw.y );\n\t\treturn new Vector2( r.Left \u002B Width * 0.5f, r.Bottom \u002B Height * 0.5f );\n\t}\n\n\tprivate void PushTrailCell( Vector2 raw )\n\t{\n\t\tVector2 p = SnapToCell( raw );\n\t\t// Interpolation can land two steps in the same snapped cell \u2014 don\u0027t double-record it.\n\t\tif ( _trailCount \u003E 0 \u0026\u0026 p == _trailCells[_trailHead] ) return;\n\t\t_trailHead = (_trailHead \u002B 1) % _trailLength;\n\t\t_trailCells[_trailHead] = p;\n\t\tif ( _trailCount \u003C _trailLength ) _trailCount\u002B\u002B;\n\t}\n}\n"},{"Ident":"facepunch.blockparty","Path":"Entities/Blocks/BlockBullet.cs","FileName":"BlockBullet.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Dragon-derived marksman block. Hard impacts wind up aimed shots which pass through authored\r\n/// obstacles and arena walls; phase 1 is slower and phase 2 uses the original fast bullet.\r\n/// \u003C/summary\u003E\r\npublic class BlockBullet : Block\r\n{\r\n\tenum ShootState { Idle, Windup, Recover }\r\n\r\n\tprivate const float SHOOT_WINDUP = 0.35f;\r\n\tprivate const float SHOOT_RECOVER = 0.65f;\r\n\tprivate ShootState _shootState;\r\n\tprivate float _shootTimer;\r\n\tprivate BlockBulletVariant _shootVariant;\r\n\tprotected override bool CanActivateTimedAbility =\u003E _shootState == ShootState.Idle;\r\n\tprotected virtual BlockBulletVariant ProjectileVariantForPhase( int phase )\r\n\t\t=\u003E phase == 1 ? BlockBulletVariant.BulletPhase1 : BlockBulletVariant.BulletPhase2;\r\n\r\n\tpublic override void Impact( Block otherBlock )\r\n\t{\r\n\t\tbase.Impact( otherBlock );\r\n\t\tif ( IsDead ) return;\r\n\r\n\t\tif ( _moveSpeed \u003E 40f \u0026\u0026 (Phase == 1 || Phase == 2) \u0026\u0026 _shootState == ShootState.Idle )\r\n\t\t\tStartShoot();\r\n\t}\r\n\r\n\tprivate void StartShoot()\r\n\t{\r\n\t\t_shootVariant = ProjectileVariantForPhase( Phase );\r\n\t\t_shootState = ShootState.Windup;\r\n\t\t_shootTimer = SHOOT_WINDUP;\r\n\t\t_mouth.PlayAnimation( $\u0022mouth_shooting_{Phase}\u0022 );\r\n\t}\r\n\r\n\tpublic override void Tick( float dt )\r\n\t{\r\n\t\tbase.Tick( dt );\r\n\t\tif ( IsDead || _shootState == ShootState.Idle ) return;\r\n\r\n\t\t_shootTimer -= dt;\r\n\t\tif ( _shootTimer \u003E 0f ) return;\r\n\r\n\t\tif ( _shootState == ShootState.Windup )\r\n\t\t{\r\n\t\t\tif ( !TrySpawnBullet() )\r\n\t\t\t{\r\n\t\t\t\tFinishShoot();\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tAudio.PlaySfx( SfxType.FireballShoot, Position, 0.9f, 1.15f );\r\n\t\t\t_shootState = ShootState.Recover;\r\n\t\t\t_shootTimer = SHOOT_RECOVER;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tFinishShoot();\r\n\t}\r\n\r\n\tprivate void FinishShoot()\r\n\t{\r\n\t\tRestoreFaceAndMouth();\r\n\t\t_shootState = ShootState.Idle;\r\n\t}\r\n\r\n\tprivate bool TrySpawnBullet()\r\n\t{\r\n\t\tPlayer player = Stage.ClosestTargetablePlayer( Position );\r\n\t\tif ( player is null ) return false;\r\n\r\n\t\tVector2 spawnPosition = Position \u002B new Vector2( 0f, -Height * 0.2f );\r\n\t\tVector2 direction = BlockBulletProjectile.LeadDirection(\r\n\t\t\tspawnPosition, player, BlockBulletProjectile.FullSpeed( _shootVariant ) );\r\n\t\tStage.AddBlockBullet( spawnPosition, direction, _shootVariant );\r\n\t\treturn true;\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/Blocks/BlockHunter.cs","FileName":"BlockHunter.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Hunter block \u2014 a stalking pursuer. Phase 0 is the neutral random wander every block shares. From\r\n/// phase 1 on it hunts:\r\n///\r\n///  \u2022 After each stop it turns toward the player, ranking the four axis directions by how much they\r\n///    close the straight-line distance and taking the best non-blocked one (see \u003Csee cref=\u0022PickHuntDirection\u0022/\u003E).\r\n///  \u2022 Crucially it doesn\u0027t commit to a full lane like other blocks: while charging toward the player it\r\n///    BRAKES the instant it draws level with them on its travel axis (a soft stop \u2014 no slam), then\r\n///    re-aims down the other axis and charges again. The result is a menacing staircase pursuit that\r\n///    corners toward you instead of trundling past to the far wall. It only brakes when re-aiming would\r\n///    actually give it a useful, unblocked turn toward you \u2014 if the way it wants to turn is already\r\n///    walled off it keeps charging (braking there would just stall it against a wall).\r\n///  \u2022 Brake points are landed on EXACTLY: each tick\u0027s movement is clamped to the computed stop (see\r\n///    \u003Csee cref=\u0022ClampToTurnBrakePoint\u0022/\u003E), so even a PERFECTLY FLUSH turn \u2014 a corridor mouth exactly\r\n///    the block\u0027s width, the standard junction of tunnel levels like maze-1 \u2014 can be taken. Crossing\r\n///    such an opening mid-leg also brakes a boxed-in fallback leg, letting the stalk corner down\r\n///    tunnels it could never line up with by per-tick sampling.\r\n///\r\n/// Phase 2 escalates the same behaviour: faster acceleration/top speed and a much shorter recovery pause.\r\n/// When neither direct pursuit lane is open, it also searches the traversable arena for a route around\r\n/// obstacles and other blocks, follows that route to its first corner, then re-plans. Collision,\r\n/// side-pressing and phasing are all the default block behaviour.\r\n/// \u003C/summary\u003E\r\npublic sealed class BlockHunter : Block\r\n{\r\n\t// Whether the current heading was chosen to close on the player (vs a boxed-in fallback). Only a\r\n\t// pursuit leg brakes mid-lane; a fallback leg runs until it slams something (see TickStalk) \u2014\r\n\t// unless the movement clamp lands it exactly on a genuine turn opening (see _alignedOnBrakePoint).\r\n\tbool _pursuing;\r\n\tbool _followingPath;\r\n\tbool _reachedPathWaypoint;\r\n\tVector2 _pathWaypoint;\r\n\r\n\t// Set by ClampToTurnBrakePoint when this tick\u0027s movement was cut short to land EXACTLY on a\r\n\t// computed brake point (the level point, a window far edge, or a flush corridor mouth). TickStalk\r\n\t// consumes it and brakes there \u2014 bypassing the level-point wait logic, and (uniquely) allowed on\r\n\t// fallback legs, which otherwise never stalk-brake.\r\n\tbool _alignedOnBrakePoint;\r\n\t// An aligned landing that failed TickStalk\u0027s live verify (pixel-grid contact the analytic sweep\r\n\t// can\u0027t see) suppresses the clamp for one tick so the block keeps moving instead of freezing in\r\n\t// place re-clamping to the same rejected spot.\r\n\tbool _alignedBrakeFailed;\r\n\t// Where the current leg began (captured at re-pick) \u2014 gates the per-tick early-brake fall-through\r\n\t// on real progress, see STALK_BRAKE_MIN_LEG_TRAVEL.\r\n\tVector2 _legStartPos;\r\n\t// The turn a stalk brake stopped FOR. Honored by the very next re-pick (then cleared) ahead of the\r\n\t// distance ranking: the ranking alone prefers whichever axis closes more raw distance, and at a\r\n\t// corridor mouth that\u0027s often the horizontal run back across a walled platform \u2014 so the block\r\n\t// ping-pongs between a platform\u0027s two mouths forever instead of ascending through one.\r\n\tDirection _brakeTurnIntent;\r\n\r\n\t// Phase-2 pathfinding runs only when the two direct pursuit lanes fail. Blocks tick sequentially and\r\n\t// each search completes synchronously, so all Hunters share one workspace instead of allocating three\r\n\t// large-object-heap arrays per instance. The queue is reused to reconstruct the path after BFS ends.\r\n\tconst int PATH_NODE_COUNT = Arena.WIDTH * Arena.HEIGHT;\r\n\tstatic readonly bool[] _pathBlocked = new bool[PATH_NODE_COUNT];\r\n\tstatic readonly int[] _pathParent = new int[PATH_NODE_COUNT];\r\n\tstatic readonly int[] _pathQueue = new int[PATH_NODE_COUNT];\r\n\r\n\t// Scratch for the per-tick turn-lane sweep (see FindTurnBrakePoint). Shared for the same reason as\r\n\t// the pathfinding workspace above: blocks tick sequentially and each sweep completes within the call.\r\n\tstatic readonly List\u003C(float lo, float hi)\u003E _laneBlocked = new();\r\n\tstatic int CompareLaneLo( (float lo, float hi) a, (float lo, float hi) b ) =\u003E a.lo.CompareTo( b.lo ); // named, not a static lambda: hotload-safe\r\n\r\n\tconst float BRAKE_SHAKE_STRENGTH = 0.008f; // skid lurch on a brake (half the slam SHAKE_STRENGTH)\r\n\tconst float BRAKE_DUST_SPEED = 30f;        // how fast the little skid puff scatters\r\n\r\n\t// A direction only counts as \u0022pursuing\u0022 when it closes at least this much distance \u2014 a degenerate\r\n\t// half-pixel gain isn\u0027t worth a brake (and used to cause brake-oscillation right next to the player).\r\n\tconst float MIN_PURSUE_DIST = 2f;\r\n\t// A fallback leg should have at least this much running room before it grinds something; a direction\r\n\t// with less is only taken when nothing better exists (see PickHuntDirection).\r\n\tconst float MIN_FALLBACK_RUN = 12f;\r\n\t// How close (along the travel axis) the block must be before it will take an EARLY brake \u2014 dropping\r\n\t// on the approach side of the level point when the level-point drop will be blocked (player tucked\r\n\t// into a valley). Keeps a distant clear column from triggering a premature, silly-looking stop.\r\n\tconst float EARLY_BRAKE_RANGE = 60f;\r\n\t// A clear cross-axis window no wider than this reads as a corridor mouth (a flush or near-flush\r\n\t// junction). It\u0027s the only turn a tunnel level will ever offer, so it\u0027s taken at ANY range \u2014 wide\r\n\t// windows stay subject to EARLY_BRAKE_RANGE so distant open ground doesn\u0027t cause premature stops.\r\n\tconst float FLUSH_TURN_WINDOW_MAX = 2f;\r\n\t// The per-tick early-brake fall-through (window closing at/behind us) only fires after the leg has\r\n\t// really moved this far. At leg start (speed \u2248 0, 1px lookahead) a junction mouth reads as \u0022turn\r\n\t// now or never\u0022, and braking at zero distance re-litigates the pick that just started the leg \u2014\r\n\t// ping-ponging the re-pick between the two axes forever. Windows genuinely AHEAD are exempt: the\r\n\t// movement clamp lands on those exactly regardless of leg progress.\r\n\tconst float STALK_BRAKE_MIN_LEG_TRAVEL = 2f;\r\n\r\n\tprotected override void OnSetup()\r\n\t{\r\n\t\t// Phase 0 stays at the default block profile; phase 1 accelerates harder (\u0022a bit more speed\u0022),\r\n\t\t// phase 2 is faster still. High acceleration matters more than top speed here because the stalk\r\n\t\t// makes short legs \u2014 the block lunges in quick bursts rather than long cruises.\r\n\t\t// (Base defaults: accel {50,55,60}, maxSpeed {220,230,240}.)\r\n\t\t_accelerations = new[] { 50f, 90f, 150f };\r\n\t\t_maxSpeeds = new[] { 220f, 245f, 300f };\r\n\t}\r\n\r\n\t// Eager recovery: shorter eyes-shut pause than a normal block so the stalk stays snappy; phase 2\r\n\t// re-aims and lunges again almost immediately. (Base range is 0.28\u20130.60s; see Block.EyesStayClosedTime.)\r\n\tprotected override float EyesStayClosedTime() =\u003E Phase switch\r\n\t{\r\n\t\t2 =\u003E Rng.Float( 0.03f, 0.09f ),\r\n\t\t1 =\u003E Rng.Float( 0.18f, 0.34f ),\r\n\t\t_ =\u003E base.EyesStayClosedTime(),\r\n\t};\r\n\r\n\tpublic override void Tick( float dt )\r\n\t{\r\n\t\tbase.Tick( dt );\r\n\t\tTickStalk( dt );\r\n\t}\r\n\r\n\tprotected override Vector2 GetMovementDelta( float dt )\r\n\t{\r\n\t\tVector2 movement = base.GetMovementDelta( dt );\r\n\t\tif ( Phase \u003C 2 || !_followingPath ) return ClampToTurnBrakePoint( movement );\r\n\r\n\t\tVector2 travel = Globals.GetVectorForDirection( MoveDirection );\r\n\t\tfloat remaining = Vector2.Dot( _pathWaypoint - Pos, travel );\r\n\t\tfloat step = Vector2.Dot( movement, travel );\r\n\t\tif ( remaining \u003E step ) return movement;\r\n\r\n\t\t// Stop the shared movement pipeline at the exact corner so collision resolution and\r\n\t\t// StepDisplacement both observe the distance actually travelled this tick.\r\n\t\t_reachedPathWaypoint = true;\r\n\t\treturn travel * MathF.Max( remaining, 0f );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhile charging toward the player, brake where turning toward them (the closing direction\r\n\t/// on the cross axis) is actually possible:\r\n\t///\r\n\t///  \u2022 Normally at the LEVEL point \u2014 drawn level with the player on our travel axis, continuing would\r\n\t///    only increase the distance \u2014 provided the turn is genuinely open: not immediately touching\r\n\t///    anything, and its whole lane clear to its own brake point. If it isn\u0027t (our body still\r\n\t///    half-overhangs the obstacle they\u0027re hiding beside), keep charging until it clears and take the\r\n\t///    drop on the FAR side.\r\n\t///  \u2022 EARLY, on the approach side, when the drop is clear HERE but will be BLOCKED at the level point\r\n\t///    \u2014 the player tucked into a valley narrower than our drop can reach past (e.g. pressed against\r\n\t///    the valley wall we\u0027re approaching over): the level-point column overlaps that wall forever, so\r\n\t///    holding out for it sails past the whole valley. We ride the clear window to its FAR edge (every\r\n\t///    pixel closes distance \u2014 braking on entry would stop short of the player) and brake on the last\r\n\t///    clear column, just before the next tick\u0027s travel would exit it. Range-limited so a distant clear\r\n\t///    column doesn\u0027t cause a premature stop.\r\n\t///\r\n\t/// Only runs on a pursuit leg (a boxed-in fallback leg runs to its wall as normal) \u2014 EXCEPT when\r\n\t/// the movement clamp landed the block exactly on a computed brake point this tick (see\r\n\t/// \u003Csee cref=\u0022ClampToTurnBrakePoint\u0022/\u003E): that landing is a verified full-lane turn opening, so it\r\n\t/// brakes any leg, and skips the level-point wait logic (the clamp already chose the stop).\u003C/summary\u003E\r\n\tvoid TickStalk( float dt )\r\n\t{\r\n\t\t// Consume the movement clamp\u0027s \u0022landed exactly on a brake point\u0022 flag before ANY early-out so\r\n\t\t// it can never go stale and fire on a later, unrelated tick.\r\n\t\tbool aligned = _alignedOnBrakePoint;\r\n\t\t_alignedOnBrakePoint = false;\r\n\r\n\t\t// A CW/CCW hunter is a plain cycling block: no stalk braking. Cycle modes own the whole\r\n\t\t// direction pick (Block.PickCycleDirection \u2014 GetNewDirection/PickHuntDirection never run), so\r\n\t\t// a brake here would pause the charge at every corridor mouth only for the re-pick to resume\r\n\t\t// the same heading, and _brakeTurnIntent would be set but never consumed. No shipped level\r\n\t\t// authors this combination, but the player-facing editor can.\r\n\t\tif ( IsCycleTurnMode ) return;\r\n\r\n\t\t// Only while freely charging: !IsStopped means eyes open and moving (the whole eye-cycle holds\r\n\t\t// IsStopped true). Phase 1\u002B and a real player only.\r\n\t\tif ( Phase \u003C 1 || IsStopped || IsDead ) return;\r\n\r\n\t\tif ( Phase \u003E= 2 \u0026\u0026 _followingPath )\r\n\t\t{\r\n\t\t\tif ( _reachedPathWaypoint )\r\n\t\t\t{\r\n\t\t\t\t_followingPath = false;\r\n\t\t\t\t_reachedPathWaypoint = false;\r\n\t\t\t\tEmitBrakeEffects();\r\n\t\t\t\tSoftStopAndRepick();\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// A fallback leg normally runs until it slams something \u2014 but an aligned landing means the\r\n\t\t// movement clamp found a genuine, full-lane opening toward the player mid-leg (a maze junction\r\n\t\t// crossed at speed), which is exactly when a stalker should turn.\r\n\t\tif ( !_pursuing \u0026\u0026 !aligned ) return;\r\n\r\n\t\tvar player = Stage?.ClosestTargetablePlayer( Position );\r\n\t\tif ( player == null ) return;\r\n\r\n\t\tVector2 dirVec = Globals.GetVectorForDirection( MoveDirection );\r\n\t\tVector2 delta = player.Pos - Pos;\r\n\t\t// How far the player is still AHEAD along our travel axis. Positive while closing; zero/negative\r\n\t\t// means we\u0027ve drawn level and any further travel recedes.\r\n\t\tfloat along = Vector2.Dot( delta, dirVec );\r\n\r\n\t\t// The turn we want is the closing direction on the CROSS axis. Nothing to do if turning gains\r\n\t\t// nothing (player basically on our line) or the turn isn\u0027t open right here.\r\n\t\tbool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;\r\n\t\tDirection turn = horizontal\r\n\t\t\t? (delta.y \u003E= 0f ? Direction.Up : Direction.Down)\r\n\t\t\t: (delta.x \u003E= 0f ? Direction.Right : Direction.Left);\r\n\t\tfloat turnDist = Vector2.Dot( delta, Globals.GetVectorForDirection( turn ) );\r\n\r\n\t\tif ( turnDist \u003C MIN_PURSUE_DIST ) return;\r\n\t\tif ( IsColliding( turn ) || !IsPursuitLaneClear( turn, turnDist ) )\r\n\t\t{\r\n\t\t\t// An aligned landing the live checks reject (pixel-grid contact the analytic sweep can\u0027t\r\n\t\t\t// see) must not be re-clamped to next tick \u2014 that would freeze the block on the spot.\r\n\t\t\t_alignedBrakeFailed = aligned;\r\n\t\t\treturn; // blocked here \u2014 keep charging\r\n\t\t}\r\n\r\n\t\tif ( along \u003E 0f \u0026\u0026 !aligned )\r\n\t\t{\r\n\t\t\t// Still closing, and the turn is open HERE. Hold out for the level point if the drop will\r\n\t\t\t// still be open there; but if it won\u0027t be (valley case), the approach-side clear window we\u0027re\r\n\t\t\t// in is our chance. The level point sits \u0060along\u0060 ahead on our travel axis (turnDist is\r\n\t\t\t// unchanged there since the turn is perpendicular).\r\n\t\t\tif ( along \u003E EARLY_BRAKE_RANGE ) return; // too far out to justify an early stop\r\n\t\t\tVector2 levelPos = Pos \u002B dirVec * along;\r\n\t\t\tif ( IsPursuitLaneClear( turn, turnDist, levelPos ) ) return; // level drop will be open \u2014 wait for it\r\n\r\n\t\t\t// Ride the clear window to its FAR edge \u2014 every pixel of it closes distance, so braking on\r\n\t\t\t// entry would stop short of the player. Brake on the last clear column: only when the next\r\n\t\t\t// tick\u0027s travel would carry us out of the window (or past the level point, which we know is\r\n\t\t\t// blocked).\r\n\t\t\tfloat step = _moveSpeed * dt \u002B 1f;\r\n\t\t\tif ( step \u003C along \u0026\u0026 IsPursuitLaneClear( turn, turnDist, Pos \u002B dirVec * step ) )\r\n\t\t\t\treturn; // still clear a tick ahead \u2014 keep closing\r\n\r\n\t\t\t// The window closes at/behind us \u2014 but only brake once this leg has genuinely moved.\r\n\t\t\t// Braking at zero distance re-litigates the pick that just started the leg, and at a\r\n\t\t\t// junction whose both corridors close on the player that ping-pongs the re-pick between\r\n\t\t\t// the two axes forever (the pick can never re-choose the current heading). A window\r\n\t\t\t// strictly AHEAD is unaffected: ClampToTurnBrakePoint lands on it exactly either way.\r\n\t\t\tif ( (Pos - _legStartPos).LengthSquared \u003C STALK_BRAKE_MIN_LEG_TRAVEL * STALK_BRAKE_MIN_LEG_TRAVEL )\r\n\t\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_brakeTurnIntent = turn; // this brake exists to take this turn \u2014 honored by the next re-pick\r\n\t\tEmitBrakeEffects();\r\n\t\tSoftStopAndRepick();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EMovement-side half of the stalk: cut this tick\u0027s travel so the block lands EXACTLY on\r\n\t/// the spot TickStalk wants to brake on, instead of sampling past it. Per-tick positions are\r\n\t/// quantized by speed, so on its own TickStalk can only brake where a tick happens to land \u2014 and a\r\n\t/// PERFECTLY FLUSH turn (a corridor mouth exactly the block\u0027s width, the standard junction of\r\n\t/// tunnel levels like maze-1) is a single exact coordinate no sampled position ever hits. Instead\r\n\t/// the turn lane is swept analytically (see \u003Csee cref=\u0022FindTurnBrakePoint\u0022/\u003E) and the movement is\r\n\t/// clamped to the chosen brake point; the landing is flagged via \u003Csee cref=\u0022_alignedOnBrakePoint\u0022/\u003E\r\n\t/// so TickStalk re-verifies and brakes there. Runs on FALLBACK legs too: a fallback leg can\u0027t stalk\r\n\t/// per-tick (its turn was blocked at pick time), but a genuine full-lane opening toward the player\r\n\t/// crossed mid-leg \u2014 a maze junction \u2014 is exactly when a stalker should turn.\u003C/summary\u003E\r\n\t// Cycle modes route every re-pick through Block.PickCycleDirection, bypassing this class\u0027s\r\n\t// GetNewDirection/PickHuntDirection entirely \u2014 the stalk-brake machinery has no consumer.\r\n\tbool IsCycleTurnMode =\u003E TurnMode is TurnMode.Clockwise or TurnMode.CounterClockwise;\r\n\r\n\tVector2 ClampToTurnBrakePoint( Vector2 movement )\r\n\t{\r\n\t\tif ( Phase \u003C 1 || IsDead || IsCycleTurnMode ) return movement;\r\n\t\tif ( _alignedBrakeFailed ) { _alignedBrakeFailed = false; return movement; }\r\n\r\n\t\tvar player = Stage?.ClosestTargetablePlayer( Position );\r\n\t\tif ( player == null ) return movement;\r\n\r\n\t\tVector2 travel = Globals.GetVectorForDirection( MoveDirection );\r\n\t\tfloat step = Vector2.Dot( movement, travel );\r\n\t\tif ( step \u003C= 0f ) return movement;\r\n\r\n\t\tVector2 delta = player.Pos - Pos;\r\n\t\tfloat along = Vector2.Dot( delta, travel );\r\n\r\n\t\t// Same turn selection as TickStalk: the closing direction on the cross axis.\r\n\t\tbool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;\r\n\t\tDirection turn = horizontal\r\n\t\t\t? (delta.y \u003E= 0f ? Direction.Up : Direction.Down)\r\n\t\t\t: (delta.x \u003E= 0f ? Direction.Right : Direction.Left);\r\n\t\tfloat turnDist = Vector2.Dot( delta, Globals.GetVectorForDirection( turn ) );\r\n\r\n\t\t// Only clamp for a turn whose lane actually extends past our own footprint (this subsumes the\r\n\t\t// MIN_PURSUE_DIST check). When the player\u0027s center-line is within our cross-extent the charge\r\n\t\t// we\u0027re already on reaches them without any turn \u2014 and the sweep\u0027s 1px minimum lane would read\r\n\t\t// the corridor we\u0027re travelling inside as a wall of degenerate \u0022windows\u0022, braking the block a\r\n\t\t// hair sideways at every junction and ping-ponging its re-picks between the two axes.\r\n\t\tfloat halfTurnExtent = horizontal ? Height / 2f : Width / 2f;\r\n\t\tif ( turnDist - halfTurnExtent \u003C 1f ) return movement;\r\n\r\n\t\tfloat target = FindTurnBrakePoint( travel, turn, step, along, turnDist );\r\n\t\tif ( target \u003C 0f ) return movement;\r\n\r\n\t\t_alignedOnBrakePoint = true;\r\n\t\treturn travel * target;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhere along this tick\u0027s travel (0..step) should the block stop to take the cross-axis\r\n\t/// turn toward the player? Returns -1 when nowhere this tick. Mirrors TickStalk\u0027s per-tick brake\r\n\t/// rules, computed on exact geometry so brake spots BETWEEN sampled positions are hit:\r\n\t///\r\n\t///  \u2022 the LEVEL POINT, when it\u0027s inside this tick\u0027s travel and the turn is open there;\r\n\t///  \u2022 the FAR EDGE of a clear window before the level point when the level drop is blocked (the\r\n\t///    valley early-brake) \u2014 range-limited for wide windows, but a flush window\r\n\t///    (\u2264 \u003Csee cref=\u0022FLUSH_TURN_WINDOW_MAX\u0022/\u003E, a corridor mouth) is taken at ANY range, since a\r\n\t///    tunnel level never offers anything wider;\r\n\t///  \u2022 past the level point, the FIRST clear coordinate reachable (the far-side drop).\r\n\t///\r\n\t/// All spans use the same strict open-interval overlap as \u003Csee cref=\u0022IsLaneClear\u0022/\u003E, so a lane that\r\n\t/// merely touches a wall is CLEAR and the flush coordinate between two junction walls survives as a\r\n\t/// zero-width window. Edges are taken straight off solid rects in absolute coordinates, so for\r\n\t/// pixel-aligned geometry the clamped landing is exactly flush and TickStalk\u0027s live re-verify\r\n\t/// agrees with this sweep.\u003C/summary\u003E\r\n\tfloat FindTurnBrakePoint( Vector2 travel, Direction turn, float step, float along, float turnDist )\r\n\t{\r\n\t\tVector2 turnVec = Globals.GetVectorForDirection( turn );\r\n\t\tfloat posV = Vector2.Dot( Pos, travel );   // signed travel-axis coordinate\r\n\t\tfloat posT = Vector2.Dot( Pos, turnVec );  // signed turn-axis coordinate\r\n\t\tbool horizontal = travel.y == 0f;\r\n\t\tfloat halfV = horizontal ? Width / 2f : Height / 2f;\r\n\t\tfloat halfT = horizontal ? Height / 2f : Width / 2f;\r\n\r\n\t\t// The lane the turn needs: the block\u0027s own extent plus its pursuit run toward the player\u0027s\r\n\t\t// center-line (same shortening as IsPursuitLaneClear), at least 1px so a solid resting flush\r\n\t\t// against the turn face still registers (the analytic mirror of TickStalk\u0027s IsColliding probe).\r\n\t\tfloat laneLo = posT - halfT;\r\n\t\tfloat laneHi = posT \u002B halfT \u002B MathF.Max( turnDist - halfT, 1f );\r\n\t\tfloat reach = posV \u002B step;   // travel-coord at the end of an unclamped tick\r\n\t\tfloat level = posV \u002B along;  // travel-coord where we\u0027d draw level with the player\r\n\r\n\t\t_laneBlocked.Clear();\r\n\t\tforeach ( Entity2D ob in Stage.GetBlockSolidObstacles() )\r\n\t\t\tAddLaneBlocker( ob.GetRect(), travel, turnVec, laneLo, laneHi, halfV, posV, reach );\r\n\t\tforeach ( Block b in Stage.GetBlocks() )\r\n\t\t\tif ( b != this )\r\n\t\t\t\tAddLaneBlocker( b.GetRect(), travel, turnVec, laneLo, laneHi, halfV, posV, reach );\r\n\r\n\t\t// The level point itself, when reachable and open, is always the preferred stop.\r\n\t\tif ( along \u003E 0f \u0026\u0026 along \u003C= step \u0026\u0026 IsTravelCoordClear( level ) )\r\n\t\t\treturn along;\r\n\r\n\t\tif ( _laneBlocked.Count \u003E 1 )\r\n\t\t\t_laneBlocked.Sort( CompareLaneLo );\r\n\r\n\t\tbool? levelOpen = null; // lazy: \u0022would the drop be open at the level point?\u0022\r\n\t\tbool LevelOpen()\r\n\t\t{\r\n\t\t\tlevelOpen ??= along \u003C= step\r\n\t\t\t\t? IsTravelCoordClear( level )\r\n\t\t\t\t: IsPursuitLaneClear( turn, turnDist, Pos \u002B travel * along );\r\n\t\t\treturn levelOpen.Value;\r\n\t\t}\r\n\r\n\t\t// Walk the clear windows between blocked spans. Both are OPEN intervals, so the single\r\n\t\t// coordinate between two touching spans survives as a zero-width window \u2014 the flush mouth.\r\n\t\t//\r\n\t\t// Every brake point must lie STRICTLY AHEAD (\u003E posV). The spot we\u0027re standing on was already\r\n\t\t// weighed by the pick that started this leg (and per-tick TickStalk covers it on pursuit legs);\r\n\t\t// braking there at zero distance re-litigates that choice every leg start. At a junction whose\r\n\t\t// BOTH corridors close on the player (T/crossroads, player diagonal) each axis sees a flush\r\n\t\t// window exactly at s=0, so zero-distance brakes ping-pong the re-pick between the two axes\r\n\t\t// forever \u2014 close eyes, open one way, repeat the other way, never moving.\r\n\t\tfloat prevHi = float.NegativeInfinity;\r\n\t\tfor ( int i = 0; i \u003C= _laneBlocked.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat w0 = prevHi;\r\n\t\t\tfloat w1 = i \u003C _laneBlocked.Count ? _laneBlocked[i].lo : float.PositiveInfinity;\r\n\t\t\tif ( i \u003C _laneBlocked.Count )\r\n\t\t\t\tprevHi = MathF.Max( prevHi, _laneBlocked[i].hi );\r\n\t\t\tif ( w1 \u003C w0 ) continue; // spans overlap \u2014 no window between them\r\n\r\n\t\t\tif ( along \u003E 0f \u0026\u0026 w1 \u003C= level )\r\n\t\t\t{\r\n\t\t\t\t// Approaching the level point: an early window is only taken when the level drop is\r\n\t\t\t\t// blocked (valley/maze case), riding it to its FAR edge \u2014 and a wide one only near the\r\n\t\t\t\t// player, while a flush corridor mouth is taken at any range.\r\n\t\t\t\tif ( w1 \u003C= posV || w1 \u003E reach ) continue;\r\n\t\t\t\tif ( LevelOpen() ) continue; // the level stop is available \u2014 hold out for it\r\n\t\t\t\tif ( w1 - w0 \u003E FLUSH_TURN_WINDOW_MAX \u0026\u0026 level - w1 \u003E EARLY_BRAKE_RANGE ) continue;\r\n\t\t\t\treturn w1 - posV;\r\n\t\t\t}\r\n\r\n\t\t\tif ( along \u003C= 0f || w0 \u003E= level )\r\n\t\t\t{\r\n\t\t\t\t// At or past the level point every further pixel recedes: stop on the window\u0027s first\r\n\t\t\t\t// clear coordinate \u2014 provided it\u0027s genuinely ahead of us.\r\n\t\t\t\tif ( w0 \u003C= posV || w0 \u003E reach ) continue;\r\n\t\t\t\treturn w0 - posV;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn -1f;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EIf \u003Cparamref name=\u0022solid\u0022/\u003E pokes into the turn lane (strict overlap on the turn axis \u2014\r\n\t/// touching is clear, matching RectF.Intersects), record the OPEN span of travel-axis coordinates\r\n\t/// where the block\u0027s body would overlap it. Spans that can\u0027t affect this tick are dropped.\u003C/summary\u003E\r\n\tstatic void AddLaneBlocker( RectF solid, Vector2 travel, Vector2 turnVec,\r\n\t\tfloat laneLo, float laneHi, float halfV, float posV, float reach )\r\n\t{\r\n\t\tVector2 center = new Vector2( (solid.Left \u002B solid.Right) * 0.5f, (solid.Bottom \u002B solid.Top) * 0.5f );\r\n\t\tfloat centerT = Vector2.Dot( center, turnVec );\r\n\t\tfloat halfSolidT = turnVec.y == 0f ? solid.Width * 0.5f : solid.Height * 0.5f;\r\n\t\tif ( centerT \u002B halfSolidT \u003C= laneLo || centerT - halfSolidT \u003E= laneHi ) return;\r\n\r\n\t\tfloat centerV = Vector2.Dot( center, travel );\r\n\t\tfloat halfSolidV = travel.y == 0f ? solid.Width * 0.5f : solid.Height * 0.5f;\r\n\t\tfloat lo = centerV - (halfV \u002B halfSolidV);\r\n\t\tfloat hi = centerV \u002B (halfV \u002B halfSolidV);\r\n\t\tif ( hi \u003C= posV - 1f || lo \u003E= reach \u002B 1f ) return;\r\n\t\t_laneBlocked.Add( (lo, hi) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EIs this travel-axis coordinate outside every blocked span collected for the turn lane?\r\n\t/// Spans are open, so an exactly-flush coordinate (touching walls on both sides) is clear.\u003C/summary\u003E\r\n\tstatic bool IsTravelCoordClear( float coord )\r\n\t{\r\n\t\tforeach ( (float lo, float hi) in _laneBlocked )\r\n\t\t\tif ( lo \u003C coord \u0026\u0026 coord \u003C hi )\r\n\t\t\t\treturn false;\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EA subtle skid tell when the block brakes to change course: a small lurch shake plus a\r\n\t/// little puff of dust off the leading edge. Deterministic sim event (a function of block/player\r\n\t/// positions), so it draws from the authoritative Rng exactly like the slam dust in ImpactEffects and\r\n\t/// reproduces on replay.\u003C/summary\u003E\r\n\tvoid EmitBrakeEffects()\r\n\t{\r\n\t\tVector2 dirVec = Globals.GetVectorForDirection( MoveDirection );\r\n\t\tVector2 perp = new Vector2( -dirVec.y, dirVec.x ); // unit perpendicular to travel\r\n\r\n\t\t// Small lurch backward against the direction of travel (subtler than a real slam).\r\n\t\tAddShake( -dirVec * _moveSpeed * BRAKE_SHAKE_STRENGTH );\r\n\r\n\t\t// A few dust motes scattering off the leading edge \u2014 the block digging in to change course.\r\n\t\tVector2 edge = Position \u002B dirVec * (Width / 2f);\r\n\t\tint n = Rng.Int( 2, 4 );\r\n\t\tfor ( int i = 0; i \u003C n; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tVector2 ppos = edge \u002B perp * Rng.Float( -Height / 3f, Height / 3f );\r\n\t\t\tVector2 vel = perp * (Rng.Float( -1f, 1f ) * BRAKE_DUST_SPEED)   // scatter sideways\r\n\t\t\t\t- dirVec * Rng.Float( 0f, BRAKE_DUST_SPEED * 0.5f );          // and a touch backward\r\n\t\t\tStage.AddParticle( ppos, vel, Rng.Float( 0.88f, 0.94f ), Globals.GRAVITY_STR_DUST,\r\n\t\t\t\tParticleKind.Dust, Rng.Float( 0.25f, 0.5f ), Rng.Int( 2, 4 ) );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETurn toward the player: honor the turn the last stalk brake stopped for\r\n\t/// (\u003Csee cref=\u0022_brakeTurnIntent\u0022/\u003E) when it\u0027s still a valid pursuit, then rank the four axis\r\n\t/// directions by how much each closes the straight-line distance to the player (best \u2192 worst).\r\n\t/// Prefer the best direction whose whole lane is\r\n\t/// clear all the way to where it would brake (see \u003Csee cref=\u0022IsLaneClear\u0022/\u003E) \u2014 so it won\u0027t aim down a\r\n\t/// path an obstacle juts into. If no clear pursuit lane exists, fall back to the best non-walled\r\n\t/// direction so it never wedges. Returns \u003Csee cref=\u0022Direction.None\u0022/\u003E only when fully boxed in.\r\n\t/// \u003Cparamref name=\u0022pursuing\u0022/\u003E is true only for a genuine, lane-clear closing direction.\u003C/summary\u003E\r\n\tDirection PickHuntDirection( out bool pursuing )\r\n\t{\r\n\t\tpursuing = false;\r\n\t\t_followingPath = false;\r\n\t\t_reachedPathWaypoint = false;\r\n\t\t_alignedBrakeFailed = false;\r\n\t\tDirection intent = _brakeTurnIntent;\r\n\t\t_brakeTurnIntent = Direction.None;\r\n\t\tvar player = Stage?.ClosestTargetablePlayer( Position );\r\n\t\tif ( player == null ) return Direction.None;\r\n\r\n\t\tVector2 delta = player.Pos - Pos;\r\n\r\n\t\t// A stalk brake pulls up FOR a specific turn (we stopped exactly where that turn is open).\r\n\t\t// Honor it before the distance ranking, re-verifying against the live world: from a corridor\r\n\t\t// mouth the other axis often still closes MORE raw distance (the far mouth of a walled\r\n\t\t// platform), so ranking alone re-picks the horizontal run and ping-pongs between the two\r\n\t\t// mouths without ever taking the opening we braked for.\r\n\t\tif ( intent != Direction.None \u0026\u0026 intent != MoveDirection \u0026\u0026 !IsColliding( intent )\r\n\t\t\t\u0026\u0026 IsNewDirectionAllowed( MoveDirection, intent ) )\r\n\t\t{\r\n\t\t\tfloat intentAlong = Vector2.Dot( delta, Globals.GetVectorForDirection( intent ) );\r\n\t\t\tif ( intentAlong \u003E= MIN_PURSUE_DIST \u0026\u0026 IsPursuitLaneClear( intent, intentAlong ) )\r\n\t\t\t{\r\n\t\t\t\tpursuing = true;\r\n\t\t\t\treturn intent;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// The closing (toward-player) direction on each axis. \u002BX = right, \u002BY = up (see Globals).\r\n\t\tDirection horiz = delta.x \u003E= 0f ? Direction.Right : Direction.Left;\r\n\t\tDirection vert = delta.y \u003E= 0f ? Direction.Up : Direction.Down;\r\n\r\n\t\t// Prefer the axis with the greater distance to cover; the two receding directions rank last, so\r\n\t\t// this is a full best\u2192worst ordering of all four directions.\r\n\t\tbool horizFirst = MathF.Abs( delta.x ) \u003E= MathF.Abs( delta.y );\r\n\t\tDirection first = horizFirst ? horiz : vert;\r\n\t\tDirection second = horizFirst ? vert : horiz;\r\n\r\n\t\tDirection[] ranked =\r\n\t\t{\r\n\t\t\tfirst,\r\n\t\t\tsecond,\r\n\t\t\tGlobals.GetOppositeDirection( second ),\r\n\t\t\tGlobals.GetOppositeDirection( first ),\r\n\t\t};\r\n\r\n\t\tDirection fallback = Direction.None;      // best non-walled dir with real running room\r\n\t\tDirection lastResort = Direction.None;    // best non-walled dir at all (may grind immediately)\r\n\t\tforeach ( var dir in ranked )\r\n\t\t{\r\n\t\t\tif ( dir == MoveDirection || IsColliding( dir ) || !IsNewDirectionAllowed( MoveDirection, dir ) )\r\n\t\t\t\tcontinue; // current heading, or immediately walled \u2014 never a candidate\r\n\r\n\t\t\t// A pursuit direction meaningfully closes distance; take the best one whose lane is clear all\r\n\t\t\t// the way to the brake point (so an obstacle part-way down the lane rules it out) AND whose\r\n\t\t\t// closing turn can actually open somewhere along the leg (see PursuitCanCorner) \u2014 a clear\r\n\t\t\t// corridor under the player\u0027s platform stalks nothing.\r\n\t\t\tfloat along = Vector2.Dot( delta, Globals.GetVectorForDirection( dir ) );\r\n\t\t\tif ( along \u003E= MIN_PURSUE_DIST \u0026\u0026 IsPursuitLaneClear( dir, along ) \u0026\u0026 PursuitCanCorner( dir, delta, along ) )\r\n\t\t\t{\r\n\t\t\t\tpursuing = true;\r\n\t\t\t\treturn dir;\r\n\t\t\t}\r\n\r\n\t\t\t// Otherwise a fallback (runs to its wall, doesn\u0027t stalk) \u2014 but prefer one with actual running\r\n\t\t\t// room over one that grinds an obstacle a pixel away (the \u0022immediately stopped\u0022 pick).\r\n\t\t\tif ( fallback == Direction.None \u0026\u0026 IsLaneClear( dir, MIN_FALLBACK_RUN, includeWalls: true ) )\r\n\t\t\t\tfallback = dir;\r\n\t\t\tif ( lastResort == Direction.None )\r\n\t\t\t\tlastResort = dir;\r\n\t\t}\r\n\r\n\t\t// Phase 1 deliberately keeps the simpler direct stalk. At phase 2, use a shortest cardinal route\r\n\t\t// when level geometry or another block cuts off both player-closing lanes. The waypoint is the\r\n\t\t// route\u0027s first corner, where TickStalk brakes and asks for a fresh route against moving blockers.\r\n\t\tif ( Phase \u003E= 2 \u0026\u0026 TryFindPathToPlayer( player, out Direction pathDirection, out Vector2 waypoint )\r\n\t\t\t\u0026\u0026 IsNewDirectionAllowed( MoveDirection, pathDirection ) )\r\n\t\t{\r\n\t\t\t_followingPath = true;\r\n\t\t\t_pathWaypoint = waypoint;\r\n\t\t\treturn pathDirection;\r\n\t\t}\r\n\r\n\t\treturn fallback != Direction.None ? fallback : lastResort; // Direction.None if fully boxed in\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECan a pursuit leg in \u003Cparamref name=\u0022dir\u0022/\u003E actually corner toward the player somewhere\r\n\t/// along its run? A lane can be clear to the player\u0027s center-line while a solid seals the closing\r\n\t/// turn for the leg\u0027s entire length \u2014 player on top of a long platform, hunter charging beneath it:\r\n\t/// the leg draws level, sails past and slams without ever stalking anything. Worse, such a leg\r\n\t/// outranks the phase-2 route search at every re-pick (including the corner of a route it\u0027s mid-way\r\n\t/// through following), so the hunter shuttles under the platform forever instead of going around.\r\n\t/// Sweeping the turn lane over the whole free run (\u003Csee cref=\u0022FindTurnBrakePoint\u0022/\u003E with the run as\r\n\t/// the tick step) exposes the doomed leg so the pick can demote it.\u003C/summary\u003E\r\n\tbool PursuitCanCorner( Direction dir, Vector2 delta, float along )\r\n\t{\r\n\t\tbool horizontal = dir == Direction.Left || dir == Direction.Right;\r\n\t\tDirection turn = horizontal\r\n\t\t\t? (delta.y \u003E= 0f ? Direction.Up : Direction.Down)\r\n\t\t\t: (delta.x \u003E= 0f ? Direction.Right : Direction.Left);\r\n\t\tfloat turnDist = Vector2.Dot( delta, Globals.GetVectorForDirection( turn ) );\r\n\r\n\t\t// Player\u0027s center-line inside our own cross extent: the charge reaches them with no turn at\r\n\t\t// all (same guard as ClampToTurnBrakePoint, and for the same reason \u2014 the sweep\u0027s 1px minimum\r\n\t\t// lane would misread the corridor we\u0027d travel inside as a wall of degenerate windows).\r\n\t\tfloat halfTurnExtent = horizontal ? Height / 2f : Width / 2f;\r\n\t\tif ( turnDist - halfTurnExtent \u003C 1f ) return true;\r\n\r\n\t\tVector2 travel = Globals.GetVectorForDirection( dir );\r\n\t\treturn FindTurnBrakePoint( travel, turn, FreeRunDistance( dir ), along, turnDist ) \u003E= 0f;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHow far the block\u0027s body can travel in \u003Cparamref name=\u0022dir\u0022/\u003E before contacting the\r\n\t/// arena wall, an interior obstacle or another block \u2014 the full length of the leg a pick would start.\u003C/summary\u003E\r\n\tfloat FreeRunDistance( Direction dir )\r\n\t{\r\n\t\tRectF r = GetRect();\r\n\t\tfloat run = dir switch\r\n\t\t{\r\n\t\t\tDirection.Left =\u003E r.Left - Arena.WALL_SIZE,\r\n\t\t\tDirection.Right =\u003E Arena.WIDTH - Arena.WALL_SIZE - r.Right,\r\n\t\t\tDirection.Down =\u003E r.Bottom - Arena.WALL_SIZE,\r\n\t\t\t_ =\u003E Arena.HEIGHT - Arena.WALL_SIZE - r.Top, // Up\r\n\t\t};\r\n\r\n\t\tforeach ( Entity2D ob in Stage.GetBlockSolidObstacles() )\r\n\t\t\trun = MathF.Min( run, RunToSolid( r, ob.GetRect(), dir ) );\r\n\t\tforeach ( Block b in Stage.GetBlocks() )\r\n\t\t\tif ( b != this )\r\n\t\t\t\trun = MathF.Min( run, RunToSolid( r, b.GetRect(), dir ) );\r\n\t\treturn MathF.Max( run, 0f );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETravel distance in \u003Cparamref name=\u0022dir\u0022/\u003E before \u003Cparamref name=\u0022body\u0022/\u003E hits\r\n\t/// \u003Cparamref name=\u0022solid\u0022/\u003E; \u002B\u221E when the solid isn\u0027t in the travel lane (strict cross overlap \u2014\r\n\t/// touching is clear, matching \u003Csee cref=\u0022RectF.Intersects\u0022/\u003E) or isn\u0027t ahead.\u003C/summary\u003E\r\n\tstatic float RunToSolid( RectF body, RectF solid, Direction dir )\r\n\t{\r\n\t\tbool horizontal = dir == Direction.Left || dir == Direction.Right;\r\n\t\tbool inLane = horizontal\r\n\t\t\t? solid.Bottom \u003C body.Top \u0026\u0026 solid.Top \u003E body.Bottom\r\n\t\t\t: solid.Left \u003C body.Right \u0026\u0026 solid.Right \u003E body.Left;\r\n\t\tif ( !inLane ) return float.PositiveInfinity;\r\n\r\n\t\tfloat gap = dir switch\r\n\t\t{\r\n\t\t\tDirection.Left =\u003E body.Left - solid.Right,\r\n\t\t\tDirection.Right =\u003E solid.Left - body.Right,\r\n\t\t\tDirection.Down =\u003E body.Bottom - solid.Top,\r\n\t\t\t_ =\u003E solid.Bottom - body.Top, // Up\r\n\t\t};\r\n\t\treturn gap \u003E= 0f ? gap : float.PositiveInfinity;\r\n\t}\r\n\r\n\tbool TryFindPathToPlayer( Player player, out Direction direction, out Vector2 waypoint )\r\n\t{\r\n\t\tdirection = Direction.None;\r\n\t\twaypoint = Pos;\r\n\r\n\t\tint minX = (int)MathF.Ceiling( Arena.WALL_SIZE \u002B Width / 2f );\r\n\t\tint maxX = (int)MathF.Floor( Arena.WIDTH - Arena.WALL_SIZE - Width / 2f );\r\n\t\tint minY = (int)MathF.Ceiling( Arena.WALL_SIZE \u002B Height / 2f );\r\n\t\tint maxY = (int)MathF.Floor( Arena.HEIGHT - Arena.WALL_SIZE - Height / 2f );\r\n\t\tint startX = Math.Clamp( (int)MathF.Round( X ), minX, maxX );\r\n\t\tint startY = Math.Clamp( (int)MathF.Round( Y ), minY, maxY );\r\n\t\tint start = PathIndex( startX, startY );\r\n\r\n\t\tArray.Clear( _pathBlocked );\r\n\t\tforeach ( Entity2D obstacle in Stage.GetBlockSolidObstacles() )\r\n\t\t\tMarkPathBlocked( obstacle.GetRect(), minX, maxX, minY, maxY );\r\n\t\tforeach ( Block block in Stage.GetBlocks() )\r\n\t\t\tif ( block != this )\r\n\t\t\t\tMarkPathBlocked( block.GetRect(), minX, maxX, minY, maxY );\r\n\r\n\t\t// A transient shove can leave the rounded start overlapping a moving blocker by a fraction. The\r\n\t\t// real collision solver will separate it; allowing the start node lets the route search escape.\r\n\t\t_pathBlocked[start] = false;\r\n\t\tif ( !HasUnblockedPathGoal( player, minX, maxX, minY, maxY ) ) return false;\r\n\r\n\t\tArray.Fill( _pathParent, -1 );\r\n\t\t_pathParent[start] = start;\r\n\t\tint head = 0, tail = 0;\r\n\t\t_pathQueue[tail\u002B\u002B] = start;\r\n\t\tint goal = -1;\r\n\r\n\t\tVector2 targetDelta = player.Pos - new Vector2( startX, startY );\r\n\t\tDirection horizontal = targetDelta.x \u003E= 0f ? Direction.Right : Direction.Left;\r\n\t\tDirection vertical = targetDelta.y \u003E= 0f ? Direction.Up : Direction.Down;\r\n\t\tbool horizontalFirst = MathF.Abs( targetDelta.x ) \u003E= MathF.Abs( targetDelta.y );\r\n\t\tDirection first = horizontalFirst ? horizontal : vertical;\r\n\t\tDirection second = horizontalFirst ? vertical : horizontal;\r\n\t\tDirection[] searchOrder =\r\n\t\t{\r\n\t\t\tfirst,\r\n\t\t\tsecond,\r\n\t\t\tGlobals.GetOppositeDirection( second ),\r\n\t\t\tGlobals.GetOppositeDirection( first ),\r\n\t\t};\r\n\r\n\t\twhile ( head \u003C tail )\r\n\t\t{\r\n\t\t\tint node = _pathQueue[head\u002B\u002B];\r\n\t\t\tint nodeX = node % Arena.WIDTH;\r\n\t\t\tint nodeY = node / Arena.WIDTH;\r\n\t\t\tif ( PathNodeReachesPlayer( nodeX, nodeY, player ) )\r\n\t\t\t{\r\n\t\t\t\tgoal = node;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tforeach ( Direction candidate in searchOrder )\r\n\t\t\t{\r\n\t\t\t\tVector2 step = Globals.GetVectorForDirection( candidate );\r\n\t\t\t\tint nextX = nodeX \u002B (int)step.x;\r\n\t\t\t\tint nextY = nodeY \u002B (int)step.y;\r\n\t\t\t\tif ( nextX \u003C minX || nextX \u003E maxX || nextY \u003C minY || nextY \u003E maxY ) continue;\r\n\r\n\t\t\t\tint next = PathIndex( nextX, nextY );\r\n\t\t\t\tif ( _pathBlocked[next] || _pathParent[next] != -1 ) continue;\r\n\t\t\t\t_pathParent[next] = node;\r\n\t\t\t\t_pathQueue[tail\u002B\u002B] = next;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( goal == -1 || goal == start ) return false;\r\n\r\n\t\tint pathLength = 0;\r\n\t\tfor ( int node = goal; node != start; node = _pathParent[node] )\r\n\t\t\t_pathQueue[pathLength\u002B\u002B] = node;\r\n\r\n\t\tint firstNode = _pathQueue[pathLength - 1];\r\n\t\tdirection = DirectionBetweenPathNodes( start, firstNode );\r\n\t\tint corner = firstNode;\r\n\t\tfor ( int i = pathLength - 2; i \u003E= 0; i-- )\r\n\t\t{\r\n\t\t\tint next = _pathQueue[i];\r\n\t\t\tif ( DirectionBetweenPathNodes( corner, next ) != direction ) break;\r\n\t\t\tcorner = next;\r\n\t\t}\r\n\r\n\t\twaypoint = new Vector2( corner % Arena.WIDTH, corner / Arena.WIDTH );\r\n\t\treturn direction != Direction.None;\r\n\t}\r\n\r\n\tbool HasUnblockedPathGoal( Player player, int minX, int maxX, int minY, int maxY )\r\n\t{\r\n\t\tfloat reachX = Width / 2f \u002B player.Width / 2f;\r\n\t\tfloat reachY = Height / 2f \u002B player.Height / 2f;\r\n\t\tint goalMinX = Math.Max( minX, (int)MathF.Floor( player.X - reachX ) \u002B 1 );\r\n\t\tint goalMaxX = Math.Min( maxX, (int)MathF.Ceiling( player.X \u002B reachX ) - 1 );\r\n\t\tint goalMinY = Math.Max( minY, (int)MathF.Floor( player.Y - reachY ) \u002B 1 );\r\n\t\tint goalMaxY = Math.Min( maxY, (int)MathF.Ceiling( player.Y \u002B reachY ) - 1 );\r\n\r\n\t\tfor ( int y = goalMinY; y \u003C= goalMaxY; y\u002B\u002B )\r\n\t\t\tfor ( int x = goalMinX; x \u003C= goalMaxX; x\u002B\u002B )\r\n\t\t\t\tif ( !_pathBlocked[PathIndex( x, y )] \u0026\u0026 PathNodeReachesPlayer( x, y, player ) )\r\n\t\t\t\t\treturn true;\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tvoid MarkPathBlocked( RectF solid, int minX, int maxX, int minY, int maxY )\r\n\t{\r\n\t\t// Expand the solid by the Hunter\u0027s half-extents: a grid point is then blocked exactly when a\r\n\t\t// Hunter centered there would overlap the original solid. Touching edges remain traversable.\r\n\t\tint left = Math.Max( minX, (int)MathF.Floor( solid.Left - Width / 2f ) \u002B 1 );\r\n\t\tint right = Math.Min( maxX, (int)MathF.Ceiling( solid.Right \u002B Width / 2f ) - 1 );\r\n\t\tint bottom = Math.Max( minY, (int)MathF.Floor( solid.Bottom - Height / 2f ) \u002B 1 );\r\n\t\tint top = Math.Min( maxY, (int)MathF.Ceiling( solid.Top \u002B Height / 2f ) - 1 );\r\n\t\tfor ( int y = bottom; y \u003C= top; y\u002B\u002B )\r\n\t\t\tfor ( int x = left; x \u003C= right; x\u002B\u002B )\r\n\t\t\t\t_pathBlocked[PathIndex( x, y )] = true;\r\n\t}\r\n\r\n\tbool PathNodeReachesPlayer( int x, int y, Player player )\r\n\t{\r\n\t\treturn MathF.Abs( x - player.X ) \u003C Width / 2f \u002B player.Width / 2f\r\n\t\t\t\u0026\u0026 MathF.Abs( y - player.Y ) \u003C Height / 2f \u002B player.Height / 2f;\r\n\t}\r\n\r\n\tstatic int PathIndex( int x, int y ) =\u003E y * Arena.WIDTH \u002B x;\r\n\r\n\tstatic Direction DirectionBetweenPathNodes( int from, int to )\r\n\t{\r\n\t\tint fromX = from % Arena.WIDTH, fromY = from / Arena.WIDTH;\r\n\t\tint toX = to % Arena.WIDTH, toY = to / Arena.WIDTH;\r\n\t\tif ( toX \u003C fromX ) return Direction.Left;\r\n\t\tif ( toX \u003E fromX ) return Direction.Right;\r\n\t\tif ( toY \u003C fromY ) return Direction.Down;\r\n\t\tif ( toY \u003E fromY ) return Direction.Up;\r\n\t\treturn Direction.None;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELane check for a PURSUIT leg toward the player at center-to-center distance\r\n\t/// \u003Cparamref name=\u0022alongDist\u0022/\u003E: the block only needs to travel until its BODY reaches the player\u0027s\r\n\t/// center-line (its leading edge is half an extent past that already), so the required clear distance\r\n\t/// is the center-to-center distance minus the block\u0027s half-extent along the travel axis. Sweeping the\r\n\t/// full center-to-center distance would demand clearance half a block PAST the player \u2014 through the\r\n\t/// very platform they\u0027re standing on (or the wall they\u0027re hugging) \u2014 making every player who\u0027s\r\n\t/// standing on an obstacle read as unreachable and the hunter overshoot them.\u003C/summary\u003E\r\n\tbool IsPursuitLaneClear( Direction dir, float alongDist, Vector2? from = null )\r\n\t{\r\n\t\tfloat halfExtent = (dir == Direction.Left || dir == Direction.Right) ? Width / 2f : Height / 2f;\r\n\t\treturn IsLaneClear( dir, MathF.Max( alongDist - halfExtent, 0f ), includeWalls: false, from );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EIs the block\u0027s whole body clear to travel \u003Cparamref name=\u0022distance\u0022/\u003E px in\r\n\t/// \u003Cparamref name=\u0022dir\u0022/\u003E without an interior obstacle (or another block) jutting into the lane? Tests\r\n\t/// the swept AABB \u2014 the block\u0027s rect grown by \u003Cparamref name=\u0022distance\u0022/\u003E along the travel axis \u2014 so a\r\n\t/// partial cross-axis overlap counts as blocked, not just something dead ahead. Arena walls are left\r\n\t/// to the normal collide-and-repick path by default, so a PURSUIT lane that simply runs to a wall\r\n\t/// isn\u0027t over-rejected; \u003Cparamref name=\u0022includeWalls\u0022/\u003E adds them for the fallback \u0022running room\u0022\r\n\t/// test, where a wall a couple of pixels away is just as much of an immediate stop as an obstacle.\r\n\t/// \u003Cparamref name=\u0022from\u0022/\u003E tests the lane from a hypothetical position instead of the current one\r\n\t/// (used by the stalk\u0027s \u0022will the drop still be open when I draw level?\u0022 lookahead).\u003C/summary\u003E\r\n\tbool IsLaneClear( Direction dir, float distance, bool includeWalls = false, Vector2? from = null )\r\n\t{\r\n\t\tRectF r = from is Vector2 f ? GetRect( f.x, f.y ) : GetRect();\r\n\t\tfloat left = r.Left, right = r.Right, bottom = r.Bottom, top = r.Top;\r\n\t\tswitch ( dir )\r\n\t\t{\r\n\t\t\tcase Direction.Left: left -= distance; break;\r\n\t\t\tcase Direction.Right: right \u002B= distance; break;\r\n\t\t\tcase Direction.Down: bottom -= distance; break;\r\n\t\t\tdefault: top \u002B= distance; break; // Up\r\n\t\t}\r\n\t\tRectF swept = new RectF( left, bottom, right, top );\r\n\r\n\t\tif ( includeWalls \u0026\u0026\r\n\t\t\t(swept.Left \u003C Arena.WALL_SIZE || swept.Right \u003E Arena.WIDTH - Arena.WALL_SIZE ||\r\n\t\t\t swept.Bottom \u003C Arena.WALL_SIZE || swept.Top \u003E Arena.HEIGHT - Arena.WALL_SIZE) )\r\n\t\t\treturn false;\r\n\r\n\t\tforeach ( Entity2D ob in Stage.GetBlockSolidObstacles() )\r\n\t\t\tif ( swept.Intersects( ob.GetRect() ) )\r\n\t\t\t\treturn false;\r\n\r\n\t\tforeach ( Block b in Stage.GetBlocks() )\r\n\t\t\tif ( b != this \u0026\u0026 swept.Intersects( b.GetRect() ) )\r\n\t\t\t\treturn false;\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHunt at phase 1\u002B; phase 0 stays the neutral random wander every block shares, so the\r\n\t/// Hunter\u0027s personality reveals itself as it phases up (like every other block type).\u003C/summary\u003E\r\n\tprotected override Direction GetNewDirection()\r\n\t{\r\n\t\t_legStartPos = Pos; // every re-pick starts a fresh leg (Pos is the rest position here)\r\n\r\n\t\tif ( Phase \u003C 1 || Stage?.ClosestTargetablePlayer( Position ) == null )\r\n\t\t{\r\n\t\t\t_pursuing = false;\r\n\t\t\t_followingPath = false;\r\n\t\t\t_reachedPathWaypoint = false;\r\n\t\t\t_brakeTurnIntent = Direction.None;\r\n\t\t\treturn base.GetNewDirection();\r\n\t\t}\r\n\r\n\t\tDirection dir = PickHuntDirection( out _pursuing );\r\n\t\treturn dir == Direction.None ? MoveDirection : dir; // boxed in \u2014 hold and try again next stop\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/Blocks/BlockSlicer.cs","FileName":"BlockSlicer.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\n\n/// \u003Csummary\u003E\n/// Slicer block \u2014 on a hard SLAM it lays down a full-level \u0022laser wall\u0022: a thin line that spans the\n/// entire arena, oriented parallel to the face that slammed (i.e. perpendicular to its travel direction),\n/// pinned at the block\u0027s flush contact position. Each line starts as a harmless grey\n/// telegraph, then arms and becomes lethal at full strength for a few seconds, then powers down over a\n/// short HARMLESS fade-out. Touching a LIVE line kills the player.\n///\n/// \u003Clist type=\u0022bullet\u0022\u003E\n/// \u003Citem\u003EPhase 0: no lines (the block hasn\u0027t woken up).\u003C/item\u003E\n/// \u003Citem\u003EPhase 1: block slams create one line on the contact face; arena-wall slams create none.\u003C/item\u003E\n/// \u003Citem\u003EPhase 2: block and arena-wall slams create one line on the contact face.\u003C/item\u003E\n/// \u003C/list\u003E\n///\n/// The lines sit BELOW the blocks (so blocks slide over them) but their kill band is a plain rect test\n/// against the player, unaffected by draw order. Determinism: line positions come from the block\u0027s\n/// flush impact rect, and their arm/expire timing \u002B the kill test are pure functions of the fixed step\n/// and player position; nothing reads Time.Delta \u2014 so replays reproduce them exactly. The render\n/// (pulse/flicker) reads the accumulated age, which is itself deterministic.\n///\n/// This block draws from BOTH Rng streams, and the split is load-bearing:\n/// \u003Clist type=\u0022bullet\u0022\u003E\n/// \u003Citem\u003EAUTHORITATIVE (\u003Csee cref=\u0022AddLine\u0022/\u003E) \u2014 only the phase-2 lethal lifetime. That value decides how\n/// long the kill band is live, so it IS simulation state and belongs on the seeded sim stream.\u003C/item\u003E\n/// \u003Citem\u003ECOSMETIC (\u003Csee cref=\u0022EmitSendParticles\u0022/\u003E) \u2014 every charge-particle property: where on the line it\n/// lands, its spawn jitter, colour, size, lifetime and accel. None of it touches the kill band, the arm\n/// timing or anything else the sim reads, so it must NOT advance the authoritative stream. The particle\n/// COUNT and cadence are fixed constants driven off the same fixed step, so a given input-replay still\n/// reproduces the visuals identically \u2014 the two streams just can\u0027t perturb each other.\u003C/item\u003E\n/// \u003C/list\u003E\n/// SendParticlesRemaining/SendParticleTimer live on the line for convenience but are write-only as far as\n/// the sim is concerned: nothing outside the particle emitter ever reads them.\n/// \u003C/summary\u003E\npublic sealed class BlockSlicer : Block\n{\n\t// --- timing -------------------------------------------------------------------------------\n\t// A grey warning line appears first (harmless), then the line arms and is lethal at FULL strength for\n\t// its whole deadly window, then powers down over a short HARMLESS fade-out.\n\tconst float INDICATOR_TIME = 1.25f;  // grey telegraph before it arms\n\tconst float DEADLY_TIME_P1 = 2.6f;   // phase-1 lethal window (fully lethal, full brightness)\n\t// Phase 2 lines last a bit longer, with a randomised lifetime. (Authoritative Rng: lifetime changes\n\t// how long the kill band is live, so it\u0027s simulation state \u2014 deterministic for replays.)\n\tconst float DEADLY_TIME_P2_MIN = 3.2f;\n\tconst float DEADLY_TIME_P2_MAX = 5.2f;\n\t// After the lethal window the line visibly powers down over this long \u2014 it is NOT deadly during the\n\t// fade (the fade telegraphs \u0022switching off\u0022 so a dim, near-gone line can\u0027t be a hidden instakill).\n\tconst float FADE_OUT_TIME = 0.45f;\n\n\t// --- visuals ------------------------------------------------------------------------------\n\tconst float INDICATOR_MIN_THICKNESS = 3.0f;\n\tconst float INDICATOR_MAX_THICKNESS = 5.0f;  // thickens as it\u0027s about to arm\n\tconst float INDICATOR_WAVE_AMPLITUDE = 3.0f;\n\tconst float INDICATOR_WAVELENGTH = 40.0f;\n\tconst float INDICATOR_WAVE_SPEED = 10.0f;\n\tconst float INDICATOR_SEGMENT_LENGTH = 8.0f;\n\tconst float INDICATOR_FADE_IN_TIME = 0.5f;\n\tconst int SEND_PARTICLE_COUNT = 26;\n\tconst float SEND_PARTICLE_EMIT_TIME = 0.25f;\n\tconst float SEND_PARTICLE_HORIZONTAL_JITTER = 8f;\n\tconst float SEND_PARTICLE_MIN_HEIGHT = 4f;\n\tconst float SEND_PARTICLE_MAX_HEIGHT = 12f;\n\tconst float DEADLY_THICKNESS = 3.0f;         // the lethal core width (also the kill-band width)\n\tconst float GLOW_THICKNESS = 12.0f;          // wide soft halo behind the core\n\t// How far to shove a wall-hugging line\u0027s centre toward the arena centre so it isn\u0027t half-hidden\n\t// behind the wall (moves the whole line off the ~2px wall band into open play).\n\tconst float WALL_EDGE_NUDGE = 2.0f;\n\t// The non-core layers (halo \u002B inner glow) breathe \u2014 their SIZE pulses (subtly \u002B rapidly) so the beam\n\t// feels alive; the two layers pulse OUT OF SYNC (a phase offset) so they don\u0027t move as one.\n\tconst float SIZE_PULSE_SPEED = 18.0f;\n\tconst float HALO_PULSE_AMOUNT = 0.14f;       // \u00B1 fraction of the halo width\n\tconst float GLOW_PULSE_AMOUNT = 0.11f;       // \u00B1 fraction of the inner-glow width\n\tconst float GLOW_PULSE_PHASE = 2.1f;         // radians the glow layer lags the halo (off-sync)\n\t// A vibrant brightness/colour flash that pulses the whole live beam.\n\tconst float COLOR_FLASH_SPEED = 16.0f;\n\n\n\tconst float INDICATOR_PULSE_SPEED = 9.0f;    // grey line breathes so it reads as \u0022charging\u0022\n\tconst float DEADLY_FLICKER_SPEED = 40.0f;    // fast crackle on the live beam\n\tconst float ARM_FLASH_TIME = 0.15f;          // duration of the white-hot flash the instant it arms\n\n\tstatic readonly Color INDICATOR_COLOR = new( 0.70f, 0.73f, 0.78f ); // neutral grey warning\n\t// Phase 1 = hot red/orange laser; phase 2 = a distinct electric magenta/violet so the escalated\n\t// wall slam reads differently at a glance.\n\tstatic readonly Color DEADLY_CORE_COLOR_P1 = new( 1.0f, 0.12f, 0.10f ); // hot red core\n\tstatic readonly Color DEADLY_GLOW_COLOR_P1 = new( 1.0f, 0.38f, 0.14f ); // orange-red halo\n\tstatic readonly Color DEADLY_CORE_COLOR_P2 = new( 1.0f, 0.15f, 0.95f ); // hot magenta core\n\tstatic readonly Color DEADLY_GLOW_COLOR_P2 = new( 0.65f, 0.20f, 1.0f );  // violet halo\n\n\t// --- state --------------------------------------------------------------------------------\n\tsealed class LaserLine\n\t{\n\t\tpublic bool Vertical;   // true = vertical line (fixed X, spans full height); false = horizontal\n\t\tpublic float Coord;     // the fixed coordinate the line sits at (X if vertical, else Y)\n\t\tpublic float Age;\n\t\tpublic float DeadlyTime; // how long this line stays lethal after arming (randomised in phase 2)\n\t\tpublic bool Phase2;     // colour set to use (captured at spawn, so a later phase-up doesn\u0027t recolour it)\n\t\tpublic bool ArmedSfxPlayed;\n\t\tpublic int SendParticlesRemaining;\n\t\tpublic float SendParticleTimer;\n\t\tpublic Vector2 SendOrigin;\n\t}\n\n\treadonly List\u003CLaserLine\u003E _lines = new();\n\t// Pooled line quads (shared across all this block\u0027s live lines and indicator segments). Stage-level overlay\n\t// sprites so they sit in world space independent of the block\u0027s own transform.\n\treadonly List\u003CSpriteRenderer\u003E _lineSprites = new();\n\n\tpublic override void Tick( float dt )\n\t{\n\t\tbase.Tick( dt ); // move / bounce / phase / eye-cycle; a slam calls Impact (below), laying lines\n\n\t\tUpdateLines( dt );\n\t\tRenderLines();\n\t}\n\n\tpublic override void DestroyStageVisuals()\n\t{\n\t\tbase.DestroyStageVisuals();\n\t\t// The lines\u0027 kill test lives in this instance\u0027s Tick, so they die with it \u2014 drop them along with\n\t\t// their quads rather than leaving deadly-looking beams frozen on screen.\n\t\t_lines.Clear();\n\t\tDestroyOverlayPool( _lineSprites );\n\t}\n\n\t// ----------------------------------------------------------------------------------------\n\tpublic override void Impact( Block otherBlock )\n\t{\n\t\tImpactEffects( otherBlock );\n\t\tif ( IsDead ) return;\n\n\t\t// Phase 1 activates only against another block. Phase 2 also activates against wall-like solids,\n\t\t// which arrive as a null block (LastImpactOther distinguishes arena walls from obstacles).\n\t\t// The block is snapped flush here, so GetRect() gives the exact contact edges.\n\t\tbool canSpawnLine = Phase \u003E= 2 || (Phase == 1 \u0026\u0026 otherBlock is not null);\n\t\tif ( canSpawnLine \u0026\u0026 SlammedThisTick )\n\t\t\tSpawnLine();\n\n\t\tCloseEyesAndPickNewDirection();\n\t}\n\n\tvoid SpawnLine()\n\t{\n\t\tRectF r = GetRect();\n\n\t\t// The contact face is perpendicular to the direction of travel.\n\t\tAddLine( MoveDirection, r );\n\n\t\t// A telegraph sizzle as the warning line appears.\n\t\tAudio.PlaySfx( SfxType.LaserTracer, Position, 0.3f );\n\t}\n\n\t/// \u003Csummary\u003EAdd a full-level line at \u003Cparamref name=\u0022face\u0022/\u003E of the block\u0027s flush impact rect. A\n\t/// left/right face gives a vertical line (spanning the height); a top/bottom face gives a horizontal\n\t/// one \u2014 always oriented parallel to the face (perpendicular to the block\u0027s travel into it).\u003C/summary\u003E\n\tvoid AddLine( Direction face, RectF r )\n\t{\n\t\tbool vertical = face is Direction.Left or Direction.Right;\n\t\tfloat coord = face switch\n\t\t{\n\t\t\tDirection.Left =\u003E r.Left,\n\t\t\tDirection.Right =\u003E r.Right,\n\t\t\tDirection.Up =\u003E r.Top,\n\t\t\tDirection.Down =\u003E r.Bottom,\n\t\t\t_ =\u003E 0f,\n\t\t};\n\n\t\t// If the line lands right along an arena wall (a slam into the wall), nudge its centre a few px\n\t\t// toward the arena centre \u2014 otherwise the thin line half-hides under/behind the wall and is hard\n\t\t// to see. (Shifts the kill band too, keeping it matched to what\u0027s drawn.)\n\t\tfloat span = vertical ? Arena.WIDTH : Arena.HEIGHT;\n\t\tif ( coord \u003C= Arena.WALL_SIZE \u002B 0.5f ) coord \u002B= WALL_EDGE_NUDGE;\n\t\telse if ( coord \u003E= span - Arena.WALL_SIZE - 0.5f ) coord -= WALL_EDGE_NUDGE;\n\n\t\tbool phase2 = Phase \u003E= 2;\n\t\tfloat deadly = phase2 ? Rng.Float( DEADLY_TIME_P2_MIN, DEADLY_TIME_P2_MAX ) : DEADLY_TIME_P1;\n\t\t_lines.Add( new LaserLine\n\t\t{\n\t\t\tVertical = vertical,\n\t\t\tCoord = coord,\n\t\t\tPhase2 = phase2,\n\t\t\tDeadlyTime = deadly,\n\t\t\tSendParticlesRemaining = SEND_PARTICLE_COUNT,\n\t\t\tSendOrigin = Position,\n\t\t} );\n\t}\n\n\t// ----------------------------------------------------------------------------------------\n\t/// \u003Csummary\u003EAge each line, arm/expire it, and while it\u0027s live kill the player if they touch its band.\u003C/summary\u003E\n\tvoid UpdateLines( float dt )\n\t{\n\t\tfor ( int i = _lines.Count - 1; i \u003E= 0; i-- )\n\t\t{\n\t\t\tvar line = _lines[i];\n\t\t\tline.Age \u002B= dt;\n\t\t\tEmitSendParticles( line, dt );\n\t\t\tif ( line.Age \u003E= INDICATOR_TIME \u002B line.DeadlyTime \u002B FADE_OUT_TIME )\n\t\t\t{\n\t\t\t\t_lines.RemoveAt( i );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Lethal ONLY during the deadly window \u2014 not the leading grey telegraph nor the trailing\n\t\t\t// power-down fade (both harmless, so the visible fade can\u0027t hide a still-live line).\n\t\t\tbool deadly = line.Age \u003E= INDICATOR_TIME \u0026\u0026 line.Age \u003C INDICATOR_TIME \u002B line.DeadlyTime;\n\t\t\tif ( deadly \u0026\u0026 !line.ArmedSfxPlayed )\n\t\t\t{\n\t\t\t\tAudio.PlaySfx( SfxType.Laser, Position, 0.33f );\n\t\t\t\tline.ArmedSfxPlayed = true;\n\t\t\t}\n\n\t\t\t// Don\u0027t kill during the win sequence (blocks die, but the player lingers) \u2014 only a live line\n\t\t\t// from an alive block is lethal.\n\t\t\tif ( deadly \u0026\u0026 !IsDead )\n\t\t\t{\n\t\t\t\tfloat half = DEADLY_THICKNESS * 0.5f;\n\t\t\t\tRectF band = line.Vertical\n\t\t\t\t\t? new RectF( line.Coord - half, 0f, line.Coord \u002B half, Arena.HEIGHT )\n\t\t\t\t\t: new RectF( 0f, line.Coord - half, Arena.WIDTH, line.Coord \u002B half );\n\n\t\t\t\t// A live line kills any player body it touches \u2014 the real player AND any Summoner impostors.\n\t\t\t\tif ( Stage is not null )\n\t\t\t\t{\n\t\t\t\t\tforeach ( var player in Stage.LivingPlayers ) KillInBand( player );\n\t\t\t\t\tforeach ( var imp in Stage.Impostors ) KillInBand( imp );\n\t\t\t\t}\n\n\t\t\t\tvoid KillInBand( Player body )\n\t\t\t\t{\n\t\t\t\t\tif ( body is null || body.IsDead || !band.Intersects( body.GetRect() ) ) return;\n\t\t\t\t\t// Spray blood away from the line (out along its normal, with a slight upward bias).\n\t\t\t\t\tVector2 dir = line.Vertical\n\t\t\t\t\t\t? new Vector2( body.X \u003E= line.Coord ? 1f : -1f, 0.2f )\n\t\t\t\t\t\t: new Vector2( 0.2f, body.Y \u003E= line.Coord ? 1f : -1f );\n\t\t\t\t\tbody.KilledByLaser( Utils.Normalized( dir ) );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvoid EmitSendParticles( LaserLine line, float dt )\n\t{\n\t\tif ( line.SendParticlesRemaining \u003C= 0 || Stage is null ) return;\n\n\t\tline.SendParticleTimer -= dt;\n\t\tfloat interval = SEND_PARTICLE_EMIT_TIME / SEND_PARTICLE_COUNT;\n\t\twhile ( line.SendParticlesRemaining \u003E 0 \u0026\u0026 line.SendParticleTimer \u003C= 0f )\n\t\t{\n\t\t\tfloat along = Rng.CosmeticFloat( 0f, line.Vertical ? Arena.HEIGHT : Arena.WIDTH );\n\t\t\tVector2 target = line.Vertical ? new Vector2( line.Coord, along ) : new Vector2( along, line.Coord );\n\t\t\tVector2 start = line.SendOrigin \u002B new Vector2(\n\t\t\t\tRng.CosmeticFloat( -SEND_PARTICLE_HORIZONTAL_JITTER, SEND_PARTICLE_HORIZONTAL_JITTER ),\n\t\t\t\tRng.CosmeticFloat( SEND_PARTICLE_MIN_HEIGHT, SEND_PARTICLE_MAX_HEIGHT ) );\n\t\t\t// Colour the stream like the beam it\u0027s charging \u2014 phase 1 red/orange, phase 2 magenta/violet \u2014\n\t\t\t// reading line.Phase2 (captured at spawn) so a mid-charge phase-up can\u0027t recolour it, exactly\n\t\t\t// like the render below. These were AddSpikes0/1, which is the stream Spikey sends at a face\n\t\t\t// right before spikes graft there: the same motes can\u0027t mean two different incoming threats.\n\t\t\t// ONE draw either way, so the accent stays a flat 25% and the cosmetic stream is phase-agnostic.\n\t\t\tbool accent = Rng.CosmeticValue() \u003C 0.25f;\n\t\t\tParticleKind kind = line.Phase2\n\t\t\t\t? (accent ? ParticleKind.SlicerChargeDeep1 : ParticleKind.SlicerChargeDeep0)\n\t\t\t\t: (accent ? ParticleKind.SlicerCharge1 : ParticleKind.SlicerCharge0);\n\n\t\t\tTargetedParticle particle = Stage.AddTargetedParticle( start, Vector2.Zero, 0.9f, 0f, kind,\n\t\t\t\tRng.CosmeticFloat( 1.25f, 1.55f ), Rng.CosmeticInt( 2, 5 ), target,\n\t\t\t\tRng.CosmeticFloat( 4800f, 6000f ), null );\n\t\t\tparticle.SettleAtTarget();\n\t\t\tline.SendParticlesRemaining--;\n\t\t\tline.SendParticleTimer \u002B= interval;\n\t\t}\n\t}\n\n\t// ----------------------------------------------------------------------------------------\n\t/// \u003Csummary\u003EDraw each line: a breathing grey telegraph while charging, then an orange glow \u002B hot red\n\t/// core (with a white-hot flash as it arms) while live, fading out at the end. Below the blocks.\u003C/summary\u003E\n\tvoid RenderLines()\n\t{\n\t\tint idx = 0;\n\t\tfloat baseZ = Globals.DepthToZ( Globals.DEPTH_BLOCK - 1, StageIndex ); // below blocks, above background\n\n\t\tforeach ( var line in _lines )\n\t\t{\n\t\t\tif ( line.Age \u003C INDICATOR_TIME )\n\t\t\t{\n\t\t\t\t// Charging: thick, animated grey wave so wall-edge indicators remain obvious.\n\t\t\t\tfloat p = line.Age / INDICATOR_TIME; // 0 \u2192 1\n\t\t\t\tfloat thick = INDICATOR_MIN_THICKNESS \u002B (INDICATOR_MAX_THICKNESS - INDICATOR_MIN_THICKNESS) * p;\n\t\t\t\tfloat pulse = 0.6f \u002B 0.4f * (0.5f \u002B 0.5f * MathF.Sin( line.Age * INDICATOR_PULSE_SPEED ));\n\t\t\t\tfloat fadeIn = Math.Clamp( line.Age / INDICATOR_FADE_IN_TIME, 0f, 1f );\n\t\t\t\tfadeIn *= fadeIn;\n\t\t\t\tfloat alpha = fadeIn * (0.35f \u002B 0.45f * p) * pulse;\n\t\t\t\tidx = PlaceWavyIndicator( idx, line, thick, INDICATOR_COLOR.WithAlpha( alpha ), baseZ \u002B 0.002f );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// Live: wide soft halo \u002B bright glow \u002B white-hot core. White flash at the moment it arms;\n\t\t\t\t// full strength for the whole lethal window, then a harmless power-down fade. Layered \u002B\n\t\t\t\t// higher alpha so the live beam clearly pops off the field.\n\t\t\t\tfloat deadlyElapsed = line.Age - INDICATOR_TIME;\n\t\t\t\tbool inDeadly = deadlyElapsed \u003C line.DeadlyTime;\n\t\t\t\t// Full brightness while lethal; fade to 0 over FADE_OUT_TIME once the lethal window ends.\n\t\t\t\tfloat fade = inDeadly ? 1f : Math.Clamp( 1f - (deadlyElapsed - line.DeadlyTime) / FADE_OUT_TIME, 0f, 1f );\n\t\t\t\tfloat hot = Math.Clamp( 1f - deadlyElapsed / ARM_FLASH_TIME, 0f, 1f ); // white flash as it arms\n\t\t\t\tfloat flicker = 0.9f \u002B 0.1f * MathF.Sin( line.Age * DEADLY_FLICKER_SPEED );\n\n\t\t\t\t// Vibrant brightness flash pulsing the whole live beam (0 \u2192 1), plus a size pulse for the\n\t\t\t\t// non-core layers (halo/glow breathe out of sync; the core stays a steady kill line).\n\t\t\t\tfloat flash = 0.5f \u002B 0.5f * MathF.Sin( line.Age * COLOR_FLASH_SPEED );\n\t\t\t\tfloat haloScale = 1f \u002B HALO_PULSE_AMOUNT * MathF.Sin( line.Age * SIZE_PULSE_SPEED );\n\t\t\t\tfloat glowScale = 1f \u002B GLOW_PULSE_AMOUNT * MathF.Sin( line.Age * SIZE_PULSE_SPEED \u002B GLOW_PULSE_PHASE );\n\n\t\t\t\tColor glow = line.Phase2 ? DEADLY_GLOW_COLOR_P2 : DEADLY_GLOW_COLOR_P1;\n\t\t\t\tColor core = line.Phase2 ? DEADLY_CORE_COLOR_P2 : DEADLY_CORE_COLOR_P1;\n\n\t\t\t\t// Wide faint outer halo \u2014 a soft bloom that makes the beam read from across the arena.\n\t\t\t\tidx = PlaceLine( idx, line, GLOW_THICKNESS * 2f * haloScale, glow.WithAlpha( (0.16f \u002B 0.16f * flash) * fade * flicker ), baseZ );\n\t\t\t\t// Inner glow \u2014 flashes toward white for a vivid pulse.\n\t\t\t\tColor glowCol = Color.Lerp( glow, Color.White, hot * 0.5f \u002B 0.35f * flash );\n\t\t\t\tidx = PlaceLine( idx, line, GLOW_THICKNESS * glowScale, glowCol.WithAlpha( (0.4f \u002B 0.3f * flash) * fade * flicker ), baseZ \u002B 0.002f );\n\t\t\t\t// Hot core \u2014 steady width (the kill band), but brightness flashes vibrantly toward white.\n\t\t\t\tColor coreCol = Color.Lerp( core, Color.White, Math.Clamp( 0.3f \u002B 0.45f * hot \u002B 0.35f * flash, 0f, 1f ) );\n\t\t\t\tidx = PlaceLine( idx, line, DEADLY_THICKNESS, coreCol.WithAlpha( fade ), baseZ \u002B 0.004f );\n\t\t\t}\n\t\t}\n\n\t\tfor ( int k = idx; k \u003C _lineSprites.Count; k\u002B\u002B )\n\t\t\t_lineSprites[k].Enabled = false;\n\t}\n\n\t/// \u003Csummary\u003EDraw the charging indicator as an animated polyline that settles onto the straight laser\n\t/// coordinate as it arms. At arena edges the wave bends only inward instead of behind the wall.\u003C/summary\u003E\n\tint PlaceWavyIndicator( int idx, LaserLine line, float thickness, Color color, float z )\n\t{\n\t\tfloat length = line.Vertical ? Arena.HEIGHT : Arena.WIDTH;\n\t\tfloat crossSpan = line.Vertical ? Arena.WIDTH : Arena.HEIGHT;\n\t\tfloat waveProgress = Math.Clamp( 1f - line.Age / INDICATOR_TIME, 0f, 1f );\n\t\tfloat waveAmplitude = INDICATOR_WAVE_AMPLITUDE * waveProgress;\n\t\tbool atMinEdge = line.Coord \u003C= Arena.WALL_SIZE \u002B WALL_EDGE_NUDGE \u002B 0.5f;\n\t\tbool atMaxEdge = line.Coord \u003E= crossSpan - Arena.WALL_SIZE - WALL_EDGE_NUDGE - 0.5f;\n\t\tint segmentCount = (int)MathF.Ceiling( length / INDICATOR_SEGMENT_LENGTH );\n\n\t\tfor ( int segment = 0; segment \u003C segmentCount; segment\u002B\u002B )\n\t\t{\n\t\t\tfloat startAlong = segment * INDICATOR_SEGMENT_LENGTH;\n\t\t\tfloat endAlong = Math.Min( startAlong \u002B INDICATOR_SEGMENT_LENGTH, length );\n\t\t\tVector2 start = WavePoint( startAlong );\n\t\t\tVector2 end = WavePoint( endAlong );\n\t\t\tVector2 delta = end - start;\n\t\t\tfloat segmentLength = delta.Length;\n\t\t\tPlaceSegment( idx\u002B\u002B, (start \u002B end) * 0.5f, Utils.Normalized( delta ), segmentLength \u002B 1f, thickness, color, z );\n\t\t}\n\n\t\treturn idx;\n\n\t\tVector2 WavePoint( float along )\n\t\t{\n\t\t\tfloat phase = along / INDICATOR_WAVELENGTH * MathF.PI * 2f - line.Age * INDICATOR_WAVE_SPEED;\n\t\t\tfloat offset = MathF.Sin( phase ) * waveAmplitude;\n\t\t\tfloat inwardWave = (offset \u002B waveAmplitude) * 0.5f;\n\t\t\tif ( atMinEdge ) offset = inwardWave;\n\t\t\telse if ( atMaxEdge ) offset = -inwardWave;\n\n\t\t\treturn line.Vertical\n\t\t\t\t? new Vector2( line.Coord \u002B offset, along )\n\t\t\t\t: new Vector2( along, line.Coord \u002B offset );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EPlace one full-level line quad (a thin rect spanning the whole arena along the line axis).\u003C/summary\u003E\n\tint PlaceLine( int idx, LaserLine line, float thickness, Color color, float z )\n\t{\n\t\tVector2 direction = line.Vertical ? Vector2.Up : Vector2.Right;\n\t\tVector2 centre = line.Vertical\n\t\t\t? new Vector2( line.Coord, Arena.HEIGHT * 0.5f )\n\t\t\t: new Vector2( Arena.WIDTH * 0.5f, line.Coord );\n\t\tfloat length = line.Vertical ? Arena.HEIGHT : Arena.WIDTH;\n\t\tPlaceSegment( idx, centre, direction, length, thickness, color, z );\n\t\treturn idx \u002B 1;\n\t}\n\n\tvoid PlaceSegment( int idx, Vector2 centre, Vector2 direction, float length, float thickness, Color color, float z )\n\t{\n\t\twhile ( idx \u003E= _lineSprites.Count )\n\t\t{\n\t\t\tvar segment = Stage.CreateOverlaySprite();\n\t\t\t_lineSprites.Add( segment );\n\t\t}\n\n\t\tvar sr = _lineSprites[idx];\n\t\tsr.Enabled = true;\n\t\tsr.Color = color;\n\t\tsr.Size = new Vector2( length, thickness );\n\t\tsr.GameObject.WorldRotation = SpriteLayer.FlatRotation( new Vector2( -direction.y, direction.x ) );\n\t\tsr.GameObject.WorldPosition = new Vector3( centre.x, centre.y, z );\n\t}\n}\n"},{"Ident":"facepunch.blockparty","Path":"Entities/Blocks/BlockWisp.cs","FileName":"BlockWisp.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Wisp block \u2014 a skittish free-floater, the only block that isn\u0027t bound to cardinal lanes. Phase 0 is\r\n/// the neutral cardinal wander every block shares; from phase 1 on it lifts off the grid entirely:\r\n///\r\n///  \u2022 It drifts semi-aimlessly in ANY direction \u2014 a smoothly re-rolled heading \u002B speed it eases toward,\r\n///    so the float reads dreamy rather than jittery. A gentle standing repulsion from the arena walls\r\n///    keeps the idle drift out in the open.\r\n///  \u2022 At phase 1, when the player gets too close it panics and RUNS: the drift blends into a straight\r\n///    flee away from the player, faster than the player can sprint \u2014 in open space it can\u0027t be caught,\r\n///    so you press its sides by herding it into a wall or corner where the flee has nowhere left to go\r\n///    (the wall repulsion is deliberately weaker than a full-panic flee).\r\n///\r\n/// Phase 2 (max phase \u2014 nothing left to press, so it exists purely as a hazard) PARTIALLY INVERTS:\r\n/// it never flees, it drifts faster with a much twitchier re-roll cadence and a layered sinusoidal\r\n/// weave on both axes \u2014 erratic but flowing (lissajous-style curves, not noise) \u2014 and a slice of its\r\n/// heading re-rolls aim roughly at the player, so the weave keeps carving through your space.\r\n///\r\n/// Movement integrates per-axis (X then Y each tick) against the same pixel-grid collision the lane\r\n/// blocks use: a soft touch just kills that axis component so it slides along whatever it grazed, while\r\n/// a hard hit (over the normal slam threshold) lands a real slam \u2014 dust, shake, sfx, and the standard\r\n/// eye-close \u2192 re-pick \u2192 re-open pause \u2014 so the wisp still feeds every slam-keyed mechanic. Side\r\n/// pressing and phasing are the default block behaviour throughout.\r\n/// \u003C/summary\u003E\r\npublic sealed class BlockWisp : Block\r\n{\r\n\t// Free-float velocity (px/s). This is the authoritative motion at phase 1\u002B; Block.Velocity mirrors\r\n\t// it every tick so crush checks / riding / sticky reads see the real diagonal motion.\r\n\tVector2 _vel;\r\n\r\n\tVector2 _wanderDir;      // unit drift heading the float eases toward\r\n\tfloat _wanderSpeed;      // target drift speed\r\n\tfloat _retargetTimer;    // countdown to the next heading re-roll\r\n\tfloat _oscT;             // running clock for the phase-2 sinusoidal weave\r\n\tfloat _oscPhaseX, _oscPhaseY;\r\n\tbool _floatStarted;      // seeds _vel from the cardinal Velocity on the first float tick\r\n\tfloat _trailTimer;       // cosmetic wisp-mote cadence\r\n\r\n\t// Drift: lazy meander at phase 1, quick restless weave at phase 2.\r\n\tconst float WANDER_SPEED_MIN_P1 = 25f, WANDER_SPEED_MAX_P1 = 55f;\r\n\tconst float WANDER_SPEED_MIN_P2 = 60f, WANDER_SPEED_MAX_P2 = 105f;\r\n\tconst float RETARGET_MIN_P1 = 0.7f, RETARGET_MAX_P1 = 1.6f;\r\n\tconst float RETARGET_MIN_P2 = 0.35f, RETARGET_MAX_P2 = 0.8f;\r\n\r\n\t// Flee (phase 1 only): kicks in inside the radius and ramps to full panic well before contact.\r\n\t// Faster than the player\u0027s ~86 px/s sprint, so cornering (not chasing) is how you pin it.\r\n\t// Phase 2 is already max phase \u2014 nothing left to press, so being catchable is irrelevant and it\r\n\t// doesn\u0027t flee AT ALL. Instead it\u0027s a partial INVERSION: mostly the random weave, but a slice of\r\n\t// its heading re-rolls aim at the player (see HUNT_CHANCE), so it keeps drifting menacingly\r\n\t// through your space as a roaming hazard.\r\n\tconst float FLEE_RADIUS = 80f;\r\n\tconst float FLEE_SPEED = 115f;\r\n\tconst float MAX_FLOAT_SPEED_P2 = 175f; // speed cap for the phase-2 weave (phase 1 caps at FLEE_SPEED)\r\n\r\n\t// Phase 2: fraction of heading re-rolls that aim at the player instead of a random direction.\r\n\tconst float HUNT_CHANCE = 0.3f;\r\n\tconst float HUNT_SPREAD = 0.5f; // \u00B1 radians around dead-on, so the hunt legs aren\u0027t laser-precise\r\n\r\n\t// How hard the float steers toward its desired velocity (px/s\u00B2); a phase-1 panic steers harder.\r\n\tconst float ACCEL_P1 = 260f, ACCEL_P2 = 420f;\r\n\tconst float FLEE_ACCEL_P1 = 420f;\r\n\r\n\t// Phase-2 weave: two incommensurate frequencies, one per axis, so the path never visibly repeats.\r\n\tconst float OSC_AMP = 55f;\r\n\tconst float OSC_FREQ_X = 3.1f, OSC_FREQ_Y = 4.3f;\r\n\tconst float FACING_MIN_OPEN_TIME = 0.35f;\r\n\tconst float FACING_EYES_CLOSED_TIME = 0.1f;\r\n\tconst float FACING_MIN_COMPONENT_SPEED = 12f;\r\n\tconst float FACING_AXIS_SWITCH_RATIO_P1 = 1.35f;\r\n\tconst float FACING_AXIS_SWITCH_RATIO_P2 = 1.6f;\r\n\r\n\t// Standing wall repulsion on the drift. Much weaker than a full flee, so a panicking wisp can\r\n\t// still be driven into a wall and pinned there.\r\n\tconst float WALL_AVOID_RANGE = 30f;\r\n\tconst float WALL_AVOID_STR = 70f;\r\n\r\n\tprotected override void OnSetup()\r\n\t{\r\n\t\t// Random weave phases so two wisps never oscillate in sync. Setup runs in deterministic sim\r\n\t\t// order, so drawing from the authoritative Rng here is replay-safe.\r\n\t\t_oscPhaseX = Rng.Float( 0f, MathF.PI * 2f );\r\n\t\t_oscPhaseY = Rng.Float( 0f, MathF.PI * 2f );\r\n\t}\r\n\r\n\t// Quick recovery: the wisp is flighty, so its post-slam pause is short (base is 0.28\u20130.60s).\r\n\tprotected override float EyesStayClosedTime() =\u003E Phase switch\r\n\t{\r\n\t\t2 =\u003E Rng.Float( 0.08f, 0.16f ),\r\n\t\t1 =\u003E Rng.Float( 0.16f, 0.30f ),\r\n\t\t_ =\u003E base.EyesStayClosedTime(),\r\n\t};\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\tprotected override void TickMovement( float dt )\r\n\t{\r\n\t\tif ( Phase \u003C 1 )\r\n\t\t{\r\n\t\t\t_floatStarted = false;\r\n\t\t\tbase.TickMovement( dt );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// The shared enclosed check intentionally ignores neighbouring blocks. A free-floating Wisp\r\n\t\t// trapped between blocks would otherwise keep attempting diagonal movement and alternately\r\n\t\t// unpenetrating from its side neighbours, carrying a rider left/right by a rendered pixel.\r\n\t\tif ( IsFloatTrapped() )\r\n\t\t{\r\n\t\t\tVelocity = Vector2.Zero;\r\n\t\t\tDecaySpeedLines( 0.5f );\r\n\t\t\tRenderSpeedLines();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_oscT \u002B= dt;\r\n\r\n\t\tif ( IsStopped || IsDead )\r\n\t\t{\r\n\t\t\t_vel = Vector2.Zero;\r\n\t\t\tDecaySpeedLines( 0.5f );\r\n\t\t\tRenderSpeedLines();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Phase-up mid-charge: carry the cardinal momentum into the float so it doesn\u0027t snap.\r\n\t\tif ( !_floatStarted )\r\n\t\t{\r\n\t\t\t_floatStarted = true;\r\n\t\t\t_vel = Velocity;\r\n\t\t\tRerollWander();\r\n\t\t}\r\n\r\n\t\tTickFloat( dt );\r\n\t}\r\n\r\n\tbool IsFloatTrapped()\r\n\t{\r\n\t\t// Resolve an existing penetration before treating the Wisp as stationary; probing one pixel\r\n\t\t// from an overlap can report every direction blocked by the same neighbour.\r\n\t\tif ( IsCollidingWithBlock( X, Y, out _ ) || IsCollidingWithObstacle( X, Y, out _ ) )\r\n\t\t\treturn false;\r\n\r\n\t\tforeach ( Direction direction in Globals.GetAllDirections() )\r\n\t\t\tif ( !IsColliding( direction ) ) return false;\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid TickFloat( float dt )\r\n\t{\r\n\t\tbool p2 = Phase \u003E= 2;\r\n\r\n\t\t_retargetTimer -= dt;\r\n\t\tif ( _retargetTimer \u003C= 0f )\r\n\t\t\tRerollWander();\r\n\r\n\t\t// Desired velocity: drift heading, plus the phase-2 sinusoidal weave, plus wall repulsion.\r\n\t\tVector2 desired = _wanderDir * _wanderSpeed;\r\n\t\tif ( p2 )\r\n\t\t\tdesired \u002B= new Vector2(\r\n\t\t\t\tMathF.Sin( _oscT * OSC_FREQ_X \u002B _oscPhaseX ),\r\n\t\t\t\tMathF.Sin( _oscT * OSC_FREQ_Y \u002B _oscPhaseY ) ) * OSC_AMP;\r\n\t\tdesired \u002B= WallRepulsion();\r\n\r\n\t\t// Flee (phase 1 only \u2014 phase 2 never runs, see HUNT_CHANCE): inside the radius, blend the\r\n\t\t// drift into a straight run away from the player. The blend hits full panic at ~45% of the\r\n\t\t// radius, so a close approach always outranks the drift AND the wall repulsion (which is\r\n\t\t// what lets the player pin it against a wall).\r\n\t\tfloat accel = p2 ? ACCEL_P2 : ACCEL_P1;\r\n\t\tfloat cap = p2 ? MAX_FLOAT_SPEED_P2 : FLEE_SPEED;\r\n\t\tvar player = Stage?.ClosestLivingPlayer( Position );\r\n\t\tif ( !p2 \u0026\u0026 player != null )\r\n\t\t{\r\n\t\t\tVector2 away = Pos - player.Pos;\r\n\t\t\tfloat dist = away.Length;\r\n\t\t\tif ( dist \u003C FLEE_RADIUS )\r\n\t\t\t{\r\n\t\t\t\tVector2 fleeDir = dist \u003E 0.001f ? away / dist : _wanderDir;\r\n\t\t\t\tfloat blend = Math.Clamp( (1f - dist / FLEE_RADIUS) * 1.8f, 0f, 1f );\r\n\t\t\t\tVector2 fleeTarget = fleeDir * cap;\r\n\t\t\t\tdesired \u002B= (fleeTarget - desired) * blend;\r\n\t\t\t\taccel \u002B= (FLEE_ACCEL_P1 - accel) * blend;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// Ease the live velocity toward the desired one, then cap the speed.\r\n\t\tVector2 diff = desired - _vel;\r\n\t\tfloat dm = diff.Length;\r\n\t\tif ( dm \u003E 0.0001f )\r\n\t\t\t_vel \u002B= diff / dm * MathF.Min( accel * dt, dm );\r\n\t\tfloat speed = _vel.Length;\r\n\t\tif ( speed \u003E cap )\r\n\t\t\t_vel *= cap / speed;\r\n\r\n\t\t// Integrate one axis at a time against the same pixel-grid collision the lane blocks use \u2014\r\n\t\t// diagonal motion, cardinal resolution.\r\n\t\tMoveAxis( horizontal: true, dt );\r\n\t\tMoveAxis( horizontal: false, dt );\r\n\t\tif ( IsStopped ) return; // a hard axis hit slammed us this tick \u2014 stop cleanly here\r\n\r\n\t\tVelocity = _vel;\r\n\t\t_moveSpeed = _vel.Length;\r\n\t\tUpdateFacing();\r\n\r\n\t\t// Same end-of-move safety as the base mover: bounds clamp \u002B push out of any obstacle another\r\n\t\t// block\u0027s unpenetrate shoved us into. Unlike the base mover we must also resolve BLOCK overlap\r\n\t\t// here: MoveAxis deliberately lets an already-penetrating wisp keep moving (so a shove can\u0027t\r\n\t\t// freeze it), and if the other block is stopped, nothing else would ever separate the pair\r\n\t\t// (base blocks resolve this in their forward-collision path, which the float doesn\u0027t run).\r\n\t\tClampToBounds( X, Y );\r\n\t\tforeach ( Entity2D ob in Stage.GetBlockSolidObstacles() )\r\n\t\t\tUnpenetrate( X, Y, ob );\r\n\t\tif ( IsCollidingWithBlock( X, Y, out Entity2D overlapped ) )\r\n\t\t\tUnpenetrate( X, Y, overlapped );\r\n\r\n\t\t// No speed lines while floating \u2014 the cardinal streaks read wrong on a diagonal path (they\r\n\t\t// trail off the facing axis, not the true motion). Decay out any left over from phase 0;\r\n\t\t// the mint trail motes are the float\u0027s motion tell instead.\r\n\t\tDecaySpeedLines( 0.5f );\r\n\t\tRenderSpeedLines();\r\n\t\tTickTrail( dt );\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003EAdvance one axis of the float, stopping flush against whatever it hits. A soft touch\r\n\t/// kills that axis component (the float slides along the surface); a hard hit lands a real slam.\u003C/summary\u003E\r\n\tvoid MoveAxis( bool horizontal, float dt )\r\n\t{\r\n\t\tif ( IsStopped ) return; // the other axis already slammed this tick\r\n\t\tfloat v = horizontal ? _vel.x : _vel.y;\r\n\t\tif ( v == 0f ) return;\r\n\r\n\t\tfloat nx = X \u002B (horizontal ? v * dt : 0f);\r\n\t\tfloat ny = Y \u002B (horizontal ? 0f : v * dt);\r\n\r\n\t\t// Arena walls: clamp the axis and register the hit.\r\n\t\tfloat half = horizontal ? Width / 2f : Height / 2f;\r\n\t\tfloat lo = Arena.WALL_SIZE \u002B half;\r\n\t\tfloat hi = (horizontal ? Arena.WIDTH : Arena.HEIGHT) - Arena.WALL_SIZE - half;\r\n\t\tfloat p = horizontal ? nx : ny;\r\n\t\tbool wallHit = p \u003C lo || p \u003E hi;\r\n\t\tif ( wallHit )\r\n\t\t{\r\n\t\t\tp = Math.Clamp( p, lo, hi );\r\n\t\t\tif ( horizontal ) nx = p; else ny = p;\r\n\t\t}\r\n\r\n\t\t// Blocks \u002B interior obstacles: refuse a move that creates a NEW penetration (if we\u0027re already\r\n\t\t// overlapping \u2014 shoved by another block\u0027s unpenetrate \u2014 let the move through and the\r\n\t\t// end-of-tick push-out resolve it rather than freezing in place).\r\n\t\tif ( (IsCollidingWithBlock( nx, ny, out Entity2D hit ) || IsCollidingWithObstacle( nx, ny, out hit ))\r\n\t\t\t\u0026\u0026 !(IsCollidingWithBlock( X, Y, out _ ) || IsCollidingWithObstacle( X, Y, out _ )) )\r\n\t\t{\r\n\t\t\tPos = new Vector2( nx, ny );\r\n\t\t\tUnpenetrate( X, Y, hit ); // snap flush (min-translation \u2248 this axis; the step is tiny)\r\n\t\t\tOnAxisHit( horizontal, v, hit );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tPos = new Vector2( nx, ny );\r\n\t\tif ( wallHit )\r\n\t\t\tOnAxisHit( horizontal, v, null );\r\n\t}\r\n\r\n\tvoid OnAxisHit( bool horizontal, float v, Entity2D hit )\r\n\t{\r\n\t\tfloat speed = MathF.Abs( v );\r\n\t\tif ( speed \u003E REQUIRED_COLLISION_SPEED )\r\n\t\t{\r\n\t\t\t// Hard hit: a real slam. Face the impact so the dust/shake/eyes read correctly, bank the\r\n\t\t\t// axis speed for the effect scaling, and run the standard stop \u2192 eye-cycle \u2192 re-pick.\r\n\t\t\tSetFacing( horizontal\r\n\t\t\t\t? (v \u003E 0f ? Direction.Right : Direction.Left)\r\n\t\t\t\t: (v \u003E 0f ? Direction.Up : Direction.Down) );\r\n\t\t\t_moveSpeed = speed;\r\n\t\t\tVelocity = _vel; // ImpactEffects banks this into PreImpactVelocity for the fling mechanics\r\n\t\t\tPressFaceSlammedIntoStatue( hit ); // SetFacing above aimed MoveDirection at the impact\r\n\t\t\tImpactEffects( hit as Block );\r\n\t\t\tCloseEyesAndPickNewDirection();\r\n\t\t\t_vel = Vector2.Zero;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Soft touch: just stop drifting into it and keep floating along it.\r\n\t\tif ( horizontal ) _vel.x = 0f; else _vel.y = 0f;\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003ERe-roll the drift heading/speed and the time until the next re-roll. At phase 2 a\r\n\t/// slice of the unbiased re-rolls (\u003Csee cref=\u0022HUNT_CHANCE\u0022/\u003E) aims roughly AT the player \u2014 the\r\n\t/// partial inversion of the phase-1 flee that keeps the max-phase weave drifting through the\r\n\t/// player\u0027s space instead of politely wandering elsewhere.\u003C/summary\u003E\r\n\tvoid RerollWander( float? biasAngle = null )\r\n\t{\r\n\t\tbool p2 = Phase \u003E= 2;\r\n\t\tfloat a;\r\n\t\tvar player = Stage?.ClosestLivingPlayer( Position );\r\n\t\tif ( biasAngle is float bias )\r\n\t\t\ta = bias \u002B Rng.Float( -1.1f, 1.1f );\r\n\t\telse if ( p2 \u0026\u0026 player != null \u0026\u0026 Rng.Value() \u003C HUNT_CHANCE )\r\n\t\t{\r\n\t\t\tVector2 to = player.Pos - Pos;\r\n\t\t\ta = MathF.Atan2( to.y, to.x ) \u002B Rng.Float( -HUNT_SPREAD, HUNT_SPREAD );\r\n\t\t}\r\n\t\telse\r\n\t\t\ta = Rng.Float( 0f, MathF.PI * 2f );\r\n\t\t_wanderDir = new Vector2( MathF.Cos( a ), MathF.Sin( a ) );\r\n\t\t_wanderSpeed = p2 ? Rng.Float( WANDER_SPEED_MIN_P2, WANDER_SPEED_MAX_P2 )\r\n\t\t\t\t\t\t  : Rng.Float( WANDER_SPEED_MIN_P1, WANDER_SPEED_MAX_P1 );\r\n\t\t_retargetTimer = p2 ? Rng.Float( RETARGET_MIN_P2, RETARGET_MAX_P2 )\r\n\t\t\t\t\t\t\t: Rng.Float( RETARGET_MIN_P1, RETARGET_MAX_P1 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EStanding push away from any arena wall within range \u2014 keeps the idle drift out in the\r\n\t/// open. Deliberately weaker than a full flee so the panic run can still be pinned against a wall.\u003C/summary\u003E\r\n\tVector2 WallRepulsion()\r\n\t{\r\n\t\tVector2 push = Vector2.Zero;\r\n\t\tRectF r = GetRect();\r\n\t\tfloat l = r.Left - Arena.WALL_SIZE;\r\n\t\tfloat rt = (Arena.WIDTH - Arena.WALL_SIZE) - r.Right;\r\n\t\tfloat b = r.Bottom - Arena.WALL_SIZE;\r\n\t\tfloat t = (Arena.HEIGHT - Arena.WALL_SIZE) - r.Top;\r\n\t\tif ( l \u003C WALL_AVOID_RANGE ) push.x \u002B= (1f - l / WALL_AVOID_RANGE) * WALL_AVOID_STR;\r\n\t\tif ( rt \u003C WALL_AVOID_RANGE ) push.x -= (1f - rt / WALL_AVOID_RANGE) * WALL_AVOID_STR;\r\n\t\tif ( b \u003C WALL_AVOID_RANGE ) push.y \u002B= (1f - b / WALL_AVOID_RANGE) * WALL_AVOID_STR;\r\n\t\tif ( t \u003C WALL_AVOID_RANGE ) push.y -= (1f - t / WALL_AVOID_RANGE) * WALL_AVOID_STR;\r\n\t\treturn push;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EFace the dominant axis of the float velocity, with hysteresis (the other axis must\r\n\t/// clearly dominate before a blink is requested) so a near-45\u00B0 drift doesn\u0027t flicker between\r\n\t/// facings. The eye cycle bounds how often the visible direction can change.\u003C/summary\u003E\r\n\tvoid UpdateFacing()\r\n\t{\r\n\t\tfloat ax = MathF.Abs( _vel.x ), ay = MathF.Abs( _vel.y );\r\n\r\n\t\tbool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;\r\n\t\tfloat switchRatio = Phase \u003E= 2 ? FACING_AXIS_SWITCH_RATIO_P2 : FACING_AXIS_SWITCH_RATIO_P1;\r\n\t\tif ( horizontal \u0026\u0026 ay \u003E ax * switchRatio ) horizontal = false;\r\n\t\telse if ( !horizontal \u0026\u0026 ax \u003E ay * switchRatio ) horizontal = true;\r\n\r\n\t\tfloat facingSpeed = horizontal ? ax : ay;\r\n\t\tif ( facingSpeed \u003C FACING_MIN_COMPONENT_SPEED ) return;\r\n\r\n\t\tDirection desiredFacing = horizontal\r\n\t\t\t? (_vel.x \u003E= 0f ? Direction.Right : Direction.Left)\r\n\t\t\t: (_vel.y \u003E= 0f ? Direction.Up : Direction.Down);\r\n\t\tFaceAfterBlink( desiredFacing, FACING_MIN_OPEN_TIME, FACING_EYES_CLOSED_TIME );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EPost-slam re-pick at phase 1\u002B: a fresh drift heading biased away from whatever we just\r\n\t/// slammed (we\u0027re facing it), returned as its dominant cardinal so the eyes open looking the right\r\n\t/// way. Phase 0 keeps the shared random cardinal pick.\u003C/summary\u003E\r\n\tprotected override Direction GetNewDirection()\r\n\t{\r\n\t\tif ( Phase \u003C 1 )\r\n\t\t\treturn base.GetNewDirection();\r\n\r\n\t\tVector2 away = -Globals.GetVectorForDirection( MoveDirection );\r\n\t\tRerollWander( MathF.Atan2( away.y, away.x ) );\r\n\t\tDirection proposedDirection = MathF.Abs( _wanderDir.x ) \u003E= MathF.Abs( _wanderDir.y )\r\n\t\t\t? (_wanderDir.x \u003E= 0f ? Direction.Right : Direction.Left)\r\n\t\t\t: (_wanderDir.y \u003E= 0f ? Direction.Up : Direction.Down);\r\n\t\tDirection constrainedDirection = EnforceAlwaysTurn( MoveDirection, proposedDirection );\r\n\t\tif ( constrainedDirection != proposedDirection )\r\n\t\t{\r\n\t\t\tVector2 turn = Globals.GetVectorForDirection( constrainedDirection );\r\n\t\t\tfloat turnAngle = MathF.Atan2( turn.y, turn.x ) \u002B Rng.Float( -0.6f, 0.6f );\r\n\t\t\t_wanderDir = new Vector2( MathF.Cos( turnAngle ), MathF.Sin( turnAngle ) );\r\n\t\t}\r\n\t\treturn constrainedDirection;\r\n\t}\r\n\r\n\t// ----------------------------------------------------------------------------------------\r\n\t/// \u003Csummary\u003ECosmetic tell: a faint stream of mint motes shed behind the float. Cosmetic-Rng only,\r\n\t/// so the flourish can\u0027t perturb the sim.\u003C/summary\u003E\r\n\tvoid TickTrail( float dt )\r\n\t{\r\n\t\tif ( _moveSpeed \u003C 15f ) return;\r\n\t\t_trailTimer -= dt;\r\n\t\tif ( _trailTimer \u003E 0f ) return;\r\n\t\t_trailTimer = Rng.CosmeticFloat( 0.05f, 0.10f );\r\n\r\n\t\tvar kind = Rng.CosmeticValue() \u003C 0.65f ? ParticleKind.WispTrail0 : ParticleKind.WispTrail1;\r\n\t\tVector2 pos = Position \u002B new Vector2(\r\n\t\t\tRng.CosmeticFloat( -Width * 0.35f, Width * 0.35f ),\r\n\t\t\tRng.CosmeticFloat( -Height * 0.35f, Height * 0.35f ) );\r\n\t\tVector2 vel = -_vel * 0.15f \u002B new Vector2( Rng.CosmeticFloat( -8f, 8f ), Rng.CosmeticFloat( -8f, 8f ) );\r\n\t\tStage.AddParticle( pos, vel, Rng.CosmeticFloat( 0.90f, 0.95f ), 0f, kind,\r\n\t\t\tRng.CosmeticFloat( 0.3f, 0.6f ), Rng.CosmeticInt( 2, 4 ) );\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/Player.GunnerForces.cs","FileName":"Player.GunnerForces.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\r\n\r\nnamespace BlockParty;\r\n\r\npublic sealed partial class Player\r\n{\r\n\t/// \u003Csummary\u003EApply a Gunner bullet\u0027s close-range repel. Sticky glue tears before the impulse is\r\n\t/// applied, reducing the force to 80% on phase 1 and 60% on phase 2 and opening the normal same-face\r\n\t/// re-grab grace window. Motion on the repel axis is consolidated into base velocity so an older,\r\n\t/// non-decaying jump velocity cannot resurface after a temporary opposing force fades.\u003C/summary\u003E\r\n\tpublic bool ApplyBulletRepel( Vector2 dir, float strength )\r\n\t\t=\u003E ApplyBulletRepel( dir, strength, 0f, out _ );\r\n\r\n\t/// \u003Csummary\u003EResolve a glass shot against its vertical fallback, comparing the actual impulses\r\n\t/// after sticky attenuation and opposing-momentum cancellation. Apply only the winning impulse.\u003C/summary\u003E\r\n\tinternal bool ApplyBulletRepel( Vector2 dir, float strength, float fallbackLift, out bool usedExplosion )\r\n\t{\r\n\t\tusedExplosion = true;\r\n\t\tif ( IsDead || IsHardened || IsTwinDashing || IsSquashed || strength \u003C= 0f ) return false;\r\n\r\n\t\tif ( IsStuck )\r\n\t\t{\r\n\t\t\tfloat stickyFactor = TearStickyGlueForRepel();\r\n\t\t\tstrength *= stickyFactor;\r\n\t\t\tfallbackLift *= stickyFactor;\r\n\t\t}\r\n\r\n\t\tDetachPlatform();\r\n\t\tif ( fallbackLift != 0f )\r\n\t\t{\r\n\t\t\tVector2 explosionDir = Utils.Normalized( dir );\r\n\t\t\tVector2 kickDir = new( 0f, MathF.Sign( fallbackLift ) );\r\n\t\t\tVector2 explosionImpulse = GetGunImpulse( GetConsolidatedGunVelocity( explosionDir ), explosionDir,\r\n\t\t\t\tstrength, BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );\r\n\t\t\tVector2 kickImpulse = GetGunImpulse( GetConsolidatedGunVelocity( kickDir ), kickDir,\r\n\t\t\t\tMathF.Abs( fallbackLift ), BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );\r\n\t\t\t// Both candidates start from the same total vertical velocity. Comparing their vertical\r\n\t\t\t// impulses therefore compares the resulting lift, including momentum in every gun channel.\r\n\t\t\tif ( explosionImpulse.y * kickDir.y \u003C kickImpulse.y * kickDir.y )\r\n\t\t\t{\r\n\t\t\t\tdir = kickDir;\r\n\t\t\t\tstrength = MathF.Abs( fallbackLift );\r\n\t\t\t\tusedExplosion = false;\r\n\t\t\t}\r\n\t\t}\r\n\t\tApplyGunDirectionalVelocity( dir, strength, BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EA recoil impulse opposite the Gunner\u0027s shot. Grounded vertical recoil is suppressed.\r\n\t/// From v49, shots into supporting glass queue a vertical fallback on the bullet: a real impact\r\n\t/// wins if it supplies at least as much lift, otherwise the fallback fires on impact or once the\r\n\t/// bullet clears the nearby area.\r\n\t/// This follows gravity, including shots up into a glass ceiling in reverse gravity.\r\n\t/// Suppressed vertical recoil redirects the full force along any remaining horizontal axis.\r\n\t/// The recoil axis is consolidated into base velocity rather than a separately decaying channel.\r\n\t///\r\n\t/// \u003Cpara\u003E\u003Cparamref name=\u0022counterMomentumFactor\u0022/\u003E adds the component of the player\u0027s full integrated\r\n\t/// movement opposing recoil. At 1, recoil first cancels that opposing component, then leaves the\r\n\t/// complete base kick; movement perpendicular to recoil is unchanged.\u003C/para\u003E\u003C/summary\u003E\r\n\tpublic void ApplyGunKickback( Vector2 shotDirection, float force, float counterMomentumFactor, Bullet bullet )\r\n\t{\r\n\t\tif ( IsDead || force \u003C= 0f ) return;\r\n\r\n\t\tVector2 baseKick = -Utils.Normalized( shotDirection ) * force;\r\n\t\tif ( OnFloor )\r\n\t\t{\r\n\t\t\tbullet.RecordGroundedShotForEditor( this );\r\n\t\t\t// Contact flags predate this shot. Re-probe so a block moving in front of the glass\r\n\t\t\t// cannot grant a glass fallback from a stale supporting-surface sample.\r\n\t\t\tif ( Stage.SimVersion \u003E= Sim.GUNNER_GLASS_RECOIL \u0026\u0026 baseKick.y * GravitySign \u003E 0f\r\n\t\t\t\t\u0026\u0026 IsPlayerColliding( X, Y - GravitySign, EffectiveGravityDirection, out var floor )\r\n\t\t\t\t\u0026\u0026 floor is Obstacle { IsGlass: true } glass )\r\n\t\t\t\tbullet.QueueGlassKickback( this, glass, baseKick.y );\r\n\t\t\tbaseKick.y = 0f;\r\n\t\t}\r\n\t\tif ( baseKick == Vector2.Zero ) return;\r\n\r\n\t\tApplyGunDirectionalVelocity( baseKick, force, counterMomentumFactor );\r\n\t}\r\n\r\n\tVector2 GetConsolidatedGunVelocity( Vector2 dir )\r\n\t{\r\n\t\tVector2 velocity = Velocity;\r\n\t\tif ( dir.x != 0f ) velocity.x \u002B= ExtraVelX \u002B _grappleReleaseVelocity.x \u002B _shockwaveVel.x;\r\n\t\tif ( dir.y != 0f ) velocity.y \u002B= ExtraVelY \u002B _grappleReleaseVelocity.y \u002B _shockwaveVel.y;\r\n\t\treturn velocity;\r\n\t}\r\n\r\n\t// Shared by glass-shot selection and actual application, so the comparison includes exactly\r\n\t// the same cancellation without consuming velocity channels or applying two impulses.\r\n\tVector2 GetGunImpulse( Vector2 velocity, Vector2 dir, float strength, float counterMomentumFactor )\r\n\t{\r\n\t\tfloat baseAlong = Vector2.Dot( velocity, dir );\r\n\t\tfloat externalAlong = Vector2.Dot( FieldVelocity, dir );\r\n\t\tfloat opposingSpeed = Math.Max( 0f, -(baseAlong \u002B externalAlong) );\r\n\t\treturn dir * (strength \u002B opposingSpeed * Math.Max( 0f, counterMomentumFactor ));\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECollapse every player-owned component on one Gunner-force axis into base velocity, then\r\n\t/// apply the impulse. Channels are consumed AXIS-BY-AXIS \u2014 the whole x and/or y component of a\r\n\t/// channel, including retained grapple release momentum, folds into base velocity when the force\r\n\t/// touches that axis. On a cardinal axis this is\r\n\t/// exactly the along-component (identical to a vector projection); on a diagonal it also relocates\r\n\t/// the channel\u0027s perpendicular residue into base velocity rather than leaving a ROTATED remnant\r\n\t/// behind (a projection would smear a purely horizontal ExtraVel into a phantom vertical component\r\n\t/// with the wrong decay/cap semantics). Total velocity is conserved either way. Environmental\r\n\t/// velocity is not consumed, but is included when calculating how much owned velocity is needed to\r\n\t/// cancel it.\u003C/summary\u003E\r\n\tvoid ApplyGunDirectionalVelocity( Vector2 dir, float strength, float counterMomentumFactor )\r\n\t{\r\n\t\tdir = Utils.Normalized( dir );\r\n\t\tif ( dir == Vector2.Zero || strength \u003C= 0f ) return;\r\n\r\n\t\tVelocity = GetConsolidatedGunVelocity( dir );\r\n\t\tif ( dir.x != 0f )\r\n\t\t{\r\n\t\t\tExtraVelX = 0f;\r\n\t\t\t_grappleReleaseVelocity = new Vector2( 0f, _grappleReleaseVelocity.y );\r\n\t\t\t_shockwaveVel = new Vector2( 0f, _shockwaveVel.y );\r\n\t\t}\r\n\t\tif ( dir.y != 0f )\r\n\t\t{\r\n\t\t\tExtraVelY = 0f;\r\n\t\t\t_grappleReleaseVelocity = new Vector2( _grappleReleaseVelocity.x, 0f );\r\n\t\t\t_shockwaveVel = new Vector2( _shockwaveVel.x, 0f );\r\n\t\t}\r\n\t\t// _shockwaveStartMag is deliberately left as-is (matching the stasis damp): consuming part of\r\n\t\t// the channel must not RE-ARM the bounce gate. Resetting the reference to the smaller remainder\r\n\t\t// would make an already-decayed knockback read as 100% fresh, so firing a shot mid-flight could\r\n\t\t// revive wall ricochets the decay had already disqualified.\r\n\t\tif ( _shockwaveVel.Length \u003C= SHOCKWAVE_STOP_THRESHOLD )\r\n\t\t{\r\n\t\t\t_shockwaveVel = Vector2.Zero;\r\n\t\t\t_shockwaveStartMag = 0f;\r\n\t\t}\r\n\r\n\t\t// With the owned channels folded in, the axis total is just base \u002B environmental; cancel the\r\n\t\t// opposing part (per counterMomentumFactor) and stack the impulse on top.\r\n\t\tVelocity \u002B= GetGunImpulse( Velocity, dir, strength, counterMomentumFactor );\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/Portal.cs","FileName":"Portal.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\npublic readonly record struct PortalPointerSettings(\r\n\tfloat MinOffset,\r\n\tfloat MaxOffset,\r\n\tColor ColorA,\r\n\tColor ColorB,\r\n\tfloat MinOpacity,\r\n\tfloat MaxOpacity,\r\n\tfloat OpacityPower,\r\n\tfloat PulseSpeed,\r\n\tfloat MinOscillationDistance,\r\n\tfloat MaxOscillationDistance,\r\n\tfloat MinOscillationSpeed,\r\n\tfloat MaxOscillationSpeed\r\n);\r\n\r\n/// \u003Csummary\u003E\r\n/// The Swapper character\u0027s portal: a persistent marker the player can swap positions with (see\r\n/// \u003Csee cref=\u0022SwapPortalAbility\u0022/\u003E). It never expires and does not collide with anything. A projectile\r\n/// hit can anchor it to a block at the impact offset; otherwise it remains stationary in world space.\r\n///\r\n/// Visually it\u0027s an outwardly-expanding, taller-than-wide rectangular portal: a small pool of ring\r\n/// quads, staggered in phase, each growing from tiny to a player\u002B-sized rectangle while fading, in\r\n/// two alternating tones (a cyan and a slightly bluer cyan) so that at any moment two differently\r\n/// coloured rects are expanding at once \u2014 the \u0022portal breathing\u0022 look. It renders just above the\r\n/// block/laser layer but below the player (see the child-order band below), so the player is always\r\n/// drawn on top of their own portal.\r\n///\r\n/// Deterministic / replay-safe: the ring animation is driven purely by the fixed tick \u003Cc\u003Edt\u003C/c\u003E with\r\n/// no RNG, and the entity only moves on an (input-driven, recorded) swap \u2014 so a replay reproduces it\r\n/// exactly. It ticks inside the sim loop, so it also freezes during hit-stop along with everything else.\r\n/// \u003C/summary\u003E\r\npublic sealed class Portal : Entity2D\r\n{\r\n\tpublic GameStage Stage { get; set; }\r\n\r\n\t// --- ring animation tuning ----------------------------------------------------------\r\n\t// Number of expanding ring quads. Each gets its own child-order slot so they never share a Z\r\n\t// (translucent sprites at equal Z would flicker); the band 5..8 sits above the block face layers\r\n\t// (child 0-4) and the squid beam (child 6-8 is close, but the top ring at child 8\u002B clears it) yet\r\n\t// stays below the player, whose sprite is a whole depth layer up (see Globals depth constants).\r\n\tprivate const int NUM_RINGS = 4;\r\n\tprivate const int FIRST_CHILD_ORDER = 5;\r\n\r\n\t// One ring\u0027s grow-and-fade cycle (seconds). The rings are evenly staggered across this so the\r\n\t// expansion reads as continuous.\r\n\tprivate const float RING_PERIOD = 0.9f;\r\n\r\n\t// The rectangular ring\u0027s size, tiny at birth to full (player\u002B sized, taller than it is wide) at\r\n\t// the end of its expansion. The player art is 12x12 / collision 8x10, so this reads as clearly\r\n\t// bigger than the character and portrait-shaped.\r\n\tprivate static readonly Vector2 RING_MIN_SIZE = new( 2f, 3f );\r\n\tprivate static readonly Vector2 RING_FULL_SIZE = new( 14f, 24f );\r\n\r\n\t// The two alternating tones. Cyan matches the Swapper\u0027s body; the second is a touch bluer so the\r\n\t// overlapping rects read as a two-colour portal rather than one flat glow.\r\n\tprivate static readonly Color RING_COLOR_A = new( 72 / 255f, 205 / 255f, 218 / 255f );\r\n\tprivate static readonly Color RING_COLOR_B = new( 60 / 255f, 150 / 255f, 210 / 255f );\r\n\tprivate const float POINTER_HIDE_SPEED = 6f;\r\n\tprivate const float POINTER_FULL_SIZE_SPEED = 220f;\r\n\tprivate const float POINTER_MIN_LENGTH = 5f;\r\n\tprivate const float POINTER_MAX_LENGTH = 8f;\r\n\tprivate const int POINTER_CHILD_ORDER = FIRST_CHILD_ORDER \u002B NUM_RINGS;\r\n\r\n\tprivate SpriteRenderer[] _rings;\r\n\tprivate SceneCustomObject _pointerSceneObject;\r\n\tprivate Material _pointerMaterial;\r\n\tprivate readonly List\u003CVertex\u003E _pointerVertices = new( 3 );\r\n\tprivate Vector2 _pointerVelocity;\r\n\tprivate PortalPointerSettings _pointerSettings;\r\n\tprivate bool _pointerEnabled;\r\n\tprivate float _pointerPulsePhase;\r\n\tprivate float _pointerOscillationPhase;\r\n\tprivate float _phase; // 0..1 master phase, advanced by dt each tick\r\n\tprivate int? _anchorBlockStageIndex;\r\n\tprivate Vector2 _anchorOffset;\r\n\r\n\tpublic void CreateVisuals()\r\n\t{\r\n\t\tSize = RING_FULL_SIZE;                // logical footprint (unused for collision \u2014 purely nominal)\r\n\t\tDepth = Globals.DEPTH_BLOCK;          // base Z = block layer; the child orders lift the rings above it\r\n\r\n\t\t_rings = new SpriteRenderer[NUM_RINGS];\r\n\t\tfor ( int i = 0; i \u003C NUM_RINGS; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar sr = SpriteLayer.Add( GameObject, \u0022sprites/pixel.sprite\u0022, RING_MIN_SIZE, \u0022idle\u0022, FIRST_CHILD_ORDER \u002B i );\r\n\t\t\t// Smooth translucency (same setup as GameStage.CreateOverlaySprite): don\u0027t discard low-alpha\r\n\t\t\t// pixels and alpha-blend instead of dither, so the rings fade cleanly.\r\n\t\t\tsr.Opaque = false;\r\n\t\t\tsr.AlphaCutoff = 0f;\r\n\t\t\t_rings[i] = sr;\r\n\t\t}\r\n\r\n\t\tCreatePointerSceneObject();\r\n\r\n\t\tUpdateRings();\r\n\t}\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t// Components are enabled before GameStage calls CreateVisuals; only recreate after that setup exists.\r\n\t\tif ( _rings is not null ) CreatePointerSceneObject();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\t_pointerSceneObject?.Delete();\r\n\t\t_pointerSceneObject = null;\r\n\t}\r\n\r\n\tprivate void CreatePointerSceneObject()\r\n\t{\r\n\t\tif ( _pointerSceneObject is not null ) return;\r\n\r\n\t\t_pointerMaterial ??= Material.FromShader( \u0022shaders/portal_pointer.shader\u0022 );\r\n\t\t_pointerSceneObject = new SceneCustomObject( Scene.SceneWorld )\r\n\t\t{\r\n\t\t\tTransform = global::Transform.Zero,\r\n\t\t\tRenderLayer = SceneRenderLayer.OverlayWithDepth,\r\n\t\t\tRenderOverride = RenderPointer,\r\n\t\t};\r\n\t\t_pointerSceneObject.Flags.IsOpaque = false;\r\n\t\t_pointerSceneObject.Flags.IsTranslucent = true;\r\n\t\t_pointerSceneObject.Flags.CastShadows = false;\r\n\t\tUpdatePointer();\r\n\t}\r\n\r\n\tpublic override void Tick( float dt )\r\n\t{\r\n\t\tUpdateAnchoredPosition();\r\n\r\n\t\t_phase \u002B= dt / RING_PERIOD;\r\n\t\t_phase -= MathF.Floor( _phase ); // wrap to [0,1) without drift\r\n\t\tUpdateRings();\r\n\r\n\t\tfloat magnitude = _pointerVelocity.Length;\r\n\t\tfloat sizeFraction = PointerSizeFraction( magnitude );\r\n\t\t_pointerPulsePhase = WrapPhase( _pointerPulsePhase \u002B dt * Math.Max( 0f, _pointerSettings.PulseSpeed ) );\r\n\t\tfloat oscillationSpeed = LerpOrdered(\r\n\t\t\t_pointerSettings.MinOscillationSpeed,\r\n\t\t\t_pointerSettings.MaxOscillationSpeed,\r\n\t\t\tsizeFraction\r\n\t\t);\r\n\t\t_pointerOscillationPhase = WrapPhase( _pointerOscillationPhase \u002B dt * Math.Max( 0f, oscillationSpeed ) );\r\n\t\tUpdatePointer();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERe-snap to the anchor block\u0027s CURRENT position. Runs every Tick \u2014 but Portals tick\r\n\t/// AFTER Players, so the swap must call this before reading \u003Csee cref=\u0022Entity2D.Pos\u0022/\u003E or it\r\n\t/// teleports to where the block was LAST tick (up to a block-step behind the rendered portal,\r\n\t/// biased into a moving block).\u003C/summary\u003E\r\n\tpublic void UpdateAnchoredPosition()\r\n\t{\r\n\t\tif ( _anchorBlockStageIndex is not int stageIndex ) return;\r\n\r\n\t\tforeach ( Block block in Stage.GetBlocks() )\r\n\t\t{\r\n\t\t\tif ( block.StageIndex != stageIndex ) continue;\r\n\t\t\tPos = block.Pos \u002B _anchorOffset;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_anchorBlockStageIndex = null;\r\n\t}\r\n\r\n\tprivate void UpdateRings()\r\n\t{\r\n\t\tif ( _rings is null ) return;\r\n\r\n\t\tfor ( int i = 0; i \u003C _rings.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\t// Evenly stagger each ring across the cycle so the expansion is continuous.\r\n\t\t\tfloat t = _phase \u002B (float)i / NUM_RINGS;\r\n\t\t\tt -= MathF.Floor( t );\r\n\r\n\t\t\t// Grow fast then settle toward full size.\r\n\t\t\tfloat w = Utils.Map( t, 0f, 1f, RING_MIN_SIZE.x, RING_FULL_SIZE.x, true, EasingType.SineEaseOut );\r\n\t\t\tfloat h = Utils.Map( t, 0f, 1f, RING_MIN_SIZE.y, RING_FULL_SIZE.y, true, EasingType.SineEaseOut );\r\n\r\n\t\t\t// Fade in quickly, hold, then fade out over the back half \u2014 so a ring is visible while it\u0027s\r\n\t\t\t// actually expanding and never pops in/out at full/zero size.\r\n\t\t\tfloat alpha = MathF.Min( MathF.Min( t / 0.12f, (1f - t) / 0.5f ), 1f );\r\n\t\t\tif ( alpha \u003C 0f ) alpha = 0f;\r\n\r\n\t\t\tvar sr = _rings[i];\r\n\t\t\tsr.Size = new Vector2( w, h );\r\n\t\t\tColor baseColor = (i % 2 == 0) ? RING_COLOR_A : RING_COLOR_B;\r\n\t\t\tsr.Color = baseColor.WithAlpha( alpha );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EShow the velocity the player will receive on the next swap.\u003C/summary\u003E\r\n\tpublic void SetStoredVelocityPointer( Vector2 velocity, bool enabled, PortalPointerSettings settings )\r\n\t{\r\n\t\t_pointerVelocity = velocity;\r\n\t\t_pointerEnabled = enabled;\r\n\t\t_pointerSettings = settings;\r\n\t\tUpdatePointer();\r\n\t}\r\n\r\n\tprivate void UpdatePointer()\r\n\t{\r\n\t\t_pointerVertices.Clear();\r\n\t\tif ( _pointerSceneObject is null ) return;\r\n\r\n\t\tfloat magnitude = _pointerVelocity.Length;\r\n\t\tif ( !_pointerEnabled || magnitude \u003C= POINTER_HIDE_SPEED ) return;\r\n\r\n\t\tVector2 direction = _pointerVelocity / magnitude;\r\n\t\tVector2 perpendicular = new( -direction.y, direction.x );\r\n\t\tfloat sizeFraction = PointerSizeFraction( magnitude );\r\n\t\tfloat minOffset = Math.Max( 0f, _pointerSettings.MinOffset );\r\n\t\tfloat maxOffset = Math.Max( minOffset, _pointerSettings.MaxOffset );\r\n\t\tfloat baseOffset = minOffset \u002B (maxOffset - minOffset) * sizeFraction;\r\n\t\tfloat oscillationDistance = LerpOrdered(\r\n\t\t\t_pointerSettings.MinOscillationDistance,\r\n\t\t\t_pointerSettings.MaxOscillationDistance,\r\n\t\t\tsizeFraction\r\n\t\t);\r\n\t\tfloat oscillation = MathF.Sin( _pointerOscillationPhase * MathF.PI * 2f ) * Math.Max( 0f, oscillationDistance );\r\n\t\tfloat length = POINTER_MIN_LENGTH \u002B (POINTER_MAX_LENGTH - POINTER_MIN_LENGTH) * sizeFraction;\r\n\t\tfloat halfWidth = 1.8f \u002B 0.8f * sizeFraction;\r\n\t\tVector2 center = Pos \u002B direction * (baseOffset \u002B oscillation);\r\n\t\tVector2 tip = center \u002B direction * length * 0.55f;\r\n\t\tVector2 rearCenter = center - direction * length * 0.45f;\r\n\t\tVector2 rearA = rearCenter \u002B perpendicular * halfWidth;\r\n\t\tVector2 rearB = rearCenter - perpendicular * halfWidth;\r\n\t\tfloat z = Globals.DepthToZ( Globals.DEPTH_BLOCK ) \u002B POINTER_CHILD_ORDER * SpriteLayer.LAYER_Z_STEP;\r\n\t\tfloat pulse = 0.5f - 0.5f * MathF.Cos( _pointerPulsePhase * MathF.PI * 2f );\r\n\t\tfloat minOpacity = Math.Clamp( _pointerSettings.MinOpacity, 0f, 1f );\r\n\t\tfloat maxOpacity = Math.Clamp( _pointerSettings.MaxOpacity, minOpacity, 1f );\r\n\t\tfloat opacityFraction = MathF.Pow( sizeFraction, Math.Max( 0.01f, _pointerSettings.OpacityPower ) );\r\n\t\tfloat opacity = minOpacity \u002B (maxOpacity - minOpacity) * opacityFraction;\r\n\t\tColor32 color = Color.Lerp( _pointerSettings.ColorA, _pointerSettings.ColorB, pulse ).WithAlpha( opacity );\r\n\t\t_pointerVertices.Add( new Vertex( new Vector3( rearA.x, rearA.y, z ), color ) );\r\n\t\t_pointerVertices.Add( new Vertex( new Vector3( rearB.x, rearB.y, z ), color ) );\r\n\t\t_pointerVertices.Add( new Vertex( new Vector3( tip.x, tip.y, z ), color ) );\r\n\t\t// Vertices are already world-space; this transform supplies the translucent object\u0027s sort position.\r\n\t\t_pointerSceneObject.Transform = new Transform( new Vector3( center.x, center.y, z ) );\r\n\t}\r\n\r\n\tprivate static float PointerSizeFraction( float magnitude )\r\n\t{\r\n\t\tfloat fraction = Math.Clamp(\r\n\t\t\t(magnitude - POINTER_HIDE_SPEED) / (POINTER_FULL_SIZE_SPEED - POINTER_HIDE_SPEED),\r\n\t\t\t0f,\r\n\t\t\t1f\r\n\t\t);\r\n\t\treturn MathF.Sin( fraction * MathF.PI * 0.5f );\r\n\t}\r\n\r\n\tprivate static float LerpOrdered( float min, float max, float fraction )\r\n\t{\r\n\t\tmin = Math.Max( 0f, min );\r\n\t\tmax = Math.Max( min, max );\r\n\t\treturn min \u002B (max - min) * fraction;\r\n\t}\r\n\r\n\tprivate static float WrapPhase( float phase ) =\u003E phase - MathF.Floor( phase );\r\n\r\n\tprivate void RenderPointer( SceneObject sceneObject )\r\n\t{\r\n\t\tif ( _pointerVertices.Count == 0 || _pointerMaterial is null ) return;\r\n\t\tGraphics.Draw( _pointerVertices, _pointerVertices.Count, _pointerMaterial, primitiveType: Graphics.PrimitiveType.Triangles );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETeleport the portal in world space (used by the swap), detaching it from any block.\u003C/summary\u003E\r\n\tpublic void MoveTo( Vector2 pos )\r\n\t{\r\n\t\t_anchorBlockStageIndex = null;\r\n\t\tPos = pos;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EAnchor the portal to a block while preserving the exact world-space impact position.\u003C/summary\u003E\r\n\tpublic void MoveToBlock( Vector2 pos, Block block )\r\n\t{\r\n\t\t_anchorBlockStageIndex = block.StageIndex;\r\n\t\t_anchorOffset = pos - block.Pos;\r\n\t\tPos = pos;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EPlace the portal just outside a contacted solid face, using its visual footprint.\u003C/summary\u003E\r\n\tpublic void MoveToSurface( Vector2 impact, Direction surfaceFace, float padding )\r\n\t{\r\n\t\t_anchorBlockStageIndex = null;\r\n\t\tVector2 normal = Globals.GetVectorForDirection( surfaceFace );\r\n\t\tfloat halfExtent = normal.x != 0f ? Width * 0.5f : Height * 0.5f;\r\n\t\tPos = impact \u002B normal * (halfExtent \u002B Math.Max( 0f, padding ));\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/ProjectileCollision.cs","FileName":"ProjectileCollision.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Swept collision shared by the player-fired projectiles (\u003Csee cref=\u0022Bullet\u0022/\u003E, \u003Csee cref=\u0022SwapperProjectile\u0022/\u003E).\r\n/// Every test sweeps the projectile\u0027s rect along its WHOLE step (relative to the solid\u0027s own\r\n/// \u003Csee cref=\u0022Block.StepDisplacement\u0022/\u003E for blocks), so a step that starts inside a solid or jumps clean\r\n/// over a thin one still registers, and \u003Csee cref=\u0022TryFindFirstHit\u0022/\u003E ranks blocks, obstacles and the\r\n/// arena walls by entry time so only the FIRST surface along the step is struck \u2014 a block behind a\r\n/// wall the step also crosses is never pressed.\r\n/// \u003C/summary\u003E\r\ninternal static class ProjectileCollision\r\n{\r\n\tpublic enum HitKind { None, Block, Obstacle, Wall }\r\n\r\n\tpublic struct Hit\r\n\t{\r\n\t\tpublic HitKind Kind;\r\n\t\t/// \u003Csummary\u003EEntry time along the step (0 = previousPos, 1 = the current position); negative when the\r\n\t\t/// two already overlapped at the start.\u003C/summary\u003E\r\n\t\tpublic float Time;\r\n\t\tpublic Vector2 Point;\r\n\t\t/// \u003Csummary\u003EThe struck face of the solid, or None when no face was crossed (the solid materialised\r\n\t\t/// onto the projectile, or there was no relative motion at all).\u003C/summary\u003E\r\n\t\tpublic Direction Side;\r\n\t\tpublic Block Block;\r\n\t\tpublic Entity2D Obstacle;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe earliest solid the projectile\u0027s step from \u003Cparamref name=\u0022previousPos\u0022/\u003E to its current\r\n\t/// position strikes, across live blocks, interior obstacles and the arena walls. Ties keep the first\r\n\t/// found (blocks, then obstacles, then walls).\u003C/summary\u003E\r\n\tpublic static bool TryFindFirstHit( Entity2D projectile, GameStage stage, Vector2 previousPos, Vector2 travelDirection, out Hit hit )\r\n\t{\r\n\t\thit = new Hit { Time = float.PositiveInfinity };\r\n\r\n\t\tforeach ( Block block in stage.GetBlocks() )\r\n\t\t{\r\n\t\t\tif ( !TryGetBlockImpact( projectile, block, previousPos, out float time, out Vector2 point, out Direction side ) ) continue;\r\n\t\t\tif ( time \u003E= hit.Time ) continue;\r\n\t\t\thit = new Hit { Kind = HitKind.Block, Time = time, Point = point, Side = side, Block = block };\r\n\t\t}\r\n\r\n\t\tforeach ( Entity2D ob in stage.GetSolidObstacles() )\r\n\t\t{\r\n\t\t\tRectF rect = ob.GetRect();\r\n\t\t\tif ( !TrySweep( projectile, previousPos, rect, Vector2.Zero, out float time, out Direction side ) ) continue;\r\n\t\t\tif ( time \u003E= hit.Time ) continue;\r\n\t\t\tVector2 point = GetImpactPoint( projectile, previousPos, rect, Vector2.Zero, time, side );\r\n\t\t\thit = new Hit { Kind = HitKind.Obstacle, Time = time, Point = point, Side = side, Obstacle = ob };\r\n\t\t}\r\n\r\n\t\tif ( TryHitArenaWall( projectile, travelDirection, previousPos, out float wallTime, out Vector2 wallPoint, out Direction wallSide ) \u0026\u0026 wallTime \u003C hit.Time )\r\n\t\t\thit = new Hit { Kind = HitKind.Wall, Time = wallTime, Point = wallPoint, Side = wallSide };\r\n\r\n\t\treturn hit.Kind != HitKind.None;\r\n\t}\r\n\r\n\tprivate static bool TryGetBlockImpact( Entity2D projectile, Block block, Vector2 previousPos, out float time, out Vector2 impact, out Direction hitSide )\r\n\t{\r\n\t\ttime = 0f;\r\n\t\timpact = default;\r\n\t\thitSide = Direction.None;\r\n\t\tif ( block.IsDead || block.PhasingIn ) return false;\r\n\r\n\t\tRectF blockRect = block.GetRect();\r\n\t\t// A block that materialised onto the projectile this tick crossed no face: the impact is where the\r\n\t\t// step began and nothing gets pressed.\r\n\t\tif ( block.BecameSolidThisTick \u0026\u0026 projectile.GetRect( previousPos.x, previousPos.y ).Intersects( blockRect ) )\r\n\t\t{\r\n\t\t\timpact = previousPos;\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tVector2 delta = block.StepDisplacement;\r\n\t\tRectF startRect = new( blockRect.Left - delta.x, blockRect.Bottom - delta.y, blockRect.Right - delta.x, blockRect.Top - delta.y );\r\n\t\tif ( !TrySweep( projectile, previousPos, startRect, delta, out time, out hitSide ) ) return false;\r\n\t\timpact = GetImpactPoint( projectile, previousPos, startRect, delta, time, hitSide );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESweep the projectile\u0027s rect from \u003Cparamref name=\u0022previousPos\u0022/\u003E to its current position against a\r\n\t/// solid that starts at \u003Cparamref name=\u0022solidStart\u0022/\u003E and moves by \u003Cparamref name=\u0022solidDelta\u0022/\u003E over the same\r\n\t/// step. Overlap is strict (touching edges never collide, matching \u003Csee cref=\u0022RectF.Intersects\u0022/\u003E). The\r\n\t/// entry time is negative when the two already overlap at the start; the face is None only when there is\r\n\t/// no relative motion at all.\u003C/summary\u003E\r\n\tpublic static bool TrySweep( Entity2D projectile, Vector2 previousPos, RectF solidStart, Vector2 solidDelta, out float entryTime, out Direction hitSide )\r\n\t{\r\n\t\tentryTime = 0f;\r\n\t\thitSide = Direction.None;\r\n\t\tVector2 rel = (projectile.Pos - previousPos) - solidDelta;\r\n\r\n\t\t// Minkowski sum: sweep the projectile\u0027s centre as a point through the solid grown by the projectile\u0027s half-size.\r\n\t\tfloat halfWidth = projectile.Width * 0.5f;\r\n\t\tfloat halfHeight = projectile.Height * 0.5f;\r\n\t\tif ( !AxisSpan( previousPos.x, rel.x, solidStart.Left - halfWidth, solidStart.Right \u002B halfWidth, out float entryX, out float exitX ) ) return false;\r\n\t\tif ( !AxisSpan( previousPos.y, rel.y, solidStart.Bottom - halfHeight, solidStart.Top \u002B halfHeight, out float entryY, out float exitY ) ) return false;\r\n\r\n\t\tfloat entry = MathF.Max( entryX, entryY );\r\n\t\tfloat exit = MathF.Min( exitX, exitY );\r\n\t\tif ( entry \u003E= exit || entry \u003E= 1f || exit \u003C= 0f ) return false;\r\n\r\n\t\tentryTime = entry;\r\n\t\thitSide = entryX \u003E= entryY\r\n\t\t\t? (rel.x \u003E 0f ? Direction.Left : rel.x \u003C 0f ? Direction.Right : Direction.None)\r\n\t\t\t: (rel.y \u003E 0f ? Direction.Down : rel.y \u003C 0f ? Direction.Up : Direction.None);\r\n\t\treturn true;\r\n\t}\r\n\r\n\t// Times at which a point moving from p at v enters and leaves the open span (min, max). A stationary\r\n\t// point is inside forever or never.\r\n\tprivate static bool AxisSpan( float p, float v, float min, float max, out float entry, out float exit )\r\n\t{\r\n\t\tif ( v == 0f )\r\n\t\t{\r\n\t\t\tentry = float.NegativeInfinity;\r\n\t\t\texit = float.PositiveInfinity;\r\n\t\t\treturn p \u003E min \u0026\u0026 p \u003C max;\r\n\t\t}\r\n\t\tfloat a = (min - p) / v;\r\n\t\tfloat b = (max - p) / v;\r\n\t\tentry = MathF.Min( a, b );\r\n\t\texit = MathF.Max( a, b );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhere the step touched the solid: on the struck face at the entry time, at the projectile\u0027s\r\n\t/// centre clamped to that face\u0027s extent. With no face, the projectile\u0027s own position.\u003C/summary\u003E\r\n\tprivate static Vector2 GetImpactPoint( Entity2D projectile, Vector2 previousPos, RectF solidStart, Vector2 solidDelta, float entryTime, Direction hitSide )\r\n\t{\r\n\t\tif ( hitSide == Direction.None ) return projectile.Pos;\r\n\r\n\t\tVector2 centerAtImpact = previousPos \u002B (projectile.Pos - previousPos) * entryTime;\r\n\t\tfloat rectLeft = solidStart.Left \u002B solidDelta.x * entryTime;\r\n\t\tfloat rectRight = solidStart.Right \u002B solidDelta.x * entryTime;\r\n\t\tfloat rectBottom = solidStart.Bottom \u002B solidDelta.y * entryTime;\r\n\t\tfloat rectTop = solidStart.Top \u002B solidDelta.y * entryTime;\r\n\t\treturn hitSide switch\r\n\t\t{\r\n\t\t\tDirection.Left =\u003E new Vector2( rectLeft, Math.Clamp( centerAtImpact.y, rectBottom, rectTop ) ),\r\n\t\t\tDirection.Right =\u003E new Vector2( rectRight, Math.Clamp( centerAtImpact.y, rectBottom, rectTop ) ),\r\n\t\t\tDirection.Down =\u003E new Vector2( Math.Clamp( centerAtImpact.x, rectLeft, rectRight ), rectBottom ),\r\n\t\t\t_ =\u003E new Vector2( Math.Clamp( centerAtImpact.x, rectLeft, rectRight ), rectTop ),\r\n\t\t};\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe arena wall in the travel direction, if the step reached it. Only the wall ahead can be\r\n\t/// reached, so the end rect suffices (the walls are far too thick to jump).\u003C/summary\u003E\r\n\tprivate static bool TryHitArenaWall( Entity2D projectile, Vector2 travelDirection, Vector2 previousPos, out float bestTime, out Vector2 point, out Direction hitSide )\r\n\t{\r\n\t\tfloat left = Arena.WALL_SIZE;\r\n\t\tfloat right = Arena.WIDTH - Arena.WALL_SIZE;\r\n\t\tfloat bottom = Arena.WALL_SIZE;\r\n\t\tfloat top = Arena.HEIGHT - Arena.WALL_SIZE;\r\n\t\tVector2 delta = projectile.Pos - previousPos;\r\n\t\tbestTime = float.PositiveInfinity;\r\n\t\tpoint = default;\r\n\t\thitSide = Direction.None;\r\n\r\n\t\tif ( travelDirection.x \u003E 0f \u0026\u0026 projectile.Right \u003E= right )\r\n\t\t{\r\n\t\t\tfloat time = (right - projectile.Width * 0.5f - previousPos.x) / delta.x;\r\n\t\t\tif ( time \u003C bestTime ) { bestTime = time; point = new Vector2( right, Math.Clamp( previousPos.y \u002B delta.y * time, bottom, top ) ); hitSide = Direction.Left; }\r\n\t\t}\r\n\t\telse if ( travelDirection.x \u003C 0f \u0026\u0026 projectile.Left \u003C= left )\r\n\t\t{\r\n\t\t\tfloat time = (left \u002B projectile.Width * 0.5f - previousPos.x) / delta.x;\r\n\t\t\tif ( time \u003C bestTime ) { bestTime = time; point = new Vector2( left, Math.Clamp( previousPos.y \u002B delta.y * time, bottom, top ) ); hitSide = Direction.Right; }\r\n\t\t}\r\n\r\n\t\tif ( travelDirection.y \u003E 0f \u0026\u0026 projectile.Top \u003E= top )\r\n\t\t{\r\n\t\t\tfloat time = (top - projectile.Height * 0.5f - previousPos.y) / delta.y;\r\n\t\t\tif ( time \u003C bestTime ) { bestTime = time; point = new Vector2( Math.Clamp( previousPos.x \u002B delta.x * time, left, right ), top ); hitSide = Direction.Down; }\r\n\t\t}\r\n\t\telse if ( travelDirection.y \u003C 0f \u0026\u0026 projectile.Bottom \u003C= bottom )\r\n\t\t{\r\n\t\t\tfloat time = (bottom \u002B projectile.Height * 0.5f - previousPos.y) / delta.y;\r\n\t\t\tif ( time \u003C bestTime ) { bestTime = time; point = new Vector2( Math.Clamp( previousPos.x \u002B delta.x * time, left, right ), bottom ); hitSide = Direction.Up; }\r\n\t\t}\r\n\r\n\t\treturn bestTime \u003C float.PositiveInfinity;\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Entities/SwapperProjectile.cs","FileName":"SwapperProjectile.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\n/// \u003Csummary\u003EA slower magical bolt that moves its owning Swapper portal to the first solid it hits. Collision\r\n/// is swept like the Gunner\u0027s \u003Csee cref=\u0022Bullet\u0022/\u003E (see \u003Csee cref=\u0022ProjectileCollision\u0022/\u003E): the earliest surface\r\n/// on each step wins, and the first step starts back at the caster\u0027s centre so a solid hugging them is hit.\u003C/summary\u003E\r\npublic sealed class SwapperProjectile : Entity2D\r\n{\r\n\tprivate const int HITBOX_SIZE = 3;\r\n\tprivate const float MAX_LIFETIME = 8f;\r\n\tprivate const float TRAIL_INTERVAL = 0.05f;\r\n\tprivate const int IMPACT_PARTICLES = 18;\r\n\tprivate const int DEPARTURE_PARTICLES = 12;\r\n\tprivate static readonly Color OuterColor = new( 72 / 255f, 205 / 255f, 218 / 255f, 0.58f );\r\n\tprivate static readonly Color CoreColor = new( 180 / 255f, 248 / 255f, 1f, 0.95f );\r\n\tprivate static readonly Color AccentColor = new( 1f, 0.82f, 0.38f, 0.9f );\r\n\r\n\tprivate Vector2 _direction;\r\n\tprivate Portal _portal;\r\n\tprivate int _impactHitStopFrames;\r\n\tprivate float _portalSurfacePadding;\r\n\tprivate float _age;\r\n\tprivate float _trailTimer;\r\n\tprivate float _visualPhase;\r\n\tprivate SpriteRenderer _outerSprite;\r\n\tprivate SpriteRenderer _coreSprite;\r\n\tprivate SpriteRenderer _accentSprite;\r\n\r\n\t// The caster\u0027s centre at fire time; the first step sweeps from here (the muzzle spawns clear of the body).\r\n\tprivate Vector2 _sweepOrigin;\r\n\tprivate bool _sweepFromOrigin;\r\n\r\n\tpublic GameStage Stage { get; set; }\r\n\tpublic Portal TargetPortal =\u003E _portal;\r\n\r\n\tpublic void Setup( Vector2 direction, Vector2 sweepOrigin, Portal portal, float speed, int impactHitStopFrames, float portalSurfacePadding )\r\n\t{\r\n\t\t_direction = Utils.Normalized( direction );\r\n\t\t_sweepOrigin = sweepOrigin;\r\n\t\t_sweepFromOrigin = true;\r\n\t\t_portal = portal;\r\n\t\t_impactHitStopFrames = Math.Max( 0, impactHitStopFrames );\r\n\t\t_portalSurfacePadding = Math.Max( 0f, portalSurfacePadding );\r\n\t\tVelocity = _direction * Math.Max( 1f, speed );\r\n\t\tSize = new Vector2( HITBOX_SIZE, HITBOX_SIZE );\r\n\t}\r\n\r\n\tpublic void CreateVisuals()\r\n\t{\r\n\t\t_outerSprite = AddLayer( new Vector2( 7f, 7f ), OuterColor, 0 );\r\n\t\t_coreSprite = AddLayer( new Vector2( 4f, 4f ), CoreColor, 1 );\r\n\t\t_accentSprite = AddLayer( new Vector2( 2f, 2f ), AccentColor, 2 );\r\n\t}\r\n\r\n\tpublic override void Tick( float dt )\r\n\t{\r\n\t\t_age \u002B= dt;\r\n\t\tif ( _age \u003E MAX_LIFETIME || _portal is null )\r\n\t\t{\r\n\t\t\tDead = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_visualPhase \u002B= dt * 4f;\r\n\t\t_visualPhase -= MathF.Floor( _visualPhase );\r\n\t\tfloat pulse = 0.5f - 0.5f * MathF.Cos( _visualPhase * MathF.Tau );\r\n\t\t_outerSprite.Size = new Vector2( 6.25f \u002B pulse, 6.25f \u002B pulse );\r\n\t\t_coreSprite.Size = new Vector2( 3.5f \u002B pulse * 0.5f, 3.5f \u002B pulse * 0.5f );\r\n\t\t_accentSprite.Color = Color.Lerp( AccentColor, CoreColor, pulse );\r\n\r\n\t\tVector2 previousPos = _sweepFromOrigin ? _sweepOrigin : Pos;\r\n\t\t_sweepFromOrigin = false;\r\n\t\tPos \u002B= Velocity * dt;\r\n\t\tTickTrail( dt );\r\n\r\n\t\t// The earliest solid on this step\u0027s path wins: a block behind an obstacle is never the anchor.\r\n\t\tif ( !ProjectileCollision.TryFindFirstHit( this, Stage, previousPos, _direction, out ProjectileCollision.Hit hit ) ) return;\r\n\t\tif ( hit.Kind == ProjectileCollision.HitKind.Block )\r\n\t\t\tImpact( hit.Point, anchorBlock: hit.Block );\r\n\t\telse\r\n\t\t\tImpact( hit.Point, hit.Side );\r\n\t}\r\n\r\n\tprivate SpriteRenderer AddLayer( Vector2 size, Color color, int childOrder )\r\n\t{\r\n\t\tSpriteRenderer sprite = SpriteLayer.Add( GameObject, \u0022sprites/pixel.sprite\u0022, size, \u0022idle\u0022, childOrder );\r\n\t\tsprite.Color = color;\r\n\t\tsprite.Opaque = false;\r\n\t\tsprite.AlphaCutoff = 0f;\r\n\t\treturn sprite;\r\n\t}\r\n\r\n\tprivate void TickTrail( float dt )\r\n\t{\r\n\t\t_trailTimer \u002B= dt;\r\n\t\twhile ( _trailTimer \u003E= TRAIL_INTERVAL )\r\n\t\t{\r\n\t\t\t_trailTimer -= TRAIL_INTERVAL;\r\n\t\t\tVector2 perpendicular = new( -_direction.y, _direction.x );\r\n\t\t\tVector2 position = Pos - _direction * Rng.CosmeticFloat( 1.5f, 3.5f )\r\n\t\t\t\t\u002B perpendicular * Rng.CosmeticFloat( -1.2f, 1.2f );\r\n\t\t\tVector2 velocity = -_direction * Rng.CosmeticFloat( 5f, 16f )\r\n\t\t\t\t\u002B perpendicular * Rng.CosmeticFloat( -7f, 7f );\r\n\t\t\tColor color = Rng.CosmeticValue() \u003C 0.72f ? OuterColor : AccentColor.WithAlpha( 0.55f );\r\n\t\t\tStage.AddColoredParticle( position, velocity, 0.9f, color,\r\n\t\t\t\tRng.CosmeticFloat( 0.18f, 0.3f ), Rng.CosmeticInt( 1, 3 ), Globals.DEPTH_PARTICLE_1, translucent: true );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate void Impact( Vector2 impact, Direction surfaceFace = Direction.None, Block anchorBlock = null )\r\n\t{\r\n\t\tPos = impact;\r\n\t\tSpawnPortalDepartureParticles( _portal.Pos );\r\n\t\tif ( anchorBlock is not null )\r\n\t\t\t_portal.MoveToBlock( impact, anchorBlock );\r\n\t\telse if ( surfaceFace == Direction.None )\r\n\t\t\t_portal.MoveTo( impact );\r\n\t\telse\r\n\t\t\t_portal.MoveToSurface( impact, surfaceFace, _portalSurfacePadding );\r\n\t\tStage.RequestHitStop( _impactHitStopFrames );\r\n\t\tSpawnImpactParticles( impact );\r\n\t\tAudio.PlaySfx( SfxType.TurnVisible, new Vector3( impact.x, impact.y, 0f ), 0.75f, 1.25f );\r\n\t\tDead = true;\r\n\t}\r\n\r\n\tprivate void SpawnPortalDepartureParticles( Vector2 portalPosition )\r\n\t{\r\n\t\tfor ( int i = 0; i \u003C DEPARTURE_PARTICLES; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat angle = (float)i / DEPARTURE_PARTICLES * MathF.Tau;\r\n\t\t\tVector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );\r\n\t\t\tColor color = (i \u0026 2) == 0 ? OuterColor.WithAlpha( 0.75f ) : CoreColor.WithAlpha( 0.82f );\r\n\t\t\tStage.AddColoredParticle( portalPosition \u002B outward * Rng.CosmeticFloat( 6f, 10f ),\r\n\t\t\t\t-outward * Rng.CosmeticFloat( 24f, 58f ), Rng.CosmeticFloat( 0.84f, 0.92f ), color,\r\n\t\t\t\tRng.CosmeticFloat( 0.18f, 0.32f ), Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );\r\n\t\t}\r\n\t}\r\n\r\n\tprivate void SpawnImpactParticles( Vector2 impact )\r\n\t{\r\n\t\tfor ( int i = 0; i \u003C IMPACT_PARTICLES; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat angle = (i \u002B Rng.CosmeticFloat( -0.25f, 0.25f )) / IMPACT_PARTICLES * MathF.Tau;\r\n\t\t\tVector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );\r\n\t\t\tColor color = (i \u0026 3) == 0 ? AccentColor : ((i \u0026 1) == 0 ? CoreColor : OuterColor.WithAlpha( 0.82f ));\r\n\t\t\tStage.AddColoredParticle( impact \u002B outward * Rng.CosmeticFloat( 0f, 2f ),\r\n\t\t\t\toutward * Rng.CosmeticFloat( 45f, 125f ), Rng.CosmeticFloat( 0.82f, 0.92f ), color,\r\n\t\t\t\tRng.CosmeticFloat( 0.2f, 0.42f ), Rng.CosmeticInt( 2, 5 ), Globals.DEPTH_PARTICLE_1, translucent: true );\r\n\t\t}\r\n\t}\r\n}"},{"Ident":"facepunch.blockparty","Path":"Game/Characters.cs","FileName":"Characters.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// The movement-tuning parameters that a character can vary. Only the curated \u0022feel\u0022 values live\r\n/// here (top speed, ground accel/decel, jump/gravity, wall-jump); the many other player constants\r\n/// (hit-stop frames, particle counts, bounce/dash/wall-kick FX) stay fixed in \u003Csee cref=\u0022Player\u0022/\u003E\r\n/// because they\u0027re not character identity. Every field defaults (via \u003Csee cref=\u0022Original\u0022/\u003E) to the\r\n/// current player\u0027s values, so a character that doesn\u0027t override anything behaves identically to\r\n/// the original \u2014 the abstraction is scaffolded now, ready for genuinely different characters later.\r\n/// \u003C/summary\u003E\r\npublic sealed class CharacterMovement\r\n{\r\n\t/// \u003Csummary\u003ETop horizontal walk speed (px/s). VelX is clamped to this.\u003C/summary\u003E\r\n\tpublic float MaxXSpeed { get; init; } = 86.0f;\r\n\r\n\t/// \u003Csummary\u003EAbsolute cap on UPWARD speed (px/s), applied after gravity each step. Deliberately\r\n\t/// very high by default so it never affects normal play; a character can lower it to stop jumps /\r\n\t/// wall-jumps / launches from stacking into ever more rise speed.\u003C/summary\u003E\r\n\tpublic float MaxRiseSpeed { get; init; } = 1500.0f;\r\n\r\n\t/// \u003Csummary\u003EAbsolute cap on DOWNWARD (fall) speed (px/s), applied after gravity each step. Very\r\n\t/// high by default so a normal fall never reaches it; it matters for a character that can fall\r\n\t/// through the floor and wrap (keeping the fall sane), or one given a deliberately floaty descent.\r\n\t/// Kept separate from \u003Csee cref=\u0022MaxRiseSpeed\u0022/\u003E so rise and fall can be tuned independently.\u003C/summary\u003E\r\n\tpublic float MaxFallSpeed { get; init; } = 1500.0f;\r\n\r\n\t/// \u003Csummary\u003EHorizontal acceleration while moving (px/s\u00B2).\u003C/summary\u003E\r\n\tpublic float HorizontalAcceleration { get; init; } = 350.0f;\r\n\r\n\t/// \u003Csummary\u003EHorizontal deceleration when not moving / turning (px/s\u00B2).\u003C/summary\u003E\r\n\tpublic float HorizontalDeceleration { get; init; } = 330.0f;\r\n\r\n\t/// \u003Csummary\u003EMultiplier on \u003Csee cref=\u0022HorizontalDeceleration\u0022/\u003E applied ONLY while grounded \u2014 a\r\n\t/// traction knob. 1 = original grip; below 1 = slippery / ice feet (you keep sliding when you let\r\n\t/// go); above 1 = a grippier, snappier stop. No effect in the air (air braking is unchanged).\u003C/summary\u003E\r\n\tpublic float GroundFrictionFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EMultiplier on \u003Csee cref=\u0022HorizontalDeceleration\u0022/\u003E applied ONLY while airborne (the air\r\n\t/// equivalent of \u003Csee cref=\u0022GroundFrictionFactor\u0022/\u003E). 1 = the original air braking; 0 = NO horizontal\r\n\t/// air braking at all, so once airborne you keep your horizontal velocity until you land (pressing the\r\n\t/// opposite direction can\u0027t bleed it off either). Pairs with a low \u003Csee cref=\u0022AirAccelerationFactor\u0022/\u003E\r\n\t/// to fully commit a character to the trajectory it left the ground with. Stacks with the post-wall-jump\r\n\t/// momentum window (does not override it).\u003C/summary\u003E\r\n\tpublic float AirFrictionFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EHow strongly \u003Cc\u003EBlockWind\u003C/c\u003E gusts push this character. 1 = original; \u003E1 = a light\r\n\t/// \u0022sail/kite\u0022 that catches gusts harder and rides them faster/further (the wind speed cap scales\r\n\t/// with it too); 0 = immune to wind; negative values are pushed UPWIND (a contrarian). Only affects the wind\r\n\t/// channel \u2014 gravity / dashes / knockback are unchanged.\u003C/summary\u003E\r\n\tpublic float WindCatchFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EHow strongly a singing \u003Cc\u003EBlockSiren\u003C/c\u003E\u0027s pull drifts this character. 1 = original;\r\n\t/// \u003E1 = drawn in harder/faster and to a higher combined drift cap (a moth to the flame); 0 =\r\n\t/// deaf to the song; values below 1 resist it. Only affects the siren drift channel \u2014 gravity / dashes /\r\n\t/// knockback are unchanged. Mirrors \u003Csee cref=\u0022WindCatchFactor\u0022/\u003E for the siren pull.\u003C/summary\u003E\r\n\tpublic float SirenCatchFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EUpward velocity applied by a jump (px/s).\u003C/summary\u003E\r\n\tpublic float JumpPower { get; init; } = 112.0f;\r\n\r\n\t/// \u003Csummary\u003EMinimum random multiplier applied to floor-jump launch strength. Equal min/max\r\n\t/// values disable the random draw; 1 preserves the base movement values.\u003C/summary\u003E\r\n\tpublic float JumpStrengthMinFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EMaximum random multiplier applied to floor-jump launch strength. Values at or\r\n\t/// below \u003Csee cref=\u0022JumpStrengthMinFactor\u0022/\u003E use the minimum without consuming simulation RNG.\u003C/summary\u003E\r\n\tpublic float JumpStrengthMaxFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EBase downward gravity (px/s\u00B2).\u003C/summary\u003E\r\n\tpublic float Gravity { get; init; } = 300.0f;\r\n\r\n\t/// \u003Csummary\u003EGravity multiplier while holding Up in the air (a lighter, floatier rise/fall).\u003C/summary\u003E\r\n\tpublic float ActiveUpGravityFactor { get; init; } = 0.5f;\r\n\r\n\t/// \u003Csummary\u003EWhen true, \u003Csee cref=\u0022ActiveUpGravityFactor\u0022/\u003E only softens gravity while FALLING (a gentle\r\n\t/// hold-Up glide DOWN), not while rising. Off by default = the original both-ways behaviour. Set it so a\r\n\t/// character can float DOWN slowly on Up without that same low gravity floating its jumps / wall-jumps\r\n\t/// way too high on the way UP (the ascent then decelerates at normal rise gravity).\u003C/summary\u003E\r\n\tpublic bool ActiveUpGravityFallOnly { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EGravity multiplier while holding Down in the air (a faster drop).\u003C/summary\u003E\r\n\tpublic float ActiveDownGravityFactor { get; init; } = 2.0f;\r\n\r\n\t/// \u003Csummary\u003EWhen true, holding Down in the air negates a held Up: with both held, the Up gravity\r\n\t/// softening is skipped and the Down factor applies (a committed dive can\u0027t be floated mid-hold).\r\n\t/// Off by default = the original Up-wins priority.\u003C/summary\u003E\r\n\tpublic bool ActiveDownOverridesUp { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EGravity multiplier while holding Down in the air AND still RISING (VelY away from the floor),\r\n\t/// separate from \u003Csee cref=\u0022ActiveDownGravityFactor\u0022/\u003E which then applies only while falling. Lets a\r\n\t/// character be pulled down hard on the way DOWN without that same strong factor braking its ASCENT.\r\n\t/// Negative (the default) = use \u003Csee cref=\u0022ActiveDownGravityFactor\u0022/\u003E for both directions (the original\r\n\t/// both-ways behaviour, so it\u0027s a no-op for every character that doesn\u0027t set it).\u003C/summary\u003E\r\n\tpublic float ActiveDownGravityRiseFactor { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003EGravity multiplier while hugging a wall (a slow slide).\u003C/summary\u003E\r\n\tpublic float WallGravityFactor { get; init; } = 0.15f;\r\n\r\n\t/// \u003Csummary\u003EGravity multiplier while hugging a wall AND still RISING (VelY \u003E 0) \u2014 how much upward\r\n\t/// momentum is preserved as you slide UP a wall. 1 = original (normal rise gravity on the wall);\r\n\t/// below 1 = a \u0022slidy\u0022 wall that carries your upward momentum far up the surface. Stacks on top of\r\n\t/// \u003Csee cref=\u0022RiseGravityFactor\u0022/\u003E. Distinct from \u003Csee cref=\u0022WallGravityFactor\u0022/\u003E, which only shapes\r\n\t/// the DOWNWARD slide (non-positive VelY).\u003C/summary\u003E\r\n\tpublic float WallRiseGravityFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003ESeparate downward speed cap (px/s) applied ONLY while sliding down a wall (hugging \u002B\r\n\t/// falling), independent of \u003Csee cref=\u0022WallGravityFactor\u0022/\u003E. Lets a character accelerate normally in\r\n\t/// free-fall but slide down walls at a controlled terminal speed. Negative (the default) = off (the\r\n\t/// normal \u003Csee cref=\u0022MaxFallSpeed\u0022/\u003E applies on walls too).\u003C/summary\u003E\r\n\tpublic float WallSlideMaxFallSpeed { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003EHorizontal push away from the wall on a wall jump (px/s).\u003C/summary\u003E\r\n\tpublic float WallJumpHorizontalPower { get; init; } = 70.0f;\r\n\r\n\t/// \u003Csummary\u003EUpward velocity of a wall jump (px/s).\u003C/summary\u003E\r\n\tpublic float WallJumpVerticalPower { get; init; } = 112.0f;\r\n\r\n\t/// \u003Csummary\u003EMinimum random multiplier applied to wall-jump launch strength. Equal min/max\r\n\t/// values disable the random draw; 1 preserves the base wall-jump powers.\u003C/summary\u003E\r\n\tpublic float WallJumpStrengthMinFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EMaximum random multiplier applied to wall-jump launch strength. Values at or\r\n\t/// below \u003Csee cref=\u0022WallJumpStrengthMinFactor\u0022/\u003E use the minimum without consuming simulation RNG.\u003C/summary\u003E\r\n\tpublic float WallJumpStrengthMaxFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EWhether a wall jump adds its vertical power to existing away-from-floor velocity.\r\n\t/// When false, each wall jump starts with exactly \u003Csee cref=\u0022WallJumpVerticalPower\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool WallJumpStacksVerticalVelocity { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EHow long (s) after a wall jump the reduced air control/acceleration lasts, to prevent clinging to the wall you just jumped from.\u003C/summary\u003E\r\n\tpublic float WallJumpTime { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EHow fast the overflow \u0022extra velocity\u0022 horizontal channel (dash / wall-dive / swap\r\n\t/// launch) decays back to zero (px/s\u00B2). Lower = launches/dashes carry much further before petering\r\n\t/// out. Default matches the original feel; a slippery character can drop it for long glides.\u003C/summary\u003E\r\n\tpublic float ExtraVelHorizontalDeceleration { get; init; } = 300.0f;\r\n\r\n\t/// \u003Csummary\u003EHow fast the overflow \u0022extra velocity\u0022 vertical channel (vertical dashes / platform\r\n\t/// flings) decays back to zero (px/s\u00B2). Lower values carry vertical launches further.\u003C/summary\u003E\r\n\tpublic float ExtraVelVerticalDeceleration { get; init; } = 300.0f;\r\n\r\n\t/// \u003Csummary\u003EMultiplier on horizontal acceleration WHILE AIRBORNE (1 = same as ground). Below 1 =\r\n\t/// floatier, less responsive air steering; above 1 = sharper air control. No effect on the ground.\u003C/summary\u003E\r\n\tpublic float AirAccelerationFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003ECoyote time: how many fixed steps after leaving the ground a jump still works. Higher =\r\n\t/// more forgiving. Measured in ticks (frame-rate independent / replay-safe).\u003C/summary\u003E\r\n\tpublic int GroundedLeniencyFrames { get; init; } = 6;\r\n\r\n\t/// \u003Csummary\u003EHow many fixed steps after leaving a wall a wall jump still works. Higher = more\r\n\t/// forgiving. Measured in ticks.\u003C/summary\u003E\r\n\tpublic int WallJumpLeniencyFrames { get; init; } = 7;\r\n\r\n\t/// \u003Csummary\u003EExtra deceleration (px/s\u00B2) applied ONLY while reversing direction (holding a direction\r\n\t/// opposite to current horizontal velocity) \u2014 a \u0022skid\u0022 brake that snaps turns tighter. 0 = the\r\n\t/// original feel (turning is driven purely by acceleration in the new direction).\u003C/summary\u003E\r\n\tpublic float TurnaroundDeceleration { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EFraction of horizontal speed kept on landing (1 = keep all = original; lower = a grippy\r\n\t/// \u0022thud\u0022 that bleeds momentum when you touch down). Applies to VelX and the overflow ExtraVelX on a\r\n\t/// downward landing.\u003C/summary\u003E\r\n\tpublic float LandingMomentumRetention { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EMultiplier on BASE gravity while RISING (VelY \u003E 0). Values below 1 give a floatier ascent. Stacks with\r\n\t/// the input-gated ActiveUp/Down/Wall factors. 1 = original.\u003C/summary\u003E\r\n\tpublic float RiseGravityFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EMultiplier on BASE gravity while FALLING (non-positive VelY). \u003E1 = a snappier drop (pair with a low\r\n\t/// RiseGravityFactor for a classic float-up/fall-fast arc). 1 = original.\u003C/summary\u003E\r\n\tpublic float FallGravityFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003ETop horizontal walk speed WHILE AIRBORNE. Negative (the default) = use\r\n\t/// \u003Csee cref=\u0022MaxXSpeed\u0022/\u003E (no distinction); set it to run slow on the ground but fly fast in the\r\n\t/// air, or vice-versa.\u003C/summary\u003E\r\n\tpublic float MaxAirXSpeed { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003ERaised air X-speed cap (px/s) while holding Down in the air \u2014 a \u0022dive gear\u0022 on top of\r\n\t/// \u003Csee cref=\u0022MaxAirXSpeed\u0022/\u003E for a wavy flap-up-then-swoop-forward rhythm. Reads the\r\n\t/// orientation-swapped Down, so it stays the dive key under reverse gravity. Releasing Down drops\r\n\t/// the cap back and the excess bleeds off at \u003Csee cref=\u0022OverspeedDecay\u0022/\u003E. Negative (the default)\r\n\t/// = off (holding Down changes nothing horizontally).\u003C/summary\u003E\r\n\tpublic float DiveMaxAirXSpeed { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003EExtra horizontal acceleration (px/s\u00B2) applied along the CURRENT heading while the dive\r\n\t/// gear is engaged and already moving \u2014 the swoop itself builds speed toward the raised cap, no\r\n\t/// left/right hold needed (steering against the motion suspends it so a reversal stays possible).\r\n\t/// 0 = the raised cap must be earned with ordinary air steering instead. Only used when\r\n\t/// \u003Csee cref=\u0022DiveMaxAirXSpeed\u0022/\u003E is set.\u003C/summary\u003E\r\n\tpublic float DiveHorizontalAcceleration { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EHow fast (px/s\u00B2) horizontal speed that EXCEEDS the current cap bleeds back down to it.\r\n\t/// Only relevant when \u003Csee cref=\u0022MaxAirXSpeed\u0022/\u003E differs from \u003Csee cref=\u0022MaxXSpeed\u0022/\u003E: when the\r\n\t/// air/ground state flips and the cap drops below current speed, the excess eases off at this rate\r\n\t/// instead of snapping to the cap in one tick (which felt like hitting glue on landing, or a run\r\n\t/// being truncated the first airborne tick). Defaults to the ground deceleration so the settle\r\n\t/// feels like normal braking; raise it for a near-instant clamp, lower it for a long coast-down.\u003C/summary\u003E\r\n\tpublic float OverspeedDecay { get; init; } = 330.0f;\r\n\r\n\t/// \u003Csummary\u003EJump buffer: press jump up to this many ticks BEFORE landing and it fires on touchdown.\r\n\t/// 0 = off. Only buffers a press that didn\u0027t already trigger a wall/air jump, so it composes cleanly\r\n\t/// with those.\u003C/summary\u003E\r\n\tpublic int JumpBufferFrames { get; init; } = 3;\r\n\r\n\t/// \u003Csummary\u003EExtra horizontal launch speed per unit of horizontal speed at takeoff. Rewards running\r\n\t/// into a jump with a farther leap in the current travel direction. 0 = off.\u003C/summary\u003E\r\n\tpublic float JumpSpeedBonus { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EHorizontal \u0022hop-forward\u0022 kick (px/s) added on a GROUND jump, always in the direction the\r\n\t/// player is FACING (the way they last turned) \u2014 so it fires even from a standstill and isn\u0027t\r\n\t/// affected by external pushes (wind / platforms). Additive momentum: fills VelX up to the walk cap\r\n\t/// and spills the remainder into the carrying ExtraVelX channel, so it never brakes a faster run. 0 = off.\u003C/summary\u003E\r\n\tpublic float JumpHorizExitBoost { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EHold INTO a wall \u002B Up to climb it at this speed (px/s). 0 = off. A pressed jump still\r\n\t/// wall-jumps (fires over this); a held Up climbs.\u003C/summary\u003E\r\n\tpublic float WallClimbSpeed { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EHold Up in the air to hover (freeze the fall) for this many ticks, once per airtime\r\n\t/// (normally refilled on landing). 0 = off.\u003C/summary\u003E\r\n\tpublic int HoverFrames { get; init; } = 0;\r\n\r\n\t/// \u003Csummary\u003EGravity multiplier near the PEAK of a jump (while airborne with |VelY| below\r\n\t/// \u003Csee cref=\u0022ApexHangVelThreshold\u0022/\u003E), for extra \u0022hang time\u0022 at the top of the arc. 1 = off\r\n\t/// (also a no-op when the threshold is 0). Stacks on top of the other gravity factors.\u003C/summary\u003E\r\n\tpublic float ApexHangGravityFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EVertical speed (px/s) below which the apex-hang reduced gravity applies. 0 = off.\u003C/summary\u003E\r\n\tpublic float ApexHangVelThreshold { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EThe original player\u0027s movement \u2014 the baseline every character inherits. Expression-bodied\r\n\t/// (a fresh instance each access) rather than a static field so edits to the baseline tuning above\r\n\t/// take effect on hotload \u2014 a static field would be restored to its pre-edit value. Cheap: the type\r\n\t/// is a small immutable record only touched at character spawn / registry (re)build.\u003C/summary\u003E\r\n\tpublic static CharacterMovement Original =\u003E new();\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// The set of on/off (and simple tunable) abilities a character has. These are the \u0022toggle\u0022 tier of\r\n/// the ability system: pure data the shared \u003Csee cref=\u0022Player\u0022/\u003E physics reads to enable/skip a\r\n/// behaviour. Genuinely-new behaviours that carry their own code \u002B state (edge-wrap, teleport, \u2026)\r\n/// are modelled as \u003Csee cref=\u0022PlayerAbility\u0022/\u003E modules instead; the two tiers compose freely so a\r\n/// character can mix any subset. Most fields default to the original player\u0027s behaviour; the four\r\n/// gated moves (\u003Csee cref=\u0022CanBounce\u0022/\u003E, \u003Csee cref=\u0022CanDash\u0022/\u003E, \u003Csee cref=\u0022CanWallDive\u0022/\u003E,\r\n\t/// \u003Csee cref=\u0022CanNeutralWallJump\u0022/\u003E) default OFF so Original opts in and new characters start minimal.\r\n/// \u003C/summary\u003E\r\npublic sealed record class CharacterAbilities\r\n{\r\n\t/// \u003Csummary\u003EHolding Down on a hard landing converts the impact into an upward bounce.\r\n\t/// Off by default \u2014 Original opts in.\u003C/summary\u003E\r\n\tpublic bool CanBounce { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EMinimum downward impact speed for a manual (hold-Down) bounce, so a gentle touchdown\r\n\t/// doesn\u0027t jitter. Only used when \u003Csee cref=\u0022CanBounce\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float BounceMinSpeed { get; init; } = 90.0f;\r\n\r\n\t/// \u003Csummary\u003EFraction of downward impact speed returned upward on a manual (hold-Down) bounce.\r\n\t/// Only used when \u003Csee cref=\u0022CanBounce\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float BounceRestitution { get; init; } = 0.45f;\r\n\r\n\t/// \u003Csummary\u003EDouble-tap a horizontal direction to dash. Off by default \u2014 Original opts in.\u003C/summary\u003E\r\n\tpublic bool CanDash { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EAllows horizontal double-tap dashes and horizontal components in diagonal dashes.\r\n\t/// On by default to preserve the baseline dash. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool CanDashHorizontal { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EAllows Up double-tap dashes and upward components in diagonal dashes.\r\n\t/// Off by default. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool CanDashUp { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EAllows Down double-tap dashes and downward components in diagonal dashes.\r\n\t/// Off by default. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool CanDashDown { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhen true, a charged dash may fire while standing on the gravity-relative floor.\r\n\t/// Off by default (airborne only). Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool CanDashOnGround { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhen true, establishing fresh gravity-relative floor contact refreshes the dash charge.\r\n\t/// On by default. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool DashRefreshOnFloor { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EWhen true, establishing a fresh wall hug refreshes the dash charge.\r\n\t/// Off by default. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool DashRefreshOnWallHug { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EDistance (px) of self-propelled movement along the gravity-relative floor (the ceiling\r\n\t/// under reverse gravity) that refreshes a spent dash charge. Being carried by a platform never\r\n\t/// counts. Progress from the three recharge modes pools into one charge, so mixed movement adds\r\n\t/// up. 0 = off. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashRechargeFloorDistance { get; init; } = 0f;\r\n\r\n\t/// \u003Csummary\u003EDistance (px) moved along a hugged wall \u2014 climbing OR sliding \u2014 that refreshes a spent\r\n\t/// dash charge. 0 = off. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashRechargeWallDistance { get; init; } = 0f;\r\n\r\n\t/// \u003Csummary\u003EDistance (px) moved sideways while ceiling clinging that refreshes a spent dash charge.\r\n\t/// 0 = off. Only meaningful when \u003Csee cref=\u0022CanDash\u0022/\u003E and \u003Csee cref=\u0022CanCeilingCling\u0022/\u003E are true.\u003C/summary\u003E\r\n\tpublic float DashRechargeCeilingDistance { get; init; } = 0f;\r\n\r\n\t/// \u003Csummary\u003EPitch multiplier on the walk / wall-climb / ceiling-climb sfx while the dash is SPENT with\r\n\t/// no recharge progress yet; it slides toward \u003Csee cref=\u0022DashRechargeSfxPitchFull\u0022/\u003E as the charge\r\n\t/// refills and snaps back to 1 (authored pitch) once charged. Only used when a DashRecharge*Distance\r\n\t/// is set.\u003C/summary\u003E\r\n\tpublic float DashRechargeSfxPitchEmpty { get; init; } = 0.75f;\r\n\r\n\t/// \u003Csummary\u003EPitch multiplier on the surface-movement sfx just before the dash refills (see\r\n\t/// \u003Csee cref=\u0022DashRechargeSfxPitchEmpty\u0022/\u003E).\u003C/summary\u003E\r\n\tpublic float DashRechargeSfxPitchFull { get; init; } = 1.3f;\r\n\r\n\t/// \u003Csummary\u003EDouble-tap dash: max time the first tap may be held to still count as a tap (s).\r\n\t/// Only used when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashTapReleaseTime { get; init; } = 0.1f;\r\n\r\n\t/// \u003Csummary\u003EDouble-tap dash: max gap from the first tap\u0027s release to the second press (s).\r\n\t/// Only used when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashSecondTapTime { get; init; } = 0.07f;\r\n\r\n\t/// \u003Csummary\u003EWhether directional double-tap gestures ignore a tap consumed by a WALL jump, so a\r\n\t/// ground jump chained into an instant wall jump doesn\u0027t read as a double tap. Ground/ceiling\r\n\t/// jump taps still count. Off by default.\u003C/summary\u003E\r\n\tpublic bool IgnoreWallJumpDoubleTaps { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EDash horizontal impulse.\r\n\t/// Only used when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashForce { get; init; } = 100.0f;\r\n\r\n\t/// \u003Csummary\u003EForce for a dash straight ALONG the effective gravity (down normally, up when reversed);\r\n\t/// diagonals toward gravity use the midpoint between this and \u003Csee cref=\u0022DashForce\u0022/\u003E.\r\n\t/// Null = same force as every other dash.\u003C/summary\u003E\r\n\tpublic float? DashForceAlongGravity { get; init; } = null;\r\n\r\n\t/// \u003Csummary\u003EHow long after a dash gravity is suppressed for a flat hang (s).\r\n\t/// Only used when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashGravitySuppressTime { get; init; } = 0.16f;\r\n\r\n\t/// \u003Csummary\u003EGravity-suppression window for a dash straight ALONG the effective gravity (down normally,\r\n\t/// up when reversed); diagonals toward gravity use the midpoint between this and\r\n\t/// \u003Csee cref=\u0022DashGravitySuppressTime\u0022/\u003E. Null = same window as every other dash. 0 lets a\r\n\t/// gravity-ward dash keep falling instead of coasting flat.\u003C/summary\u003E\r\n\tpublic float? DashGravitySuppressTimeAlongGravity { get; init; } = null;\r\n\r\n\t/// \u003Csummary\u003EExtra dash impulse per unit of entry speed along the dash direction, so dashing while\r\n\t/// already moving that way carries further. Applies to horizontal, vertical, and diagonal dashes.\r\n\t/// 0 = off (a flat \u003Csee cref=\u0022DashForce\u0022/\u003E). Only used when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float DashMomentumFactor { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EConsolidates a horizontal dash with existing VelX and ExtraVelX. Aligned velocity is\r\n\t/// combined with the dash; opposing velocity is cancelled first so it cannot resist the dash or\r\n\t/// resurface after the dash overflow decays. Off by default to preserve the standard dash.\r\n\t/// Only used when \u003Csee cref=\u0022CanDash\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic bool ConsolidateDashHorizontalVelocity { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EPreserves existing vertical velocity when dashing, making the vertical dash component\r\n\t/// additive instead of resetting the rise or fall first. Horizontal dashes therefore leave vertical\r\n\t/// motion untouched. Off by default to preserve the standard flat dash.\u003C/summary\u003E\r\n\tpublic bool PreserveDashVerticalVelocity { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ERebound off a wall by pressing the opposite direction just after touching it.\r\n\t/// (Off by default \u2014 matches the original\u0027s disabled wall-kick.)\u003C/summary\u003E\r\n\tpublic bool CanWallKick { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ELeaning away from the wall on a wall jump trades height for a flatter dive.\r\n\t/// Off by default \u2014 Original opts in.\u003C/summary\u003E\r\n\tpublic bool CanWallDive { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ELegacy replay setting: whether a wall jump can launch straight UP with no horizontal\r\n\t/// held. Sim v44\u002B disables neutral wall jumps for every character; a horizontal direction is required.\r\n\t/// Original, Mimic and both Twins retain their opt-ins so older replays reproduce.\u003C/summary\u003E\r\n\tpublic bool CanNeutralWallJump { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether the four ARENA walls are solid to this character. When false the character\r\n\t/// passes straight through non-spiked arena walls (a SPIKED wall is still lethal); BLOCK\r\n\t/// collisions are unaffected. Usually paired with \u003Csee cref=\u0022WrapsArenaEdges\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool CollidesWithArenaWalls { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EWhether the character wraps to the opposite arena edge after passing far enough\r\n\t/// through a (non-spiked) wall. Requires \u003Csee cref=\u0022CollidesWithArenaWalls\u0022/\u003E to be false to have\r\n\t/// any effect. Implemented by the \u003Csee cref=\u0022WrapArenaEdgesAbility\u0022/\u003E module.\u003C/summary\u003E\r\n\tpublic bool WrapsArenaEdges { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether the character spawns a persistent portal at its spawn point and can swap places\r\n\t/// with it by pressing all four inputs on the same frame. Implemented by the\r\n\t/// \u003Csee cref=\u0022SwapPortalAbility\u0022/\u003E module.\u003C/summary\u003E\r\n\tpublic bool HasSwapPortal { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether touching the floor refills the character\u0027s hover budget.\u003C/summary\u003E\r\n\tpublic bool HoverRefreshOnFloor { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EWhether the character carries the Gunner kit: a small ammo reserve reloaded by holding\r\n\t/// Down while grounded, and a hold-Down-to-fire aim (the other direction keys point the shot) that\r\n\t/// fires a fast \u003Csee cref=\u0022Bullet\u0022/\u003E (or coughs sparks when empty). Implemented by the\r\n\t/// \u003Csee cref=\u0022GunnerAbility\u0022/\u003E module.\u003C/summary\u003E\r\n\tpublic bool HasGunner { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether falling past an exposed block or obstacle corner can snap into a ledge hang,\r\n\t/// followed by a fresh Up press to jump from the hang. Off by default.\u003C/summary\u003E\r\n\tpublic bool CanLedgeGrab { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether the character periodically creates shared-input copies and can continue from a\r\n\t/// surviving body after death. Implemented by \u003Csee cref=\u0022SwarmAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasSwarm { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether the character records its personal movement history and can return to its state\r\n\t/// from two seconds ago. Implemented by \u003Csee cref=\u0022RewindAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasRewind { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether double-tapping any cardinal or diagonal direction fires a grappling line.\r\n\t/// Implemented by \u003Csee cref=\u0022GrapplerAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasGrappler { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether the character can prepare and fire a directional blink.\r\n\t/// Implemented by \u003Csee cref=\u0022BlinkerAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasBlinker { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether sunlight powers this character\u0027s movement.\r\n\t/// Implemented by \u003Csee cref=\u0022SolarAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasSolar { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether this run identity cycles through temporary character forms.\r\n\t/// Implemented by the persistent \u003Csee cref=\u0022MimicAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasMimic { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether holding Down charges a temporary immovable obstacle form.\r\n\t/// Implemented by \u003Csee cref=\u0022HardenAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasHarden { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether this body has Twin1\u0027s committed, hazard-phasing directional dash.\r\n\t/// Implemented by \u003Csee cref=\u0022TwinDashAbility\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasTwinDash { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EHow many mid-air jumps the character has (0 = none, the original; 1 = a double jump,\r\n\t/// etc.). Refilled on landing. A bare Up press in the air with jumps remaining fires one.\u003C/summary\u003E\r\n\tpublic int MaxAirJumps { get; init; } = 0;\r\n\r\n\t/// \u003Csummary\u003EUpward velocity of a mid-air jump (px/s). Negative (the default) = use the ground\r\n\t/// \u003Csee cref=\u0022CharacterMovement.JumpPower\u0022/\u003E; set it to make air jumps weaker or stronger than the\r\n\t/// ground jump.\u003C/summary\u003E\r\n\tpublic float AirJumpPower { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003EWhether an air jump adds its power to existing away-from-floor velocity instead of replacing it.\r\n\t/// Any stacked rise above \u003Csee cref=\u0022CharacterMovement.MaxRiseSpeed\u0022/\u003E spills into ExtraVelY.\u003C/summary\u003E\r\n\tpublic bool AirJumpStacksVerticalVelocity { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether an air jump can fire while holding toward the floor (normally a held Down swallows\r\n\t/// the press). For characters whose Down-hold is a travel mode (the owl\u0027s swoop) rather than a\r\n\t/// fast-fall intent \u2014 a flap mid-dive should still work.\u003C/summary\u003E\r\n\tpublic bool AirJumpWhileHoldingDown { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhen true, releasing the jump button while still rising cuts the remaining upward speed\r\n\t/// (\u003Csee cref=\u0022VariableJumpCutFactor\u0022/\u003E) for a tap-for-short / hold-for-tall jump. Off = fixed-height\r\n\t/// jump (the original).\u003C/summary\u003E\r\n\tpublic bool VariableJumpHeight { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EFraction of upward speed KEPT when a rising jump is cut short (see\r\n\t/// \u003Csee cref=\u0022VariableJumpHeight\u0022/\u003E). Only used when that\u0027s enabled; 0.4 = a brisk short-hop.\u003C/summary\u003E\r\n\tpublic float VariableJumpCutFactor { get; init; } = 0.4f;\r\n\r\n\t/// \u003Csummary\u003EHow many fixed steps a fresh wall hug sticks with ZERO slide before the normal\r\n\t/// \u003Csee cref=\u0022CharacterMovement.WallGravityFactor\u0022/\u003E slide begins (0 = no cling, the original). A\r\n\t/// brief grip that makes wall play more deliberate. Measured in ticks.\u003C/summary\u003E\r\n\tpublic int WallClingFrames { get; init; } = 0;\r\n\r\n\t/// \u003Csummary\u003EHang from an OVERHEAD surface when you hit it with Up held (false = off, the original).\r\n\t/// Like \u003Csee cref=\u0022WallClingFrames\u0022/\u003E but for ceilings, and with NO time limit: the fall freezes for as\r\n\t/// long as Up is held and you stay in contact, then normal gravity resumes on release. Horizontal\r\n\t/// movement is unaffected, so you can shimmy along the ceiling while hanging, and a moving ceiling block\r\n\t/// carries you along with it.\u003C/summary\u003E\r\n\tpublic bool CanCeilingCling { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EDownward closing speed relative to the floor that kills this character on impact (px/s).\r\n\t/// 0 = disabled. This is measured at contact rather than inferred from fall height, so wind or another\r\n\t/// force that brakes the fall before landing can save the player, while a downward shove still counts.\u003C/summary\u003E\r\n\tpublic float FallDamageImpactSpeed { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EMario-style LONG JUMP: hold Down while RUNNING and jump for a low, far leap (reduced\r\n\t/// height \u002B a big forward horizontal launch). Off by default. Shares the Down\u002Bjump input with\r\n\t/// \u003Csee cref=\u0022CanBackFlip\u0022/\u003E \u2014 running triggers the long jump, a standstill triggers the back-flip.\u003C/summary\u003E\r\n\tpublic bool CanLongJump { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EFraction of \u003Csee cref=\u0022CharacterMovement.JumpPower\u0022/\u003E used for a long jump\u0027s height\r\n\t/// (lower = flatter). Only used when \u003Csee cref=\u0022CanLongJump\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float LongJumpHeightFactor { get; init; } = 0.6f;\r\n\r\n\t/// \u003Csummary\u003EForward horizontal launch speed (px/s) added on a long jump. Fills VelX to the walk cap\r\n\t/// and spills the rest into the carrying ExtraVelX channel so it flies far. Used only with\r\n\t/// \u003Csee cref=\u0022CanLongJump\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float LongJumpHorizontalBoost { get; init; } = 120.0f;\r\n\r\n\t/// \u003Csummary\u003EMario-style BACK-FLIP: hold Down from a STANDSTILL and jump for a high leap that launches\r\n\t/// BACKWARD (opposite the facing direction). Off by default. Shares the Down\u002Bjump input with\r\n\t/// \u003Csee cref=\u0022CanLongJump\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool CanBackFlip { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EFraction of \u003Csee cref=\u0022CharacterMovement.JumpPower\u0022/\u003E used for a back-flip\u0027s height\r\n\t/// (higher = taller). Only used when \u003Csee cref=\u0022CanBackFlip\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float BackFlipHeightFactor { get; init; } = 1.4f;\r\n\r\n\t/// \u003Csummary\u003EInitial FORWARD horizontal pop (px/s) on a back-flip, in the facing direction (a small\r\n\t/// hop off the take-off before the backward arc pulls you back). Used only with\r\n\t/// \u003Csee cref=\u0022CanBackFlip\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float BackFlipHorizontalBoost { get; init; } = 60.0f;\r\n\r\n\t/// \u003Csummary\u003EPEAK backward acceleration (px/s\u00B2) of the back-flip push, applied opposite the direction\r\n\t/// faced at take-off \u2014 the momentum arc that carries you back over where you jumped from. Its strength\r\n\t/// eases 0%\u2192100%\u21920% over \u003Csee cref=\u0022BackFlipBackwardDuration\u0022/\u003E (peaking mid-way) so the arc swells in\r\n\t/// then tapers off. Runs for the FULL duration regardless of whether Up is held; ends early only if you\r\n\t/// land or hug a wall (then the drift coasts to a stop). Used only with \u003Csee cref=\u0022CanBackFlip\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float BackFlipBackwardForce { get; init; } = 500.0f;\r\n\t/// \u003Csummary\u003EHow long (seconds) the back-flip backward push lasts \u2014 the period over which its strength\r\n\t/// eases 0%\u2192100%\u21920% (see \u003Csee cref=\u0022BackFlipBackwardForce\u0022/\u003E). Keep it short. Once elapsed the force\r\n\t/// ends and the drift coasts to a stop. Used only with \u003Csee cref=\u0022CanBackFlip\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float BackFlipBackwardDuration { get; init; } = 0.45f;\r\n\t/// \u003Csummary\u003ETap Down in the air to slam straight down (ground pound) at\r\n\t/// \u003Csee cref=\u0022GroundPoundSpeed\u0022/\u003E. Off by default.\u003C/summary\u003E\r\n\tpublic bool CanGroundPound { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EDownward speed of a ground pound (see \u003Csee cref=\u0022CanGroundPound\u0022/\u003E).\u003C/summary\u003E\r\n\tpublic float GroundPoundSpeed { get; init; } = 400.0f;\r\n\r\n\t/// \u003Csummary\u003EAlways bounce on a hard landing WITHOUT holding Down (a pogo). Off by default.\u003C/summary\u003E\r\n\tpublic bool AutoBounceGround { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ERestitution for the auto ground bounce (fraction of downward impact speed returned up).\u003C/summary\u003E\r\n\tpublic float AutoBounceGroundRestitution { get; init; } = 0.65f;\r\n\r\n\t/// \u003Csummary\u003EMinimum downward impact speed to trigger the auto ground bounce (below this you just land\r\n\t/// normally). Keep it above the resting re-land speed (~5) to avoid idle jitter.\u003C/summary\u003E\r\n\tpublic float AutoBounceGroundMinSpeed { get; init; } = 90.0f;\r\n\r\n\t/// \u003Csummary\u003EFloor for the auto ground bounce\u0027s upward speed: if the computed bounce is weaker than\r\n\t/// this it\u0027s boosted up to it (0 = no floor). Still subject to \u003Csee cref=\u0022CharacterMovement.MaxRiseSpeed\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float AutoBounceGroundMinStrength { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EMultiplier for screenshake and hit-stop caused by an automatic ground bounce.\r\n\t/// 1 preserves the standard impact feedback; 0 disables both.\u003C/summary\u003E\r\n\tpublic float AutoBounceGroundImpactFeedbackFactor { get; init; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003EFixed ticks after an automatic ground bounce that use a compressed charged-jump pose.\r\n\t/// 0 disables the pose override.\u003C/summary\u003E\r\n\tpublic int AutoBounceGroundCompressionFrames { get; init; } = 0;\r\n\r\n\t/// \u003Csummary\u003EBounce off a wall on a horizontal impact (reverse \u002B scale VelX) instead of stopping\r\n\t/// dead. Off by default.\u003C/summary\u003E\r\n\tpublic bool AutoBounceWall { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ERestitution for the auto wall bounce (fraction of horizontal speed reversed).\u003C/summary\u003E\r\n\tpublic float AutoBounceWallRestitution { get; init; } = 0.6f;\r\n\r\n\t/// \u003Csummary\u003EMinimum horizontal impact speed to trigger the auto wall bounce (below this you just\r\n\t/// stop at the wall). Avoids jitter when barely touching a wall.\u003C/summary\u003E\r\n\tpublic float AutoBounceWallMinSpeed { get; init; } = 20.0f;\r\n\r\n\t/// \u003Csummary\u003EFloor for the auto wall bounce\u0027s rebound speed: if the reversed speed is weaker than\r\n\t/// this it\u0027s boosted up to it (0 = no floor). Overflow past the walk cap rides ExtraVelX (like a\r\n\t/// dash), so a strong floor actually launches instead of being clamped.\u003C/summary\u003E\r\n\tpublic float AutoBounceWallMinStrength { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EBounce off an OVERHEAD surface on an upward impact (reverse the rise into a fall) instead\r\n\t/// of stopping dead against the ceiling. The vertical twin of \u003Csee cref=\u0022AutoBounceWall\u0022/\u003E. Off by\r\n\t/// default. Gravity-sign aware (reverses the \u0022away from floor\u0022 impact), so it also bounces off the\r\n\t/// floor-that-is-a-ceiling in a reverse-gravity field.\u003C/summary\u003E\r\n\tpublic bool AutoBounceCeiling { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ERestitution for the auto ceiling bounce (fraction of upward impact speed returned as a\r\n\t/// downward fall). Only used when \u003Csee cref=\u0022AutoBounceCeiling\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float AutoBounceCeilingRestitution { get; init; } = 0.6f;\r\n\r\n\t/// \u003Csummary\u003EMinimum upward impact speed to trigger the auto ceiling bounce (below this you just\r\n\t/// stop against the ceiling). Only used when \u003Csee cref=\u0022AutoBounceCeiling\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float AutoBounceCeilingMinSpeed { get; init; } = 30.0f;\r\n\r\n\t/// \u003Csummary\u003ECHARGE JUMP: hold the toward-floor key (Down normally, Up in a reverse field) while\r\n\t/// grounded to wind up a big directional jump. While charging, the\r\n\t/// away-from-floor jump key does nothing and the Left/Right keys steer the launch angle (see\r\n\t/// \u003Csee cref=\u0022Player.HandleChargeJump\u0022/\u003E). Off by default.\u003C/summary\u003E\r\n\tpublic bool HasChargeJump { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EHolding into a side wall winds up a charged jump away from it. Releasing the wall-hug\r\n\t/// direction fires; Up/Down steer along the wall. Full charge normally remains held until release.\r\n\t/// This does not require \u003Csee cref=\u0022WallClingFrames\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool HasChargeWallJump { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EHolding Up against an overhead surface attaches to it and winds up a charged jump away\r\n\t/// from the ceiling. Left/Right aim exactly like a floor charge. Releasing Up fires; full charge remains\r\n\t/// held until then unless auto-fire is enabled. Uses the floor charge time and speed tuning.\u003C/summary\u003E\r\n\tpublic bool HasChargeCeilingJump { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EAutomatically launch a floor, wall, ceiling, or sticky charged jump upon reaching maximum\r\n\t/// charge instead of waiting for its hold input to be released. Off by default.\u003C/summary\u003E\r\n\tpublic bool ChargeJumpAutoFireAtMax { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003ESeconds for a wall charge to reach maximum power. A value at or below zero\r\n\t/// uses \u003Csee cref=\u0022ChargeJumpMaxTime\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float ChargeWallJumpMaxTime { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003ELaunch speed of an uncharged wall jump. A value at or below zero uses\r\n\t/// \u003Csee cref=\u0022ChargeJumpMinSpeed\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float ChargeWallJumpMinSpeed { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003ELaunch speed of a fully charged wall jump. A value at or below zero uses\r\n\t/// \u003Csee cref=\u0022ChargeJumpMaxSpeed\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic float ChargeWallJumpMaxSpeed { get; init; } = -1.0f;\r\n\r\n\t/// \u003Csummary\u003ESeconds after a charged wall jump during which gravity is not applied. Collision and\r\n\t/// external forces remain active. Zero disables the hang.\u003C/summary\u003E\r\n\tpublic float ChargeWallJumpGravitySuppressTime { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003ECharging against a PHASE-1 sticky block\u0027s glue (see \u003Csee cref=\u0022Player.HandleStickyChargeJump\u0022/\u003E):\r\n\t/// the fraction of the face\u0027s max charge time a wind-up must reach before releasing the hold tears the\r\n\t/// player out and fires. Below it the goo wins \u2014 the player just shakes (harder the longer it was\r\n\t/// wound) and stays stuck, the wind-up reset. Needs the matching charge ability for the stuck face\r\n\t/// (floor / wall / ceiling).\u003C/summary\u003E\r\n\tpublic float StickyChargeReleaseFraction { get; init; } = 0.35f;\r\n\r\n\t/// \u003Csummary\u003EThe \u003Csee cref=\u0022StickyChargeReleaseFraction\u0022/\u003E for a PHASE-2 sticky block \u2014 its stronger\r\n\t/// goo takes a (near-)full wind to tear out of. At the 0.99 default only a release inside the last\r\n\t/// sliver of the wind-up or after reaching full charge escapes.\u003C/summary\u003E\r\n\tpublic float StickyChargeReleaseFractionPhase2 { get; init; } = 0.99f;\r\n\r\n\t/// \u003Csummary\u003ELaunch-speed factor applied to a charged jump fired out of PHASE-1 sticky goo \u2014 tearing\r\n\t/// free costs some power. 1 = full strength.\u003C/summary\u003E\r\n\tpublic float StickyChargePowerFactor { get; init; } = 0.75f;\r\n\r\n\t/// \u003Csummary\u003EThe \u003Csee cref=\u0022StickyChargePowerFactor\u0022/\u003E for a PHASE-2 sticky block \u2014 its stronger goo\r\n\t/// saps more of the launch on the way out.\u003C/summary\u003E\r\n\tpublic float StickyChargePowerFactorPhase2 { get; init; } = 0.6f;\r\n\r\n\t/// \u003Csummary\u003ESeconds of holding the charge to reach MAXIMUM jump power. Power caps here until the charge\r\n\t/// input is released unless \u003Csee cref=\u0022ChargeJumpAutoFireAtMax\u0022/\u003E is enabled. Only used when\r\n\t/// \u003Csee cref=\u0022HasChargeJump\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float ChargeJumpMaxTime { get; init; } = 1.25f;\r\n\r\n\t/// \u003Csummary\u003ELaunch speed (px/s) of a charge jump released at ZERO charge \u2014 a tap. Only used when\r\n\t/// \u003Csee cref=\u0022HasChargeJump\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float ChargeJumpMinSpeed { get; init; } = 130.0f;\r\n\r\n\t/// \u003Csummary\u003ELaunch speed (px/s) of a FULLY-charged jump. The launch magnitude lerps from\r\n\t/// \u003Csee cref=\u0022ChargeJumpMinSpeed\u0022/\u003E to this over \u003Csee cref=\u0022ChargeJumpMaxTime\u0022/\u003E. Keep the character\u0027s\r\n\t/// \u003Csee cref=\u0022CharacterMovement.MaxRiseSpeed\u0022/\u003E at or above this so a straight-up max jump isn\u0027t clamped.\r\n\t/// Only used when \u003Csee cref=\u0022HasChargeJump\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float ChargeJumpMaxSpeed { get; init; } = 340.0f;\r\n\r\n\t/// \u003Csummary\u003EThe launch angle (degrees measured FROM the surface, i.e. from horizontal) when a\r\n\t/// horizontal key was held for the WHOLE charge \u2014 a shallow, far leap. Straight up (no net horizontal\r\n\t/// hold) is always 90\u00B0; the angle lerps toward this as the net horizontal-hold fraction reaches 1.\r\n\t/// ~25\u00B0 \u2248 a low, ranging jump. Only used when \u003Csee cref=\u0022HasChargeJump\u0022/\u003E is true.\u003C/summary\u003E\r\n\tpublic float ChargeJumpMinAngleDeg { get; init; } = 25.0f;\r\n\r\n\t/// \u003Csummary\u003EMinimum magnitude of the accumulated horizontal charge aim, normalised to 0..1, before\r\n\t/// directional charge art replaces the neutral pose. Only affects visuals.\u003C/summary\u003E\r\n\tpublic float ChargeJumpDirectionalPoseThreshold { get; init; } = 0.25f;\r\n\r\n\t/// \u003Csummary\u003EAn air (double) jump also flings this fast in the currently-held horizontal direction \u2014\r\n\t/// a directional leap rather than a pure vertical reset. 0 = off (straight-up air jump). Overflow\r\n\t/// past the walk cap rides ExtraVelX so it carries.\u003C/summary\u003E\r\n\tpublic float AirJumpHorizontalBoost { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003EPressing a NOT-yet-pressed BLOCK face flings the player straight away from it at this\r\n\t/// flat speed \u2014 a trampoline/hazard on any of the four faces (incl. tops/bottoms). Only un-pressed\r\n\t/// faces launch, so a fully-pressed block top can still be stood on. Arena walls / obstacles are\r\n\t/// unaffected. Flat (not speed-scaled). 0 = off.\u003C/summary\u003E\r\n\tpublic float BlockPressBoost { get; init; } = 0.0f;\r\n\r\n\t/// \u003Csummary\u003E\u0022Magnet feet\u0022: stay stuck to a moving block instead of being flung off when it slams to\r\n\t/// a stop (suppresses the inertia fling). Off by default (the flingy original feel).\u003C/summary\u003E\r\n\tpublic bool StickToMovingBlocks { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EFLIPPER: the character has NO jump \u2014 a fresh jump/up press while standing on a floor flips\r\n\t/// its OWN gravity instead (persistently), and pressing down while hanging upside-down flips it back.\r\n\t/// Implemented in \u003Csee cref=\u0022Player.HandleGravityFlip\u0022/\u003E; it also gates the normal ground jump off (the\r\n\t/// same press can\u0027t both flip and jump). A self-flip XORs with a reverse-gravity FIELD, so entering a\r\n\t/// field while self-flipped cancels back to normal gravity. Off by default.\u003C/summary\u003E\r\n\tpublic bool CanFlipGravity { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EAdopts any newly touched surface as the gravity-relative floor. Movement and jump input\r\n\t/// rotate with that floor, and Reverse fields invert the resulting cardinal gravity direction.\u003C/summary\u003E\r\n\tpublic bool HasSurfaceGravity { get; init; } = false;\r\n\r\n\t/// \u003Csummary\u003EWhether this character can wall-jump at all. True for the original moveset.\r\n\t/// Independent of \u003Csee cref=\u0022CanNeutralWallJump\u0022/\u003E (which only gates the straight-up variant) \u2014\r\n\t/// false disables every wall jump.\u003C/summary\u003E\r\n\tpublic bool CanWallJump { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EWhether holding into a side wall engages wall slide/grip and moving-block side carry.\r\n\t/// True for the original moveset. False leaves the wall physically solid but prevents wall hugging.\u003C/summary\u003E\r\n\tpublic bool CanWallHug { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EThe original player\u0027s ability set \u2014 the baseline every character inherits. Expression-bodied\r\n\t/// (a fresh instance each access) rather than a static field so edits to the baseline toggles above\r\n\t/// take effect on hotload \u2014 a static field would be restored to its pre-edit value.\u003C/summary\u003E\r\n\tpublic static CharacterAbilities Original =\u003E new();\r\n}\r\n\r\n/// \u003Csummary\u003EMovement sounds selected by a character. Keep cue identity here so adding character-specific\r\n/// footsteps, landings, or jumps does not require character checks in \u003Csee cref=\u0022Player\u0022/\u003E.\u003C/summary\u003E\r\npublic sealed class CharacterAudio\r\n{\r\n\tpublic SfxType Footstep { get; init; } = SfxType.PlayerWalk;\r\n\tpublic SfxType Land { get; init; } = SfxType.PlayerLand;\r\n\tpublic SfxType Jump { get; init; } = SfxType.PlayerJump;\r\n\tpublic SfxType AirJump { get; init; } = SfxType.PlayerAirJump;\r\n\tpublic SfxType Death { get; init; } = SfxType.PlayerSquish;\r\n\tpublic bool PlayLandSfx { get; init; } = true;\r\n\r\n\t/// \u003Csummary\u003EAir-jump pitch when the jump leaves zero charges, and when it leaves the greatest\r\n\t/// possible number of charges. Equal values produce a fixed pitch.\u003C/summary\u003E\r\n\tpublic float AirJumpPitchEmpty { get; init; } = 1.2f;\r\n\tpublic float AirJumpPitchFull { get; init; } = 1.2f;\r\n\r\n\tpublic float ResolveAirJumpPitch( int remaining, int maximum )\r\n\t{\r\n\t\tif ( maximum \u003C= 1 ) return AirJumpPitchEmpty;\r\n\t\tfloat fraction = Math.Clamp( remaining / (float)(maximum - 1), 0f, 1f );\r\n\t\treturn AirJumpPitchEmpty \u002B (AirJumpPitchFull - AirJumpPitchEmpty) * fraction;\r\n\t}\r\n}\r\n\r\npublic enum DeathFxStyle\r\n{\r\n\tBlood,\r\n\tSolar,\r\n\tSpring,\r\n\tBird,\r\n\tWrapper,\r\n\tGunner,\r\n}\r\n\r\npublic sealed class CharacterHelpRow\r\n{\r\n\t/// \u003Csummary\u003EShort ability name shown above its instructions.\u003C/summary\u003E\r\n\tpublic string Name { get; set; }\r\n\r\n\t/// \u003Csummary\u003EExplicit action used by this help row in sim v45\u002B. Zero keeps directional controls.\u003C/summary\u003E\r\n\tpublic int AbilitySlot { get; set; }\r\n\r\n\t/// \u003Csummary\u003EInstructions for the dedicated ability controls, omitted from legacy replay help.\u003C/summary\u003E\r\n\tpublic string AbilityHint { get; set; }\r\n\r\n\t/// \u003Csummary\u003EConcise character-specific instructions.\u003C/summary\u003E\r\n\tpublic string Text { get; set; }\r\n\r\n\t/// \u003Csummary\u003EOptional text override for help shown inside pre-v45 replays.\u003C/summary\u003E\r\n\tpublic string LegacyText { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether the instruction text needs extra separation from an oversized illustration.\u003C/summary\u003E\r\n\tpublic bool ExtraTextTopPadding { get; set; }\r\n\r\n\t/// \u003Csummary\u003EDirection keys shown before the instructions. On an AbilitySlot row, these are the\r\n\t/// legacy gesture shown only in pre-v45 replay help; current help displays the bound action.\u003C/summary\u003E\r\n\tpublic IReadOnlyList\u003CDirection\u003E Directions { get; set; } = Array.Empty\u003CDirection\u003E();\r\n\r\n\t/// \u003Csummary\u003EWhether direction keys are packed into one visual chord instead of spaced taps.\u003C/summary\u003E\r\n\tpublic bool CompactDirections { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row shows Spring\u0027s animated charge poses and surface-relative controls.\u003C/summary\u003E\r\n\tpublic bool ShowChargeJumpSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row alternates between normal and vertically flipped gravity controls.\u003C/summary\u003E\r\n\tpublic bool ShowGravityFlipSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row alternates between rightward and mirrored leftward dash controls.\u003C/summary\u003E\r\n\tpublic bool ShowHorizontalDashSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row alternates grounded-to-air long jumps and their directional chords.\u003C/summary\u003E\r\n\tpublic bool ShowLongJumpSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through every cardinal and diagonal dash input.\u003C/summary\u003E\r\n\tpublic bool ShowDashSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row alternates between mirrored wall-kick poses and side controls.\u003C/summary\u003E\r\n\tpublic bool ShowWallKickSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through every eight-way blink and post-blink force input.\u003C/summary\u003E\r\n\tpublic bool ShowTeleportSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through repeated cardinal and diagonal thread inputs.\u003C/summary\u003E\r\n\tpublic bool ShowThreadInputSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through Swapper\u0027s repeated cardinal and diagonal portal-shot inputs.\u003C/summary\u003E\r\n\tpublic bool ShowPortalShotSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through Gunner\u0027s repeated cardinal and diagonal shooting inputs.\u003C/summary\u003E\r\n\tpublic bool ShowGunnerShootSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row alternates Climber\u0027s upward and downward wall movement.\u003C/summary\u003E\r\n\tpublic bool ShowClimberWallSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through Climber\u0027s stationary and lateral ceiling movement.\u003C/summary\u003E\r\n\tpublic bool ShowClimberCeilingSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles from Climber\u0027s fast fall through its impact frames.\u003C/summary\u003E\r\n\tpublic bool ShowClimberFallSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through Rewind\u0027s explosion frames.\u003C/summary\u003E\r\n\tpublic bool ShowRewindExplosionSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row walks around each surface orientation.\u003C/summary\u003E\r\n\tpublic bool ShowShifterGravityGripSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row shows Twin 1\u0027s translucent phase-dash shake.\u003C/summary\u003E\r\n\tpublic bool ShowTwinPhaseDashSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through Mimic\u0027s shaking transformation frames.\u003C/summary\u003E\r\n\tpublic bool ShowMimicTransformSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether this row cycles through Mimic\u0027s horizontal and vertical squash recovery.\u003C/summary\u003E\r\n\tpublic bool ShowMimicSquashSequence { get; set; }\r\n\r\n\t/// \u003Csummary\u003EOptional text shown between direction keys.\u003C/summary\u003E\r\n\tpublic string DirectionSeparator { get; set; }\r\n\r\n\t/// \u003Csummary\u003EDirection index after which the separator is shown, or -1 for none.\u003C/summary\u003E\r\n\tpublic int DirectionSeparatorAfterIndex { get; set; } = -1;\r\n\r\n\t/// \u003Csummary\u003EHow many copies of the character preview to show before the instructions.\u003C/summary\u003E\r\n\tpublic int CharacterPreviewCount { get; set; }\r\n\r\n\t/// \u003Csummary\u003EExplicit preview images shown before the description, used for mixed-character rows.\u003C/summary\u003E\r\n\tpublic IReadOnlyList\u003Cstring\u003E CharacterPreviewImages { get; set; } = Array.Empty\u003Cstring\u003E();\r\n\r\n\t/// \u003Csummary\u003EOptional per-image opacity values for explicit character previews.\u003C/summary\u003E\r\n\tpublic IReadOnlyList\u003Cfloat\u003E CharacterPreviewOpacities { get; set; } = Array.Empty\u003Cfloat\u003E();\r\n\r\n\t/// \u003Csummary\u003EOptional per-image vertical offsets for explicit character previews.\u003C/summary\u003E\r\n\tpublic IReadOnlyList\u003Cfloat\u003E CharacterPreviewYOffsets { get; set; } = Array.Empty\u003Cfloat\u003E();\r\n\r\n\t/// \u003Csummary\u003EWhether row character previews are packed tightly together.\u003C/summary\u003E\r\n\tpublic bool CompactCharacterPreviews { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether explicit previews overlap to depict direct character contact.\u003C/summary\u003E\r\n\tpublic bool OverlapCharacterPreviews { get; set; }\r\n\r\n\t/// \u003Csummary\u003EExplicit preview index that receives the active-control marker, or -1 for none.\u003C/summary\u003E\r\n\tpublic int CharacterPreviewIndicatorIndex { get; set; } = -1;\r\n\r\n\t/// \u003Csummary\u003EOptional second preview index that crossfades with the active-control marker.\u003C/summary\u003E\r\n\tpublic int CharacterPreviewAlternateIndicatorIndex { get; set; } = -1;\r\n\r\n\t/// \u003Csummary\u003EShort text rendered immediately before the direction keys.\u003C/summary\u003E\r\n\tpublic string DirectionPrefix { get; set; }\r\n\r\n\t/// \u003Csummary\u003EOptional character pose rendered before this row\u0027s direction keys.\u003C/summary\u003E\r\n\tpublic string IllustrationImage { get; set; }\r\n\r\n\t/// \u003Csummary\u003EOptional second pose crossfaded with the row illustration.\u003C/summary\u003E\r\n\tpublic string IllustrationAlternateImage { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether the row illustration cycles clockwise through all four surface orientations.\u003C/summary\u003E\r\n\tpublic bool CycleIllustrationQuarterTurns { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether the row illustration uses the same soft glow as character previews.\u003C/summary\u003E\r\n\tpublic bool IllustrationGlow { get; set; }\r\n\r\n\t/// \u003Csummary\u003EWhether the row illustration is mirrored horizontally.\u003C/summary\u003E\r\n\tpublic bool IllustrationFlipHorizontal { get; set; }\r\n}\r\n\r\npublic sealed class CharacterHelpDef\r\n{\r\n\t/// \u003Csummary\u003EWhether a fresh profile opens this help automatically on the first eligible run.\u003C/summary\u003E\r\n\tpublic bool AutoShowOnFirstPlay { get; set; } = true;\r\n\r\n\t/// \u003Csummary\u003EOrdered ability instructions rendered by the in-game help overlay.\u003C/summary\u003E\r\n\tpublic IReadOnlyList\u003CCharacterHelpRow\u003E Rows { get; set; } = Array.Empty\u003CCharacterHelpRow\u003E();\r\n\r\n\t/// \u003Csummary\u003EHeader name override; null shows the character\u0027s \u003Csee cref=\u0022CharacterDef.Name\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic string Title { get; set; }\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// One selectable character: a stable id, a display name, the baked sprite sheet it renders with,\r\n/// a single-frame preview image for menus, and its \u003Csee cref=\u0022CharacterMovement\u0022/\u003E. Characters\r\n/// share the same animation names (idle/walk/air_*/wall_*/squish/explode) and the same collision\r\n/// size and interactions (crush, block-side presses) \u2014 only the art and the movement feel differ.\r\n/// The \u003Csee cref=\u0022Id\u0022/\u003E is baked into replay payloads, so never rename/reuse one once runs exist.\r\n/// \u003C/summary\u003E\r\npublic sealed class CharacterDef\r\n{\r\n\t/// \u003Csummary\u003EStable identity, lowercase kebab-case. Baked into replay payloads.\u003C/summary\u003E\r\n\tpublic string Id { get; set; }\r\n\r\n\t/// \u003Csummary\u003EDisplay name shown on the title-screen character picker.\u003C/summary\u003E\r\n\tpublic string Name { get; set; }\r\n\r\n\tprivate string _sentenceCaseSource;\r\n\tprivate string _sentenceCaseName;\r\n\t/// \u003Csummary\u003EProgress-tooltip name, with only the first letter capitalized. Cached for hover updates.\u003C/summary\u003E\r\n\tpublic string SentenceCaseName\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( _sentenceCaseSource != Name )\r\n\t\t\t{\r\n\t\t\t\t_sentenceCaseSource = Name;\r\n\t\t\t\t_sentenceCaseName = string.IsNullOrEmpty( Name ) ? Name\r\n\t\t\t\t\t: char.ToUpperInvariant( Name[0] ) \u002B Name[1..].ToLowerInvariant();\r\n\t\t\t}\r\n\t\t\treturn _sentenceCaseName;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EPath to the baked .sprite sheet this character renders with (see \u003Csee cref=\u0022Player\u0022/\u003E).\u003C/summary\u003E\r\n\tpublic string SpritePath { get; set; }\r\n\r\n\t/// \u003Csummary\u003EThe quad size (logical px) the sprite renders at, defaulting to the shared baseline\r\n\t/// \u003Csee cref=\u0022Player.ART_SIZE\u0022/\u003E. The engine treats this as a SQUARE bounding box that the texture\u0027s\r\n\t/// aspect ratio is fit inside (scaled by its LONGER edge), so it only renders 1:1 when this square\r\n\t/// equals the sheet\u0027s longer edge. A character whose sheet is taller/wider than the baseline (e.g.\r\n\t/// extra headroom for horns) sets this to match its own longer edge so its body still renders 1:1 and\r\n\t/// stays flush with the walls/hitbox \u2014 WITHOUT forcing every other character\u0027s sheet to be re-padded.\r\n\t/// The collision box (\u003Csee cref=\u0022Player.COLLISION_SIZE\u0022/\u003E) is independent of this and never changes.\u003C/summary\u003E\r\n\tpublic Vector2 ArtSize { get; set; } = Player.ART_SIZE;\r\n\r\n\t/// \u003Csummary\u003EA single static frame (raw PNG path) used as the menu preview thumbnail.\u003C/summary\u003E\r\n\tpublic string PreviewImage { get; set; }\r\n\r\n\t/// \u003Csummary\u003EScreen-pixel nudge for this character\u0027s icon in the HUD \u0022?\u0022 button\u0027s lower-left corner\r\n\t/// (\u002Bx right, \u002By down), so art with uneven padding sits visually flush with the others.\u003C/summary\u003E\r\n\tpublic Vector2 HelpButtonIconOffset { get; set; }\r\n\r\n\t/// \u003Csummary\u003EPreview-area scale that preserves the baseline character\u0027s source-pixel size.\u003C/summary\u003E\r\n\tpublic float PreviewScale\r\n\t\t=\u003E MathF.Max( ArtSize.x, ArtSize.y ) / MathF.Max( Player.ART_SIZE.x, Player.ART_SIZE.y );\r\n\r\n\t/// \u003Csummary\u003EAn independently configured second body spawned with this selectable character.\r\n\t/// The partner is not itself registered in \u003Csee cref=\u0022Characters.All\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic CharacterDef Partner { get; set; }\r\n\r\n\t/// \u003Csummary\u003EThis character\u0027s movement tuning. Uses the Original baseline unless overridden.\u003C/summary\u003E\r\n\tpublic CharacterMovement Movement { get; set; } = CharacterMovement.Original;\r\n\r\n\t/// \u003Csummary\u003EThis character\u0027s abilities (toggles). Uses the Original set unless overridden.\u003C/summary\u003E\r\n\tpublic CharacterAbilities Abilities { get; set; } = CharacterAbilities.Original;\r\n\r\n\t/// \u003Csummary\u003EThis character\u0027s movement sound cues and air-jump pitch progression.\u003C/summary\u003E\r\n\tpublic CharacterAudio Audio { get; set; } = new();\r\n\r\n\t/// \u003Csummary\u003EMaterial-specific debris emitted by every player death path.\u003C/summary\u003E\r\n\tpublic DeathFxStyle DeathFx { get; set; } = DeathFxStyle.Blood;\r\n\r\n\t/// \u003Csummary\u003EBlood tint used by organic death effects.\u003C/summary\u003E\r\n\tpublic Color BloodColor { get; set; } = new( 200f / 255f, 30f / 255f, 25f / 255f );\r\n\r\n\t/// \u003Csummary\u003EOptional player-facing ability help. Null disables the in-game help button.\u003C/summary\u003E\r\n\tpublic CharacterHelpDef Help { get; set; }\r\n\r\n\t/// \u003Csummary\u003EHelp shown while a Mimic wears this shape (always a single body, so a paired character\r\n\t/// teaches only the one twin\u0027s ability). Null falls back to \u003Csee cref=\u0022Help\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic CharacterHelpDef MimicFormHelp { get; set; }\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// The static character registry. Ordered as shown on the title-screen picker; \u003Csee cref=\u0022Original\u0022/\u003E\r\n/// is always first and is what a run uses when no character is chosen\r\n/// or a replay names an unknown character.\r\n/// \u003C/summary\u003E\r\npublic static class Characters\r\n{\r\n\tpublic const string OriginalId = \u0022original\u0022;\r\n\r\n\t// The definitions below are (re)built by Reload(), NOT initialised inline \u2014 same reasoning as\r\n\t// Levels.Reload(): s\u0026box hotload restores the previous values of static fields instead of re-running\r\n\t// their initialisers, so inline-initialised defs keep their stale art/tuning after a code edit until\r\n\t// a full editor restart. Building them in a method the \u0022reload_characters\u0022 ConCmd can re-invoke\r\n\t// lets character edits take effect live. The static ctor runs Reload() once on first access.\r\n\r\n\t/// \u003Csummary\u003EThe original player.\u003C/summary\u003E\r\n\tpublic static CharacterDef Original { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA purple recolour of the original. Same size/collisions with blocks and the same\r\n\t/// movement feel, but it can\u0027t bounce and it phases through the arena walls (unless spiked),\r\n\t/// wrapping to the opposite edge instead.\u003C/summary\u003E\r\n\tpublic static CharacterDef Wrapper { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA cyan recolour of the original. Can\u0027t dash/bounce/wall-dive (like Wrapper) but collides\r\n\t/// with the arena walls normally. On spawn it drops a persistent portal, and pressing all four\r\n\t/// inputs on the same frame swaps its position with that portal.\u003C/summary\u003E\r\n\tpublic static CharacterDef Swapper { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EAn orange gunner. Has NONE of the special movement abilities (no dash / bounce /\r\n\t/// wall-kick / wall-dive) \u2014 just plain running, jumping and wall-jumping \u2014 but carries a unique kit:\r\n\t/// it reloads ammo by crouching (hold the toward-floor key grounded) and fires a fast bullet by\r\n\t/// DOUBLE-TAPPING a direction (the dash\u0027s gesture; the gunner has no dash, so it\u0027s unambiguous).\r\n\t/// See \u003Csee cref=\u0022GunnerAbility\u0022/\u003E and \u003Csee cref=\u0022Bullet\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic static CharacterDef Gunner { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA magenta flipper. It has NO jump and CAN\u0027T wall-jump \u2014 instead, pressing jump/up while\r\n\t/// standing on a floor flips its own gravity (it \u0022falls\u0022 up to the ceiling), and pressing down while\r\n\t/// hanging upside-down flips it back. Entering a Reverse block\u0027s field while already self-flipped\r\n\t/// cancels out to normal gravity. It also has an eight-way dash, recharged only by inverting.\r\n\t/// See \u003Csee cref=\u0022CharacterAbilities.CanFlipGravity\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic static CharacterDef Flipper { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA white bird. A light, floaty flyer: low gravity, an almost-halted descent while holding\r\n\t/// Up, and four air jumps to stay aloft. It\u0027s a poor walker (very low ground speed with grippy, snappy\r\n\t/// footing) but glides well (little air braking). Catches the wind and a siren\u0027s song far harder than\r\n\t/// any other character.\u003C/summary\u003E\r\n\tpublic static CharacterDef Bird { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA lime-green spring with firm ground traction and committed airborne trajectories. Its\r\n\t/// ground charge aims with Left/Right; holding into a wall charges a jump whose vertical angle is aimed\r\n\t/// with Up/Down. It can cling briefly but cannot bounce or perform a conventional wall jump.\u003C/summary\u003E\r\n\tpublic static CharacterDef Spring { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA coordinated group that periodically creates shared-input copies and continues from a\r\n\t/// random survivor whenever the currently authoritative body dies.\u003C/summary\u003E\r\n\tpublic static CharacterDef Swarm { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA momentum-heavy aerial mover that can return to its position and movement state from\r\n\t/// two seconds ago by pressing all four directions.\u003C/summary\u003E\r\n\tpublic static CharacterDef Rewind { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EAn ammo-limited eight-way grappler that can steer while firing and being pulled, with\r\n\t/// up to three extending hooks plus three pulling lines (acceleration currently disabled) anchored\r\n\t/// to blocks, obstacles, or arena walls. Repeating an anchored line\u0027s launch gesture removes that\r\n\t/// line; pressing all four directions within the standard leniency window destroys every active\r\n\t/// line.\u003C/summary\u003E\r\n\tpublic static CharacterDef Grappler { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA cautious surface specialist with weak air control, long wall grips, wall climbing,\r\n\t/// ceiling clinging, and low, strongly horizontal wall jumps. Hard floor impacts are lethal.\u003C/summary\u003E\r\n\tpublic static CharacterDef Climber { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA floaty aerial mover that can charge one directional blink per airtime.\u003C/summary\u003E\r\n\tpublic static CharacterDef Blinker { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA cardinal-gravity runner that turns every newly contacted surface into its floor.\u003C/summary\u003E\r\n\tpublic static CharacterDef Shifter { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA sunlight-powered robot whose energy controls its movement strength.\u003C/summary\u003E\r\n\tpublic static CharacterDef Solar { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EA slow, ability-less shape-shifter that temporarily assumes other character forms.\u003C/summary\u003E\r\n\tpublic static CharacterDef Mimic { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EThe selectable first twin. Its partner is \u003Csee cref=\u0022TwinTwo\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic static CharacterDef Twins { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EThe independently configured second twin. Not selectable on its own.\u003C/summary\u003E\r\n\tpublic static CharacterDef TwinTwo { get; private set; }\r\n\r\n\t/// \u003Csummary\u003EEvery character, in title-screen picker order (Original first).\u003C/summary\u003E\r\n\tpublic static IReadOnlyList\u003CCharacterDef\u003E All { get; private set; }\r\n\r\n\tstatic Characters() =\u003E Reload();\r\n\r\n\t// Reorder this list to change the character picker\u0027s rows without moving definition blocks.\r\n\tprivate static IReadOnlyList\u003CCharacterDef\u003E BuildPickerOrder() =\u003E new[]\r\n\t{\r\n\t\tOriginal,\r\n\t\tFlipper,\r\n\t\tBlinker,\r\n\t\tGrappler,\r\n\t\tBird,\r\n\t\tSwapper,\r\n\t\tSpring,\r\n\t\tGunner,\r\n\t\tTwins,\r\n\t\tShifter,\r\n\t\tClimber,\r\n\t\tRewind,\r\n\t\tSolar,\r\n\t\tWrapper,\r\n\t\tSwarm,\r\n\t\tMimic,\r\n\t};\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Rebuild every static character definition from source. Runs once via the static constructor and\r\n\t/// again whenever the \u003Cc\u003Ereload_characters\u003C/c\u003E ConCmd fires, so edits to a character\u0027s art or\r\n\t/// tuning take effect without restarting the editor (hotload otherwise restores the pre-edit\r\n\t/// instances).\r\n\t///\r\n\t/// NOTE: consumers that cache the registry at construction won\u0027t auto-refresh \u2014 \u003Csee cref=\u0022TitleStage\u0022/\u003E\r\n\t/// reads the selected character on enter, so re-enter the title/picker to pick up a renamed character\r\n\t/// or new preview art. A character\u0027s movement/ability edits apply to the next run that spawns them.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Reload()\r\n\t{\r\n\t\tOriginal = new()\r\n\t\t{\r\n\t\t\tId = OriginalId,\r\n\t\t\tName = \u0022NORMIE\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( 0, 3 ),\r\n\t\t\tMovement = CharacterMovement.Original,\r\n\t\t\t// The baseline moveset opts INTO every gated ability (they now default off so new\r\n\t\t\t// characters start minimal and add only what they need).\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tCanBounce = true,\r\n\t\t\t\t//CanDash = true,\r\n\t\t\t\tCanWallDive = true,\r\n\t\t\t\tCanNeutralWallJump = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tAutoShowOnFirstPlay = false,\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022BOUNCE\u0022, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player/player_air_fall_fast.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t\t//new() { Name = \u0022DASH\u0022, IllustrationImage = \u0022sprites/ui/characters/player/player_air_up.png\u0022, IllustrationGlow = true, ShowHorizontalDashSequence = true },\r\n\t\t\t\t\t// actually WallDive ability, but kick is a better description\r\n\t\t\t\t\tnew() { Name = \u0022WALL KICK\u0022, IllustrationImage = \u0022sprites/ui/characters/player/player_wall_down.png\u0022, IllustrationGlow = true, IllustrationFlipHorizontal = true, CompactDirections = true, ShowWallKickSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tWrapper = new()\r\n\t\t{\r\n\t\t\tId = \u0022wrapper\u0022,\r\n\t\t\tName = \u0022WRAITH\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_wrapper.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_wrapper/player_walk_0.png\u0022,\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tDeathFx = DeathFxStyle.Wrapper,\r\n\t\t\tMovement = new CharacterMovement \r\n\t\t\t{ \r\n\t\t\t\tMaxRiseSpeed = 500.0f, \r\n\t\t\t\tMaxFallSpeed = 300.0f,\r\n\t\t\t\tJumpPower = 140.0f,\r\n\t\t\t\tGravity = 250.0f,\r\n\t\t\t\tActiveDownGravityRiseFactor = 3f,\r\n\r\n\t\t\t\tWallJumpHorizontalPower = 105.0f,\r\n\t\t\t\tWallJumpVerticalPower = 130.0f,\r\n\t\t\t\tWallJumpTime = 0.4f,\r\n\t\t\t\tWallGravityFactor = 0.025f,\r\n\r\n\t\t\t\tActiveUpGravityFactor = 0.1f,\r\n\t\t\t\tActiveUpGravityFallOnly = true,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tWrapsArenaEdges = true,\r\n\t\t\t\tCollidesWithArenaWalls = false,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022PHASE THROUGH WALLS\u0022, IllustrationImage = \u0022sprites/ui/characters/player_wrapper/player_air_up.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tSwapper = new()\r\n\t\t{\r\n\t\t\tId = \u0022swapper\u0022,\r\n\t\t\tName = \u0022MAGICIAN\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_swapper.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_swapper/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\t// A light, airy, slippery feel to set it apart from Original \u2014 punchy single jump with fixed-height\r\n\t\t\t// wall jumps, so it can\u0027t chain jumps / wall-jumps into ever more rise speed. Add\r\n\t\t\t// hang-time (a lighter hold-up gravity), lower ground friction (keeps horizontal momentum),\r\n\t\t\t// a faster/less-grippy wall slide, a slightly lower top run speed (it\u0027s already strong with\r\n\t\t\t// the swap), and a light \u0022kick\u0022 wall-jump so the swap \u2014 not wall-jumping \u2014 is its main mobility\r\n\t\t\t// tool. Movement differs only for this new character, so existing Original/Wrapper replays are\r\n\t\t\t// unaffected and no Sim.VERSION bump is needed (movement is a pure fn of CharacterId).\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 55.0f,                \r\n\t\t\t\tJumpPower = 100.0f,                 \r\n\t\t\t\tMaxFallSpeed = 185.0f,              // controlled descent without making the character overly floaty\r\n\t\t\t\tActiveUpGravityFactor = 0.40f,      // hang-time: holding Up drifts down slowly (0.5 default)\r\n\t\t\t\tHorizontalDeceleration = 340.0f,\r\n\t\t\t\tWallGravityFactor = 0.25f,          // less wall grip (0.15 default) \u2014 slides down faster\r\n\t\t\t\tWallJumpHorizontalPower = 25.0f,    // light \u0022kick\u0022 wall-jump (70 default) \u2014 swap is the real mobility\r\n\t\t\t\tWallJumpVerticalPower = 95.0f,      // weaker rise than its ground jump \u2014 can\u0027t out-climb by wall-jumping\r\n\t\t\t\tWallJumpStacksVerticalVelocity = false,\r\n\t\t\t\tWallJumpTime = 1.5f,\r\n\t\t\t\tApexHangGravityFactor = 0.4f,\r\n\t\t\t\tApexHangVelThreshold = 10f,\r\n\t\t\t\tHoverFrames = 80,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\t// CanBounce/CanDash/CanWallDive/CanNeutralWallJump all stay at their new false default.\r\n\t\t\t\tHasSwapPortal = true,\r\n\t\t\t\tHoverRefreshOnFloor = false,\r\n\t\t\t\tIgnoreWallJumpDoubleTaps = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022PORTAL SWAP\u0022, AbilitySlot = 2, Directions = new[] { Direction.Left, Direction.Down, Direction.Up, Direction.Right }, CompactDirections = true, IllustrationImage = \u0022sprites/ui/swapper_portal_swap.png\u0022 },\r\n\t\t\t\t\tnew() { Name = \u0022PORTAL SHOT\u0022, AbilitySlot = 1, IllustrationImage = \u0022sprites/ui/swapper_portal_shot.png\u0022, ShowPortalShotSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022HOVER\u0022, Text = \u0022Recharge with Portal Swap\u0022, Directions = new[] { Direction.Up }, IllustrationImage = \u0022sprites/ui/characters/player_swapper/player_air_down_active.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tGunner = new()\r\n\t\t{\r\n\t\t\tId = \u0022gunner\u0022,\r\n\t\t\tName = \u0022GUNNER\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_gunner.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_gunner/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tDeathFx = DeathFxStyle.Gunner,\r\n\t\t\t// Ordinary movement feel (the shared baseline). Its traversal identity comes from ledge grabs\r\n\t\t\t// and recoil from its own shots rather than wall movement.\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tHorizontalAcceleration = 400f,\r\n\t\t\t\tHorizontalDeceleration = 400f,\r\n\t\t\t\tJumpPower = 100f,\r\n\t\t\t\tAirFrictionFactor = 0.1f,\r\n\t\t\t\tRiseGravityFactor = 0.75f,\r\n\t\t\t\tFallGravityFactor = 1.5f,\r\n\t\t\t\tGravity = 210f,\r\n\t\t\t\tActiveUpGravityFactor = 1f,\r\n\t\t\t\tActiveDownGravityFactor = 1.5f,\r\n\t\t\t\tActiveDownGravityRiseFactor = 5f,\r\n\t\t\t\tLandingMomentumRetention = 0.5f,\r\n\t\t\t\tTurnaroundDeceleration = 200f,\r\n\r\n\t\t\t\t//MaxXSpeed = 60f,\r\n\t\t\t\t//MaxAirXSpeed = 78f,\r\n\t\t\t\t//MaxRiseSpeed = 180f,\r\n\t\t\t\t//MaxFallSpeed = 120f,\r\n\t\t\t\t//HorizontalAcceleration = 320f,\r\n\t\t\t\t//HorizontalDeceleration = 110f,\r\n\t\t\t\t//AirAccelerationFactor = 0.8f,\r\n\t\t\t\t//AirFrictionFactor = 0.5f,\r\n\t\t\t\t//JumpPower = 86f,\r\n\t\t\t\t//WallJumpHorizontalPower = 55f,\r\n\t\t\t\t//WallJumpVerticalPower = 85f,\r\n\t\t\t\t//Gravity = 135f,\r\n\t\t\t\t//ActiveDownGravityFactor = 1f,\r\n\t\t\t\t//ActiveDownGravityRiseFactor = 1f,\r\n\t\t\t\t//ActiveUpGravityFactor = 1f,\r\n\t\t\t\t//WallJumpTime = 0.5f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\t// No wall hug or wall jump. CanBounce/CanDash/CanWallKick/CanWallDive stay at their false\r\n\t\t\t\t// defaults; dash in particular would collide with the double-tap fire input.\r\n\t\t\t\tHasGunner = true,\r\n\t\t\t\tCanLedgeGrab = true,\r\n\t\t\t\tCanWallHug = false,\r\n\t\t\t\tCanWallJump = false,\r\n\r\n\t\t\t\tVariableJumpHeight = true,\r\n\t\t\t\tVariableJumpCutFactor = 0.4f,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022SHOOT\u0022, AbilitySlot = 1, ShowGunnerShootSequence = true, IllustrationGlow = true },\r\n\t\t\t\t\tnew() { Name = \u0022RELOAD\u0022, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player_gunner/gun_reload_0.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_gunner/gun_reload_1.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t\tnew() { Name = \u0022LEDGE GRAB\u0022, IllustrationImage = \u0022sprites/ui/characters/player_gunner/player_wall_down.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tFlipper = new()\r\n\t\t{\r\n\t\t\tId = \u0022flipper\u0022,\r\n\t\t\tName = \u0022TURVY\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_flipper.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_flipper/player_idle.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tBloodColor = new Color( 72f / 255f, 212f / 255f, 68f / 255f ),\r\n\t\t\tMovement = new CharacterMovement \r\n\t\t\t{ \r\n\t\t\t\tGravity = 220.0f, \r\n\t\t\t\tMaxXSpeed = 75.0f, \r\n\t\t\t\tHorizontalAcceleration = 350,\r\n\t\t\t\tHorizontalDeceleration = 180,\r\n\t\t\t\tAirAccelerationFactor = 0.8f,\r\n\t\t\t\tAirFrictionFactor = 0.3f,\r\n\t\t\t\t// Vertical control (these read the orientation-SWAPPED up/down, so \u0022up\u0022 is physical down\r\n\t\t\t\t// when self-flipped / in a reverse field \u2014 the fast/slow directions stay relative to the\r\n\t\t\t\t// current floor). Base gravity is a touch gentle so the normal fall drifts\r\n\t\t\t\tActiveUpGravityFactor = 0.15f,\r\n\t\t\t\tActiveDownGravityFactor = 2.5f,\r\n\t\t\t\t\r\n\t\t\t\t// More wall friction than the baseline (0.15)\r\n\t\t\t\tWallGravityFactor = 0.03f,\r\n\t\t\t\tWallSlideMaxFallSpeed = 35f,\r\n\t\t\t\tJumpBufferFrames = 6,\r\n\r\n\t\t\t\t//HoverFrames = 12,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tCanFlipGravity = true,\r\n\t\t\t\tCanDash = true,\r\n\t\t\t\tCanDashOnGround = true,\r\n\t\t\t\tDashRefreshOnFloor = false,\r\n\t\t\t\tDashForce = 100f,\r\n\t\t\t\tDashForceAlongGravity = 300f,\r\n\t\t\t\tDashGravitySuppressTime = 0.25f,\r\n\t\t\t\tDashGravitySuppressTimeAlongGravity = 0f,\r\n\t\t\t\t// All eight directions, like the Climber/Rewind. Up/down read PHYSICAL directions, so a\r\n\t\t\t\t// self-flipped Flipper dashing \u0022up\u0022 still goes up the screen.\r\n\t\t\t\tCanDashHorizontal = true,\r\n\t\t\t\tCanDashUp = true,\r\n\t\t\t\tCanDashDown = true,\r\n\t\t\t\tConsolidateDashHorizontalVelocity = true,\r\n\t\t\t\tCanWallJump = false,\r\n\t\t\t\t//MaxAirJumps = 1,\r\n\t\t\t\t//AirJumpPower = 30f,\r\n\t\t\t\t//AirJumpHorizontalBoost = 110f,\r\n\t\t\t\t//CanLongJump = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022INVERT\u0022, IllustrationImage = \u0022sprites/ui/characters/player_flipper/player_idle.png\u0022, IllustrationGlow = true, ShowGravityFlipSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022DASH\u0022, AbilitySlot = 1, Text = \u0022Recharge with Invert\u0022, IllustrationImage = \u0022sprites/ui/characters/player_flipper/player_air_up.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_flipper/player_air_down.png\u0022, IllustrationGlow = true, ShowDashSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tBird = new()\r\n\t\t{\r\n\t\t\tId = \u0022bird\u0022,\r\n\t\t\tName = \u0022OWL\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_bird.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_bird/player_walk_0.png\u0022,\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tAudio = new CharacterAudio\r\n\t\t\t{\r\n\t\t\t\tAirJump = SfxType.BirdAirJump,\r\n\t\t\t\tAirJumpPitchEmpty = 1.05f,\r\n\t\t\t\tAirJumpPitchFull = 1.45f,\r\n\t\t\t},\r\n\t\t\tDeathFx = DeathFxStyle.Bird,\r\n\t\t\t// A light, floaty flyer.\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tHorizontalAcceleration = 85f,\r\n\t\t\t\tHorizontalDeceleration = 140f,\r\n\t\t\t\tGravity = 188.0f,                // lower gravity \u2014 floaty (300 default)\r\n\t\t\t\tJumpPower = 70.0f,               // weak jump \u2014 height comes from flapping (112 default)\r\n\t\t\t\tWallJumpVerticalPower = 65.0f,   // weak wall-jump rise (112 default) \u2014 no floating up walls\r\n\t\t\t\tWallJumpHorizontalPower = 135.0f, // (70 default)\r\n\t\t\t\tMaxXSpeed = 41.0f,               // very low GROUND move speed (86 default)\r\n\t\t\t\tMaxAirXSpeed = 54.0f,\r\n\t\t\t\tDiveMaxAirXSpeed = 110.0f,        // hold Down to swoop faster \u2014 flap Up for height, dive for distance\r\n\t\t\t\tDiveHorizontalAcceleration = 125.0f, // the swoop builds toward the dive cap on its own\r\n\t\t\t\tOverspeedDecay = 270f,\r\n\t\t\t\tMaxFallSpeed = 105.0f,           // slow glide-down terminal, even while actively diving\r\n\t\t\t\tMaxRiseSpeed = 77.0f,            // safety cap so flaps/wall-jumps can\u0027t stack into a huge rise\r\n\t\t\t\tFallGravityFactor = 0.7f,        // descending takes longer than climbing\r\n\t\t\t\tActiveUpGravityFactor = 0.27f,   // much lower fall speed while holding Up (0.5 default)\r\n\t\t\t\tActiveDownGravityFactor = 1.2f,  // Down still dives faster, but cannot defeat the slow descent\r\n\t\t\t\tActiveDownOverridesUp = true,    // holding Down cancels a held Up \u2014 a dive can\u0027t be floated\r\n\t\t\t\t\t\t\t\t\t\t\t\t //ActiveUpGravityFallOnly = true,  // \u2026but ONLY the DESCENT \u2014 so holding Up can\u0027t float jumps sky-high\r\n\t\t\t\tAirAccelerationFactor = 0.95f,    // ordinary air steering cannot quickly reverse a glide\r\n\t\t\t\tAirFrictionFactor = 0.04f,       // almost no natural air braking \u2014 redirect with a flap\r\n\t\t\t\tGroundFrictionFactor = 5f,     // very high GROUND x-deceleration \u2014 snappy stop (1.0 default)\r\n\t\t\t\tApexHangGravityFactor = 0.4f,    // extra hang-time at the top of the arc (1.0 = off)\r\n\t\t\t\tApexHangVelThreshold = 12.0f,    // \u2026while |VelY| \u003C 16 px/s\r\n\t\t\t\t//HoverFrames = 14,                // hold Up in the air to hover/glide briefly (once per airtime)\r\n\t\t\t\t//WindCatchFactor = 1.9f,          // more affected by wind (1.0 default)\r\n\t\t\t\t//SirenCatchFactor = 1.6f,         // more affected by a siren\u0027s song (1.0 default)\r\n\t\t\t\tWallJumpTime = 2.0f,\r\n\t\t\t\tWallRiseGravityFactor = 2.5f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\t// A pure flyer: no dash / wall-dive \u2014 its mobility is flapping (four air jumps) and gliding.\r\n\t\t\t\t// Weak flaps but four of them, each with a small directional nudge.\r\n\t\t\t\tMaxAirJumps = 6,\r\n\t\t\t\tAirJumpPower = 70.0f,\r\n\t\t\t\tAirJumpWhileHoldingDown = true,  // a flap mid-swoop still fires (Down is a travel mode here)\r\n\t\t\t\tAirJumpHorizontalBoost = 65.0f,  // each flap nudges the held way \u2014 birdlike directional flap\r\n\t\t\t\tCanLongJump = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022FLAP\u0022, Text = \u0022Recharge by landing\u0022, Directions = new[] { Direction.Up }, IllustrationImage = \u0022sprites/ui/characters/player_bird/player_air_up.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_bird/player_air_up_active.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t\tnew() { Name = \u0022SWOOP\u0022, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player_bird/player_air_fall_fast.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t\tnew() { Name = \u0022LONG JUMP\u0022, AbilitySlot = 1, IllustrationImage = \u0022sprites/ui/characters/player_bird/player_walk_0.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_bird/player_air_up.png\u0022, IllustrationGlow = true, ShowLongJumpSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tSpring = new()\r\n\t\t{\r\n\t\t\tId = \u0022spring\u0022,\r\n\t\t\tName = \u0022JACK\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_spring.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_spring/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tAudio = new CharacterAudio { Death = SfxType.SpringDeath },\r\n\t\t\tDeathFx = DeathFxStyle.Spring,\r\n\t\t\t// A fast charge-jumper with firm ground traction and no control once airborne. MaxRiseSpeed is\r\n\t\t\t// left at its high default (1500), comfortably above the fully-charged launch speeds\r\n\t\t\t// (ChargeJumpMaxSpeed 340, wall 370), so a straight-up max charge jump is never clamped.\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 45.0f,       \r\n\t\t\t\tMaxAirXSpeed = 385.0f,\r\n\t\t\t\tJumpPower = 90f,\r\n\t\t\t\tHorizontalAcceleration = 220.0f,\r\n\t\t\t\tHorizontalDeceleration = 400.0f,\r\n\t\t\t\tAirFrictionFactor = 0.0f,\r\n\t\t\t\tAirAccelerationFactor = 0.15f,\r\n\t\t\t\t//MaxRiseSpeed = 420.0f,\r\n\t\t\t\t//MaxFallSpeed = 420.0f,\r\n\t\t\t\tGravity = 320.0f,\r\n\t\t\t\tActiveDownGravityRiseFactor = 1.1f, // holding Down barely brakes the ASCENT (still drops hard \u2014 2.0 \u2014 on the way down)\r\n\t\t\t\tWallJumpLeniencyFrames = 1,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tAutoBounceGround = true,\r\n\t\t\t\tAutoBounceGroundRestitution = 0.7f,\r\n\t\t\t\tAutoBounceGroundMinSpeed = 120.0f,\r\n\t\t\t\tAutoBounceGroundImpactFeedbackFactor = 0.5f,\r\n\t\t\t\tAutoBounceGroundCompressionFrames = 6,\r\n\r\n\t\t\t\tAutoBounceWall = true,\r\n\t\t\t\tAutoBounceWallRestitution = 0.7f,\r\n\t\t\t\tAutoBounceWallMinSpeed = 120.0f,\r\n\t\t\t\t//AutoBounceWallMinStrength = 10f,\r\n\r\n\t\t\t\tAutoBounceCeiling = true,\r\n\t\t\t\tAutoBounceCeilingMinSpeed = 120.0f,\r\n\t\t\t\tAutoBounceCeilingRestitution = 0.7f,\r\n\r\n\t\t\t\tCanWallJump = false,\r\n\t\t\t\tWallClingFrames = 90,\r\n\r\n\t\t\t\t// The signature move (see Player.HandleChargeJump).\r\n\t\t\t\tHasChargeJump = true,\r\n\t\t\t\tHasChargeWallJump = true,\r\n\t\t\t\tHasChargeCeilingJump = true,\r\n\t\t\t\tChargeJumpAutoFireAtMax = false,\r\n\t\t\t\tChargeJumpMaxTime = 0.5f,\r\n\t\t\t\tChargeJumpMinSpeed = 130.0f,\r\n\t\t\t\tChargeJumpMaxSpeed = 380.0f,\r\n\t\t\t\tChargeJumpMinAngleDeg = 20.0f,\r\n\t\t\t\tChargeJumpDirectionalPoseThreshold = 0.25f,\r\n\t\t\t\tChargeWallJumpMaxTime = 0.75f,\r\n\t\t\t\tChargeWallJumpMinSpeed = 130.0f,\r\n\t\t\t\tChargeWallJumpMaxSpeed = 380.0f,\r\n\t\t\t\tChargeWallJumpGravitySuppressTime = 0.12f,\r\n\t\t\t\t// Sticky blocks: the charge can be wound against the glue itself and tears free at a cost.\r\n\t\t\t\t// Only a COMPLETELY full wind tears out of either phase (the wind-up clamps at max, so holding\r\n\t\t\t\t// past it and releasing always qualifies), and the goo saps most of the launch on the way out.\r\n\t\t\t\tStickyChargeReleaseFraction = 1.0f,\r\n\t\t\t\tStickyChargeReleaseFractionPhase2 = 1.0f,\r\n\t\t\t\tStickyChargePowerFactor = 0.4f,\r\n\t\t\t\tStickyChargePowerFactorPhase2 = 0.4f,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022SPRING JUMP\u0022, Text = \u0022Jump to cancel\u0022, DirectionPrefix = \u0022Hold\u0022, CompactDirections = true, ShowChargeJumpSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tSwarm = new()\r\n\t\t{\r\n\t\t\tId = \u0022swarm\u0022,\r\n\t\t\tName = \u0022SWARM\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_swarm.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_swarm/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -3, -3 ),\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 55.0f,\r\n\t\t\t\tHorizontalAcceleration = 165.0f,\r\n\t\t\t\tHorizontalDeceleration = 260.0f,\r\n\t\t\t\tMaxAirXSpeed = 120f,\r\n\t\t\t\tJumpPower = 90.0f,\r\n\t\t\t\tGravity = 350.0f,\r\n\t\t\t\tJumpStrengthMinFactor = 0.45f,\r\n\t\t\t\tJumpStrengthMaxFactor = 1.39f,\r\n\t\t\t\tWallJumpStrengthMinFactor = 0.75f,\r\n\t\t\t\tWallJumpStrengthMaxFactor = 1.49f,\r\n\t\t\t\tWallJumpHorizontalPower = 35.0f,\r\n\t\t\t\tWallJumpVerticalPower = 95.0f,\r\n\t\t\t\tAirAccelerationFactor = 0.75f,\r\n\t\t\t\tAirFrictionFactor = 0.4f,\r\n\t\t\t\t//HoverFrames = 34,\r\n\t\t\t\tWallJumpTime = 0.55f,\r\n\t\t\t\tJumpBufferFrames = 1,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\t//CanBounce = true,\r\n\t\t\t\tHasSwarm = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022MITOSIS\u0022, CharacterPreviewCount = 7 },\r\n\t\t\t\t\tnew() { Name = \u0022UNPREDICTABLE JUMP\u0022, CharacterPreviewImages = new[] { \u0022sprites/ui/characters/player_swarm/player_air_up.png\u0022, \u0022sprites/ui/characters/player_swarm/player_air_up.png\u0022 }, CharacterPreviewYOffsets = new[] { 10f, -10f } },\r\n\t\t\t\t\t//new() { Name = \u0022BOUNCE\u0022, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player_swarm/player_air_fall_fast.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tGrappler = new()\r\n\t\t{\r\n\t\t\tId = \u0022grappler\u0022,\r\n\t\t\tName = \u0022SPIDER\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_grappler.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_grappler/player_walk_0.png\u0022,\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tBloodColor = new Color( 10f / 255f, 5f / 255f, 60f / 255f ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 66.0f,\r\n\t\t\t\tMaxAirXSpeed = 74.0f,\r\n\t\t\t\tAirAccelerationFactor = 0.3f,\r\n\t\t\t\tHorizontalAcceleration = 310.0f,\r\n\t\t\t\tJumpPower = 70.0f,\r\n\t\t\t\tWallJumpVerticalPower = 66.0f,\r\n\t\t\t\tWallJumpHorizontalPower = 90f,\r\n\t\t\t\tGravity = 220.0f,\r\n\t\t\t\tApexHangGravityFactor = 0.7f,\r\n\t\t\t\tApexHangVelThreshold = 10f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tHasGrappler = true,\r\n\t\t\t\tWallClingFrames = 20,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022CREATE OR DESTROY THREAD\u0022, AbilitySlot = 1, ShowThreadInputSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022DESTROY ALL THREADS\u0022, AbilitySlot = 2, Directions = new[] { Direction.Left, Direction.Down, Direction.Up, Direction.Right }, CompactDirections = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tClimber = new()\r\n\t\t{\r\n\t\t\tId = \u0022climber\u0022,\r\n\t\t\tName = \u0022CLIMBER\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_climber.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_climber/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( 3, 3 ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 78.0f,\r\n\t\t\t\tMaxAirXSpeed = 72.0f,\r\n\t\t\t\tHorizontalAcceleration = 360.0f,\r\n\t\t\t\tHorizontalDeceleration = 300.0f,\r\n\t\t\t\tAirAccelerationFactor = 0.12f,\r\n\t\t\t\tAirFrictionFactor = 0.18f,\r\n\t\t\t\tJumpPower = 92.0f,\r\n\t\t\t\tGravity = 340.0f,\r\n\t\t\t\tMaxFallSpeed = 370.0f,\r\n\t\t\t\tActiveDownGravityFactor = 1.1f,\r\n\t\t\t\tWallGravityFactor = 0.12f,\r\n\t\t\t\tWallSlideMaxFallSpeed = 32.0f,\r\n\t\t\t\tWallClimbSpeed = 74.0f,\r\n\t\t\t\tWallJumpHorizontalPower = 175.0f,\r\n\t\t\t\tWallJumpVerticalPower = 28.0f,\r\n\t\t\t\tWallJumpTime = 1.15f,\r\n\t\t\t\tWallJumpLeniencyFrames = 10,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tWallClingFrames = 90,\r\n\t\t\t\tCanCeilingCling = true,\r\n\t\t\t\tFallDamageImpactSpeed = 255.0f,\r\n\r\n\t\t\t\tCanDash = true,\r\n\t\t\t\tCanDashOnGround = true,\r\n\t\t\t\tDashForce = 150f,\r\n\t\t\t\tDashGravitySuppressTime = 0.35f,\r\n\t\t\t\tCanDashHorizontal = true,\r\n\t\t\t\tCanDashUp = true,\r\n\t\t\t\tCanDashDown = true,\r\n\t\t\t\t//DashMomentumFactor = 0.1f,\r\n\t\t\t\tConsolidateDashHorizontalVelocity = true,\r\n\t\t\t\tPreserveDashVerticalVelocity = true,\r\n\t\t\t\t// Recharged by moving on surfaces, never by merely landing.\r\n\t\t\t\tDashRefreshOnFloor = false,\r\n\t\t\t\tDashRechargeFloorDistance = 60f,\r\n\t\t\t\tDashRechargeWallDistance = 60f,\r\n\t\t\t\tDashRechargeCeilingDistance = 60f,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022WALL CLIMB\u0022, DirectionPrefix = \u0022Hold\u0022, IllustrationGlow = true, ShowClimberWallSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022CEILING HANG\u0022, DirectionPrefix = \u0022Hold\u0022, IllustrationGlow = true, ShowClimberCeilingSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022DASH\u0022, AbilitySlot = 1, Text = \u0022Recharge by walking or climbing\u0022, IllustrationImage = \u0022sprites/ui/characters/player_climber/player_air_up.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_climber/player_air_down.png\u0022, IllustrationGlow = true, ShowDashSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022HARD LANDING\u0022, IllustrationGlow = true, ShowClimberFallSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tBlinker = new()\r\n\t\t{\r\n\t\t\tId = \u0022blinker\u0022,\r\n\t\t\tName = \u0022WITCH\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_blinker.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_blinker/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 57.0f,\r\n\t\t\t\tMaxAirXSpeed = 92.0f,\r\n\t\t\t\tHorizontalAcceleration = 330.0f,\r\n\t\t\t\tHorizontalDeceleration = 260.0f,\r\n\t\t\t\tGroundFrictionFactor = 2.4f,\r\n\t\t\t\tAirFrictionFactor = 0.5f,\r\n\t\t\t\tAirAccelerationFactor = 1.8f,\r\n\t\t\t\tJumpPower = 88.0f,\r\n\t\t\t\tGravity = 170.0f,\r\n\t\t\t\tMaxRiseSpeed = 165.0f,\r\n\t\t\t\tMaxFallSpeed = 185.0f,\r\n\t\t\t\tActiveUpGravityFactor = 0.38f,\r\n\t\t\t\tActiveUpGravityFallOnly = true,\r\n\t\t\t\tWallGravityFactor = 0.05f,\r\n\t\t\t\tWallRiseGravityFactor = 0.5f,\r\n\t\t\t\tWallSlideMaxFallSpeed = 24.0f,\r\n\t\t\t\tWallJumpHorizontalPower = 82.0f,\r\n\t\t\t\tWallJumpVerticalPower = 82.0f,\r\n\t\t\t\tWallJumpTime = 0.3f,\r\n\t\t\t\tGroundedLeniencyFrames = 12,\r\n\t\t\t\tJumpBufferFrames = 5,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tIgnoreWallJumpDoubleTaps = true,\r\n\t\t\t\tHasBlinker = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022TELEPORT\u0022, AbilitySlot = 1, Text = \u0022Recharge by landing or pressing block side\u0022, ShowTeleportSequence = true, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tRewind = new()\r\n\t\t{\r\n\t\t\tId = \u0022rewind\u0022,\r\n\t\t\tName = \u0022ECHO\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_rewind.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_rewind/player_walk_0.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 65.0f,\r\n\t\t\t\tMaxAirXSpeed = 108.0f,\r\n\t\t\t\tHorizontalAcceleration = 260.0f,\r\n\t\t\t\tHorizontalDeceleration = 145.0f,\r\n\t\t\t\tGroundFrictionFactor = 2.2f,\r\n\t\t\t\tAirFrictionFactor = 0.12f,\r\n\t\t\t\tAirAccelerationFactor = 1.35f,\r\n\t\t\t\tJumpPower = 82.0f,\r\n\t\t\t\tGravity = 215.0f,\r\n\t\t\t\tMaxRiseSpeed = 290.0f,\r\n\t\t\t\tMaxFallSpeed = 205.0f,\r\n\t\t\t\tActiveUpGravityFactor = 0.4f,\r\n\t\t\t\tActiveUpGravityFallOnly = true,\r\n\t\t\t\tWallGravityFactor = 0.35f,\r\n\t\t\t\tWallJumpHorizontalPower = 40.0f,\r\n\t\t\t\tWallJumpVerticalPower = 95.0f,\r\n\t\t\t\tWallJumpTime = 0.2f,\r\n\t\t\t\tApexHangGravityFactor = 0.5f,\r\n\t\t\t\tApexHangVelThreshold = 12.0f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tHasRewind = true,\r\n\t\t\t\tCanDash = true,\r\n\t\t\t\tDashForce = 215f,\r\n\t\t\t\tDashGravitySuppressTime = 0.15f,\r\n\t\t\t\tCanDashHorizontal = true,\r\n\t\t\t\tCanDashUp = true,\r\n\t\t\t\tCanDashDown = true,\r\n\t\t\t\tCanDashOnGround = true,\r\n\t\t\t\tDashRefreshOnFloor = false,\r\n\t\t\t\tDashMomentumFactor = 0.1f,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022AFTERIMAGES\u0022, CharacterPreviewImages = new[] { \u0022sprites/ui/characters/player_rewind/player_walk_3.png\u0022, \u0022sprites/ui/characters/player_rewind/player_crouched.png\u0022, \u0022sprites/ui/characters/player_rewind/player_walk_0.png\u0022 }, CharacterPreviewOpacities = new[] { 0.6f, 0.8f, 1f }, /*CharacterPreviewIndicatorIndex = 2*/ },\r\n\t\t\t\t\tnew() { Name = \u0022PARADOX BOOST\u0022, CharacterPreviewImages = new[] { \u0022sprites/ui/characters/player_rewind/player_air_up.png\u0022, \u0022sprites/ui/characters/player_rewind/player_air_up_active.png\u0022 }, CharacterPreviewOpacities = new[] { 0.8f, 1f }, CharacterPreviewYOffsets = new[] { 10.5f, -15f }, /*CharacterPreviewIndicatorIndex = 1,*/ OverlapCharacterPreviews = true },\r\n\t\t\t\t\tnew() { Name = \u0022DASH\u0022, AbilitySlot = 1, Text = \u0022Recharge with Paradox Boost\u0022, IllustrationImage = \u0022sprites/ui/characters/player_rewind/player_air_up.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_rewind/player_air_down.png\u0022, IllustrationGlow = true, ShowDashSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022REWRITE HISTORY\u0022, ShowRewindExplosionSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tShifter = new()\r\n\t\t{\r\n\t\t\tId = \u0022shifter\u0022,\r\n\t\t\tName = \u0022GYRO\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_shifter.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_shifter/player_idle.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 58.0f,\r\n\t\t\t\tMaxAirXSpeed = 66.0f,\r\n\t\t\t\tHorizontalAcceleration = 400.0f,\r\n\t\t\t\tHorizontalDeceleration = 320.0f,\r\n\t\t\t\tJumpPower = 92.0f,\r\n\t\t\t\tGravity = 275.0f,\r\n\t\t\t\tMaxRiseSpeed = 220.0f,\r\n\t\t\t\tMaxFallSpeed = 260.0f,\r\n\t\t\t\tAirAccelerationFactor = 0.9f,\r\n\t\t\t\tAirFrictionFactor = 0.85f,\r\n\t\t\t\tGroundedLeniencyFrames = 4,\r\n\t\t\t\tActiveUpGravityFactor = 0.4f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tHasSurfaceGravity = true,\r\n\t\t\t\tCanWallJump = false,\r\n\t\t\t\t//StickToMovingBlocks = true,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022GRAVITY GRIP\u0022, ShowShifterGravityGripSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tSolar = new()\r\n\t\t{\r\n\t\t\tId = \u0022solar\u0022,\r\n\t\t\tName = \u0022SOLAR\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_solar.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_solar/player_idle.png\u0022,\r\n\t\t\tHelpButtonIconOffset = new Vector2( -6, -6 ),\r\n\t\t\tArtSize = new Vector2( 16f, 16f ),\r\n\t\t\tAudio = new CharacterAudio { Death = SfxType.SolarDeath },\r\n\t\t\tDeathFx = DeathFxStyle.Solar,\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 125f,\r\n\t\t\t\tJumpPower = 100,\r\n\t\t\t\tAirAccelerationFactor = 1.15f,\r\n\t\t\t\tGroundFrictionFactor = 0.015f,\r\n\t\t\t\tAirFrictionFactor = 0.125f,\r\n\t\t\t\t//JumpSpeedBonus = 1.1f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tCanBounce = true,\r\n\t\t\t\tBounceRestitution = 0.65f,\r\n\t\t\t\t//CanDash = true,\r\n\t\t\t\t//CanDashHorizontal = false,\r\n\t\t\t\t//CanDashUp = true,\r\n\t\t\t\t//CanDashDown = true,\r\n\t\t\t\t//DashForce = 105f,\r\n\t\t\t\t//DashMomentumFactor = 0.6f,\r\n\t\t\t\t//CanWallDive = true,\r\n\t\t\t\tMaxAirJumps = 1,\r\n\t\t\t\tAirJumpPower = 95f,\r\n\t\t\t\tAirJumpStacksVerticalVelocity = true,\r\n\t\t\t\tAirJumpHorizontalBoost = 50f,\r\n\t\t\t\tHasSolar = true,\r\n\t\t\t\t//WallClingFrames = 8,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022SOLAR POWERED\u0022, Text = \u0022Energy recharges in sunlight\u0022, ExtraTextTopPadding = true, IllustrationImage = \u0022sprites/ui/solar_powered.png\u0022 },\r\n\t\t\t\t\tnew() { Name = \u0022BOUNCE\u0022, Text = \u0022Use full energy to stun a block\u0022, ExtraTextTopPadding = true, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/solar_shock_bounce_outline.png\u0022 },\r\n\t\t\t\t\tnew() { Name = \u0022ROCKET BOOST\u0022, Text = \u0022Use half energy to air jump\u0022, Directions = new[] { Direction.Up }, IllustrationImage = \u0022sprites/ui/characters/player_solar/player_air_up_active.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t\t//new() { Name = \u0022WALL THRUST\u0022, IllustrationImage = \u0022sprites/ui/characters/player_solar/player_wall_down.png\u0022, IllustrationGlow = true, IllustrationFlipHorizontal = true, CompactDirections = true, ShowWallKickSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tMimic = new()\r\n\t\t{\r\n\t\t\tId = \u0022mimic\u0022,\r\n\t\t\tName = \u0022MIMIC\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_mimic.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_mimic/player_walk_0.png\u0022,\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tBloodColor = new Color( 214f / 255f, 88f / 255f, 154f / 255f ),\r\n\t\t\tMovement = new CharacterMovement\r\n\t\t\t{\r\n\t\t\t\tMaxXSpeed = 76.0f,\r\n\t\t\t\tMaxAirXSpeed = 80.0f,\r\n\t\t\t\tHorizontalAcceleration = 310.0f,\r\n\t\t\t\tHorizontalDeceleration = 310.0f,\r\n\t\t\t\tJumpPower = 120.0f,\r\n\t\t\t\tWallJumpHorizontalPower = 75.0f,\r\n\t\t\t\tWallJumpVerticalPower = 110.0f,\r\n\t\t\t\tGravity = 300.0f,\r\n\t\t\t\tAirAccelerationFactor = 0.8f,\r\n\t\t\t\tAirFrictionFactor = 5f,\r\n\t\t\t\tWallGravityFactor = 0.05f,\r\n\t\t\t},\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tHasMimic = true,\r\n\t\t\t\tStickToMovingBlocks = true,\r\n\t\t\t\t//BlockPressBoost = 200f,\r\n\t\t\t\tWallClingFrames = 20,\r\n\t\t\t\tCanNeutralWallJump = true,\r\n\t\t\t\tCanBounce = true,\r\n\t\t\t\tBounceRestitution = 0.55f,\r\n\t\t\t},\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022STOLEN SHAPE\u0022, Directions = new[] { Direction.Left, Direction.Right }, DirectionSeparator = \u0022/\u0022, DirectionSeparatorAfterIndex = 0, ShowMimicTransformSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022BOUNCE\u0022, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player_mimic/player_air_fall_fast.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t\tnew() { Name = \u0022UNCRUSHABLE\u0022, ShowMimicSquashSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tTwinTwo = new()\r\n\t\t{\r\n\t\t\tId = \u0022twin-2\u0022,\r\n\t\t\tName = \u0022TWIN 2\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_twin_2.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_twin_2/player_walk_0.png\u0022,\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tMovement = TwinMovement(),\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tHasHarden = true,\r\n\t\t\t\tStickToMovingBlocks = true,\r\n\t\t\t\tCanNeutralWallJump = true,\r\n\t\t\t},\r\n\t\t\tMimicFormHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tTitle = \u0022TWIN\u0022,\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022STONE FORM\u0022, AbilitySlot = 1, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player_twin_2/player_crouched.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_twin_2/player_hardened.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tTwins = new()\r\n\t\t{\r\n\t\t\tId = \u0022twins\u0022,\r\n\t\t\tName = \u0022TWINS\u0022,\r\n\t\t\tSpritePath = \u0022sprites/player_twin_1.sprite\u0022,\r\n\t\t\tPreviewImage = \u0022sprites/ui/characters/player_twin_1/player_walk_0.png\u0022,\r\n\t\t\tArtSize = new Vector2( 14f, 14f ),\r\n\t\t\tMovement = TwinMovement(),\r\n\t\t\tAbilities = new CharacterAbilities\r\n\t\t\t{\r\n\t\t\t\tHasTwinDash = true,\r\n\t\t\t\tIgnoreWallJumpDoubleTaps = true,\r\n\t\t\t\tCanNeutralWallJump = true,\r\n\t\t\t},\r\n\t\t\tPartner = TwinTwo,\r\n\t\t\tHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022TAG TEAM\u0022, AbilitySlot = 2, LegacyText = \u0022Release all keys\u0022, CharacterPreviewImages = new[] { \u0022sprites/ui/characters/player_twin_1/player_walk_0.png\u0022, \u0022sprites/ui/characters/player_twin_2/player_walk_0.png\u0022 }, CompactCharacterPreviews = true, CharacterPreviewIndicatorIndex = 1, CharacterPreviewAlternateIndicatorIndex = 0 },\r\n\t\t\t\t\tnew() { Name = \u0022PHASE DASH\u0022, AbilitySlot = 1, Directions = new[] { Direction.Right, Direction.Right }, ShowTwinPhaseDashSequence = true },\r\n\t\t\t\t\tnew() { Name = \u0022STONE FORM\u0022, AbilitySlot = 1, DirectionPrefix = \u0022Hold\u0022, Directions = new[] { Direction.Down }, IllustrationImage = \u0022sprites/ui/characters/player_twin_2/player_crouched.png\u0022, IllustrationAlternateImage = \u0022sprites/ui/characters/player_twin_2/player_hardened.png\u0022, IllustrationGlow = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t\tMimicFormHelp = new CharacterHelpDef\r\n\t\t\t{\r\n\t\t\t\tTitle = \u0022TWIN\u0022,\r\n\t\t\t\tRows = new CharacterHelpRow[]\r\n\t\t\t\t{\r\n\t\t\t\t\tnew() { Name = \u0022PHASE DASH\u0022, AbilitySlot = 1, Directions = new[] { Direction.Right, Direction.Right }, ShowTwinPhaseDashSequence = true },\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t};\r\n\r\n\t\tAll = BuildPickerOrder();\r\n\t}\r\n\r\n\tprivate static CharacterMovement TwinMovement() =\u003E new()\r\n\t{\r\n\t\tMaxXSpeed = 70f,\r\n\t\tMaxAirXSpeed = 78f,\r\n\t\tMaxRiseSpeed = 180f,\r\n\t\tMaxFallSpeed = 120f,\r\n\t\tHorizontalAcceleration = 320f,\r\n\t\tHorizontalDeceleration = 110f,\r\n\t\tAirAccelerationFactor = 0.8f,\r\n\t\tAirFrictionFactor = 0.5f,\r\n\t\tJumpPower = 90f,\r\n\t\tWallJumpHorizontalPower = 60f,\r\n\t\tWallJumpVerticalPower = 87f,\r\n\t\tGravity = 135f,\r\n\t\tActiveDownGravityFactor = 1f,\r\n\t\tActiveDownGravityRiseFactor = 1f,\r\n\t\tActiveUpGravityFactor = 1f,\r\n\t\tWallJumpTime = 0.3f,\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EConsole command: rebuild the static character definitions from source so art/tuning\r\n\t/// edits apply without an editor restart. Run \u003Cc\u003Ereload_characters\u003C/c\u003E from the console.\u003C/summary\u003E\r\n\t[ConCmd( \u0022reload_characters\u0022 )]\r\n\tpublic static void ReloadCharactersCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\t\tReload();\r\n\t\tLog.Info( $\u0022[BlockParty] Reloaded {All.Count} character definitions.\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EResolve a character id to its definition. Null/empty/unknown maps to\r\n\t/// \u003Csee cref=\u0022Original\u0022/\u003E so a missing or removed character never breaks a run or replay.\u003C/summary\u003E\r\n\tpublic static CharacterDef Get( string id )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( id ) )\r\n\t\t\treturn Original;\r\n\t\treturn All.FirstOrDefault( c =\u003E c.Id == id ) ?? Original;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETry to resolve a known, non-empty character id without falling back to Original.\u003C/summary\u003E\r\n\tpublic static bool TryGet( string id, out CharacterDef character )\r\n\t{\r\n\t\tcharacter = string.IsNullOrEmpty( id ) ? null : All.FirstOrDefault( c =\u003E c.Id == id );\r\n\t\treturn character is not null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELike \u003Csee cref=\u0022TryGet\u0022/\u003E but also resolves partner defs (a Mimic can wear Twin 2\u0027s\r\n\t/// shape, which is never selectable and so never in \u003Csee cref=\u0022All\u0022/\u003E).\u003C/summary\u003E\r\n\tpublic static bool TryGetIncludingPartners( string id, out CharacterDef character )\r\n\t{\r\n\t\tif ( TryGet( id, out character ) ) return true;\r\n\t\tcharacter = string.IsNullOrEmpty( id ) ? null : All.Select( c =\u003E c.Partner ).FirstOrDefault( p =\u003E p?.Id == id );\r\n\t\treturn character is not null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EResolve a character id or display name without requiring matching case.\u003C/summary\u003E\r\n\tpublic static bool TryGetByIdOrName( string value, out CharacterDef character )\r\n\t{\r\n\t\tcharacter = string.IsNullOrWhiteSpace( value )\r\n\t\t\t? null\r\n\t\t\t: All.FirstOrDefault( c =\u003E\r\n\t\t\t\tstring.Equals( c.Id, value.Trim(), StringComparison.OrdinalIgnoreCase )\r\n\t\t\t\t|| string.Equals( c.Name, value.Trim(), StringComparison.OrdinalIgnoreCase ) );\r\n\t\treturn character is not null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EIndex of a character id in \u003Csee cref=\u0022All\u0022/\u003E (for the picker); unknown \u2192 0.\u003C/summary\u003E\r\n\tpublic static int IndexOf( string id )\r\n\t{\r\n\t\tvar def = Get( id );\r\n\t\tfor ( int i = 0; i \u003C All.Count; i\u002B\u002B )\r\n\t\t\tif ( All[i] == def )\r\n\t\t\t\treturn i;\r\n\t\treturn 0;\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Game/DailyGenConfig.cs","FileName":"DailyGenConfig.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System.Collections.Generic;\n\nnamespace BlockParty;\n\n/// \u003Csummary\u003EOne non-baseline thing a generated daily can have. Each has a weighted chance of being\n/// drawn and a cost against the day\u0027s spice budget, so interesting twists appear often but rarely\n/// stack into an outlier day.\u003C/summary\u003E\npublic enum DailyModifier\n{\n\tExtraBlocks,\n\tFewerBlocks,\n\t/// \u003Csummary\u003EAllow one block type to appear twice.\u003C/summary\u003E\n\tDuplicates,\n\t/// \u003Csummary\u003EOne or two blocks start at phase 1.\u003C/summary\u003E\n\tPhaseStart,\n}\n\npublic sealed record SpiceModifier( DailyModifier Id, int Weight, int Cost );\n\n/// \u003Csummary\u003E\n/// One hand-authored obstacle layout the daily generator can pick. Curation is the safety net:\n/// spawn positions are valid 40x40 block centres clear of the obstacles, the player spawn is safe,\n/// and only sides that are fair for THIS layout\u0027s player spawn are listed as spike candidates\n/// (never the surface the player starts standing on). Checked by the daily_validate_templates\n/// ConCmd. Rects use the project\u0027s logical space: new RectF( left, bottom, right, top ), y-up,\n/// arena 0..240 with 2-unit walls (valid block centres 22..218).\n/// \u003C/summary\u003E\npublic sealed class ObstacleTemplate\n{\n\tpublic string Id { get; init; }\n\t/// \u003Csummary\u003ERelative daily selection weight (1 = normal); zero excludes the template from normal rolls.\u003C/summary\u003E\n\tpublic float Weight { get; init; }\n\t/// \u003Csummary\u003EAllow a coin-flip mirrored variant across x=120 (only useful for asymmetric layouts).\u003C/summary\u003E\n\tpublic bool AllowMirrorX { get; init; }\n\t/// \u003Csummary\u003EAlways place spikes exactly as authored: when set, every authored spiked surface is\n\t/// spiked every day. Off (the default) = spikes are rolled \u2014 each surface has an independent\n\t/// \u003Csee cref=\u0022DailyGenConfig.SpikeChancePercent\u0022/\u003E chance each day.\u003C/summary\u003E\n\tpublic bool AuthoredSpikes { get; init; }\n\tpublic RectF[] Obstacles { get; init; } = System.Array.Empty\u003CRectF\u003E();\n\t/// \u003Csummary\u003EIndices into \u003Csee cref=\u0022Obstacles\u0022/\u003E whose rect also blocks line of sight.\u003C/summary\u003E\n\tpublic int[] VisionObstacles { get; init; } = System.Array.Empty\u003Cint\u003E();\n\t/// \u003Csummary\u003EIndices into \u003Csee cref=\u0022Obstacles\u0022/\u003E flagged as fences (solid only to blocks). Fence\n\t/// COLOUR is not template data \u2014 it rolls with the day\u0027s palette like every other colour.\u003C/summary\u003E\n\tpublic int[] FenceObstacles { get; init; } = System.Array.Empty\u003Cint\u003E();\n\t/// \u003Csummary\u003EIndices into \u003Csee cref=\u0022Obstacles\u0022/\u003E flagged as GLASS (solid only to players).\n\t/// Layout-only like the fences; the glass colour rolls with the day\u0027s palette.\u003C/summary\u003E\n\tpublic int[] GlassObstacles { get; init; } = System.Array.Empty\u003Cint\u003E();\n\tpublic Vector2[] SpawnPositions { get; init; }\n\t/// \u003Csummary\u003EOptional pinned blocks, aligned index-wise with \u003Csee cref=\u0022SpawnPositions\u0022/\u003E (null =\n\t/// free slot). Pinned blocks always spawn, independent of the day\u0027s rolled block count.\u003C/summary\u003E\n\tpublic PinnedBlock[] SpawnPins { get; init; } = System.Array.Empty\u003CPinnedBlock\u003E();\n\t/// \u003Csummary\u003ECandidate player starts; the run picks one from the run Rng when there\u0027s more than one.\u003C/summary\u003E\n\tpublic Vector2[] PlayerSpawns { get; init; } = { new( 16, 40 ) };\n\t/// \u003Csummary\u003ESpiked arena boundary sides \u2014 rolled per-side each day, unless\n\t/// \u003Csee cref=\u0022AuthoredSpikes\u0022/\u003E makes them always spiked as authored.\u003C/summary\u003E\n\tpublic Direction[] SpikeableWalls { get; init; } = System.Array.Empty\u003CDirection\u003E();\n\t/// \u003Csummary\u003ESpiked obstacle faces (obstacle index \u002B outward sides) \u2014 same\n\t/// rolled-vs-\u003Csee cref=\u0022AuthoredSpikes\u0022/\u003E rule as \u003Csee cref=\u0022SpikeableWalls\u0022/\u003E.\u003C/summary\u003E\n\tpublic (int Obstacle, Direction[] Sides)[] SpikeableObstacleSides { get; init; } = System.Array.Empty\u003C(int, Direction[])\u003E();\n\t/// \u003Csummary\u003EOptional alternate-playfield regions (cosmetic); empty for now, scaffolded for later.\u003C/summary\u003E\n\tpublic RectF[] AlternatePlayfieldRects { get; init; } = System.Array.Empty\u003CRectF\u003E();\n\t/// \u003Csummary\u003ECollectible coin centres this template\u0027s days always place (mirrored with the layout\n\t/// on a mirrored day). Spawning coins consumes no generator RNG.\u003C/summary\u003E\n\tpublic Vector2[] Coins { get; init; } = System.Array.Empty\u003CVector2\u003E();\n}\n\n/// \u003Csummary\u003E\n/// All tuning knobs for daily-challenge level generation. IMPORTANT: any change to these tables\n/// changes every past and future generated day (and breaks stored daily replays \u2014 the regenerated\n/// layout stops matching their recorded level hash, so they grey out), so config changes ship\n/// together with a LEADERBOARD_VERSION bump \u2014 the daily seed folds the version in, which re-rolls\n/// the boards cleanly. Tables are plain arrays drawn in\n/// listed order; never replace them with dictionaries (iteration order is load-bearing).\n/// \u003C/summary\u003E\npublic static class DailyGenConfig\n{\n\t/// \u003Csummary\u003ERelative draw weight per block type. Types not listed never appear in dailies.\n\t/// TUNE: classics common, mechanically loud types rarer.\u003C/summary\u003E\n\tpublic static readonly (BlockType Type, int Weight)[] TypeWeights =\n\t{\n\t\t(BlockType.Dragon, Weight: 7),\n\t\t(BlockType.Squid, Weight: 6),\n\t\t(BlockType.Smile, Weight: 6),\n\t\t(BlockType.Spikey, Weight: 7),\n\t\t(BlockType.Sad, Weight: 7),\n\t\t(BlockType.Wind, Weight: 3),\n\t\t(BlockType.Magnet, Weight: 2),\n\t\t(BlockType.Shade, Weight: 5),\n\t\t(BlockType.Sticky, Weight: 5),\n\t\t(BlockType.Stasis, Weight: 5),\n\t\t(BlockType.Shockwave, Weight: 3),\n\t\t(BlockType.Slicer, Weight: 5),\n\t\t(BlockType.Teleport, Weight: 5),\n\t\t(BlockType.Mimic, Weight: 4),\n\t\t(BlockType.Summoner, Weight: 4),\n\t\t(BlockType.Wisp, Weight: 2),\n\t\t(BlockType.Venom, Weight: 4),\n\t\t(BlockType.Hunter, Weight: 4),\n\t\t(BlockType.Siren, Weight: 3),\n\t\t(BlockType.Reverse, Weight: 3),\n\t\t(BlockType.DragonBounce, Weight: 4),\n\t\t(BlockType.Laser, Weight: 3),\n\t\t(BlockType.Bullet, Weight: 5),\n\t\t(BlockType.LaserPierce, Weight: 4),\n\t\t(BlockType.StraightLaser, Weight: 3),\n\t\t(BlockType.SadPierce, Weight: 3),\n\t};\n\n\t/// \u003Csummary\u003EType pairs that never share a day (drawing one removes the other from the pool).\n\t/// TUNE: combos that compound into unfair pressure.\u003C/summary\u003E\n\tpublic static readonly (BlockType A, BlockType B)[] ForbiddenPairs =\n\t{\n\t\t(BlockType.Hunter, BlockType.Siren),\n\t\t//(BlockType.Hunter, BlockType.Wisp),\n\t\t//(BlockType.Siren, BlockType.Sticky),\n\t\t//(BlockType.Siren, BlockType.Magnet),\n\t\t//(BlockType.Siren, BlockType.Reverse),\n\t\t//(BlockType.Laser, BlockType.StraightLaser),\n\t\t//(BlockType.Laser, BlockType.LaserPierce),\n\t\t//(BlockType.LaserPierce, BlockType.StraightLaser),\n\t\t//(BlockType.Laser, BlockType.Bullet),\n\t};\n\n\t/// \u003Csummary\u003ETypes that never appear twice even on a Duplicates day.\u003C/summary\u003E\n\tpublic static readonly BlockType[] DuplicateBannedTypes =\n\t{\n\t\tBlockType.Siren, BlockType.Summoner, BlockType.Reverse,\n\t};\n\n\tpublic static readonly (int Count, int Weight)[] BlockCountWeights =\n\t{\n\t\t(Count: 3, Weight: 10),\n\t\t(Count: 4, Weight: 20),\n\t\t(Count: 5, Weight: 20),\n\t\t(Count: 6, Weight: 10),\n\t\t(Count: 7, Weight: 5),\n\t};\n\n\t/// \u003Csummary\u003EAttempt limits rolled when the independent limited-attempts chance succeeds.\u003C/summary\u003E\n\tpublic static readonly (int Attempts, int Weight)[] AttemptWeights =\n\t{\n\t\t(Attempts: 15, Weight: 1),\n\t\t(Attempts: 10, Weight: 2),\n\t\t(Attempts: 5, Weight: 8),\n\t\t(Attempts: 3, Weight: 8),\n\t\t(Attempts: 2, Weight: 6),\n\t\t(Attempts: 1, Weight: 10),\n\t};\n\n\t/// \u003Csummary\u003EIndependent chance to limit attempts for the day.\u003C/summary\u003E\n\tpublic const int LimitedAttemptsChancePercent = 85;\n\n\t/// \u003Csummary\u003EIndependent chance to force a character for the day.\u003C/summary\u003E\n\tpublic const int ForcedCharacterChancePercent = 55;\n\n\t/// \u003Csummary\u003ERelative draw weight per character when the independent forced-character chance succeeds (forced regardless of\n\t/// unlock state; a daily victory then unlocks the character as usual). Every character is\n\t/// available; lower weight = rarer. Explicit list so adding new characters later is a\n\t/// deliberate, version-bumped choice. TUNE.\u003C/summary\u003E\n\tpublic static readonly (string CharacterId, int Weight)[] CharacterWeights =\n\t{\n\t\t(\u0022original\u0022, Weight: 1),\n\t\t(\u0022wrapper\u0022, Weight: 4),\n\t\t(\u0022swapper\u0022, Weight: 6),\n\t\t(\u0022gunner\u0022, Weight: 6),\n\t\t(\u0022flipper\u0022, Weight: 6),\n\t\t(\u0022bird\u0022, Weight: 5),\n\t\t(\u0022spring\u0022, Weight: 6),\n\t\t(\u0022swarm\u0022, Weight: 6),\n\t\t(\u0022grappler\u0022, Weight: 6),\n\t\t(\u0022climber\u0022, Weight: 6),\n\t\t(\u0022blinker\u0022, Weight: 6),\n\t\t(\u0022rewind\u0022, Weight: 5),\n\t\t(\u0022shifter\u0022, Weight: 6),\n\t\t(\u0022solar\u0022, Weight: 5),\n\t\t(\u0022mimic\u0022, Weight: 6),\n\t\t(\u0022twins\u0022, Weight: 5),\n\t};\n\n\t/// \u003Csummary\u003EHow many spice points a day gets: most days 0-1 twists, occasionally 2, rarely 3.\u003C/summary\u003E\n\tpublic static readonly (int Points, int Weight)[] SpiceBudgetWeights =\n\t{\n\t\t(Points: 0, Weight: 30),\n\t\t(Points: 1, Weight: 50),\n\t\t(Points: 2, Weight: 15),\n\t\t(Points: 3, Weight: 10),\n\t};\n\n\t/// \u003Csummary\u003EEvery modifier currently costs 1, so the spice budget IS the twist count; a future\n\t/// especially nasty modifier can cost 2\u002B to hog a whole high-budget day.\u003C/summary\u003E\n\tpublic static readonly SpiceModifier[] Modifiers =\n\t{\n\t\tnew( DailyModifier.ExtraBlocks, Weight: 10, Cost: 1 ),\n\t\t//new( DailyModifier.FewerBlocks, Weight: 10, Cost: 1 ),\n\t\tnew( DailyModifier.Duplicates, Weight: 7, Cost: 1 ),\n\t\tnew( DailyModifier.PhaseStart, Weight: 10, Cost: 1 ),\n\t};\n\n\t/// \u003Csummary\u003EModifier pairs that never land on the same day.\u003C/summary\u003E\n\tpublic static readonly (DailyModifier A, DailyModifier B)[] ExclusiveModifiers =\n\t{\n\t\t(A: DailyModifier.ExtraBlocks, B: DailyModifier.FewerBlocks),\n\t};\n\n\t/// \u003Csummary\u003EEvery day, each of the template\u0027s spike candidates independently rolls this chance\n\t/// to actually be spiked (\u003Csee cref=\u0022ObstacleTemplate.AuthoredSpikes\u0022/\u003E templates skip the roll\n\t/// and always spike everything).\u003C/summary\u003E\n\tpublic const int SpikeChancePercent = 10;\n\n\t/// \u003Csummary\u003EPercent chance a day rolls a generated alternate-playfield region (one of the\n\t/// generator\u0027s layout families \u2014 bands, boxes, frames, checkers, stairs \u2026 \u2014 randomly mirrored,\n\t/// drawn in a second pattern\u002Bcolour). Purely cosmetic, so it\u0027s an independent roll rather than a\n\t/// spice modifier. Templates that author their own\n\t/// \u003Csee cref=\u0022ObstacleTemplate.AlternatePlayfieldRects\u0022/\u003E skip this roll.\u003C/summary\u003E\n\tpublic const int AltPlayfieldChancePercent = 40;\n\n\t/// \u003Csummary\u003EPercent chance a day rolls ambient background particles (rain / snow-style squares\n\t/// falling from the top edge, tinted to the day\u0027s palette \u2014 see\n\t/// \u003Csee cref=\u0022LevelCosmeticsRandomizer.RollBackgroundParticles\u0022/\u003E). Purely cosmetic: the emitter\n\t/// runs on the Background Rng stream and never touches the sim, and the roll\u0027s draw count is\n\t/// constant whichever way this gate lands, so tuning it never disturbs draw alignment.\u003C/summary\u003E\n\tpublic const int BackgroundParticlesChancePercent = 35;\n\n\t/// \u003Csummary\u003ELast-resort layout when no positive-weight template can be selected.\u003C/summary\u003E\n\tpublic static readonly ObstacleTemplate FallbackTemplate = new()\n\t{\n\t\tId = \u0022__fallback\u0022,\n\t\tWeight = 0,\n\t\tSpawnPositions = new Vector2[]\n\t\t{\n\t\t\tnew( 218, 218 ), new( 178, 218 ), new( 138, 218 ), new( 98, 218 ), new( 58, 218 ),\n\t\t\tnew( 58, 158 ), new( 138, 158 ), new( 218, 158 ),\n\t\t},\n\t\tPlayerSpawns = new Vector2[] { new( 16, 40 ) },\n\t\tSpikeableWalls = new[] { Direction.Up, Direction.Right, Direction.Left },\n\t};\n\n\t// The template library lives in Assets/daily-templates/*.json (authored from\n\t// the level editor\u0027s Save Template button, loaded by DailyTemplates) \u2014 same generation-input\n\t// contract as the tables above: any file change re-rolls every day and rides a version bump.\n}\n"},{"Ident":"facepunch.blockparty","Path":"Game/DailyTemplates.cs","FileName":"DailyTemplates.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing Sandbox;\n\nnamespace BlockParty;\n\n/// \u003Csummary\u003EThe on-disk JSON shape of one daily-challenge obstacle template\n/// (Assets/daily-templates/*.json \u2014 the file name is the template id). Same primitive conventions\n/// as \u003Csee cref=\u0022LevelJson\u0022/\u003E: enums as names, points as [x,y], rects as [left,bottom,right,top].\n/// Authored from the level editor (Save Template) and hand-tunable (Weight/AllowMirrorX).\u003C/summary\u003E\npublic sealed class ObstacleTemplateJson\n{\n\tpublic float Weight { get; set; } = 1f;\n\tpublic bool AllowMirrorX { get; set; }\n\t/// \u003Csummary\u003EWhen set, spiked surfaces are always spiked exactly as authored instead of rolled\n\t/// per-side each day (see \u003Csee cref=\u0022ObstacleTemplate.AuthoredSpikes\u0022/\u003E).\u003C/summary\u003E\n\tpublic bool AuthoredSpikes { get; set; }\n\tpublic float[][] Obstacles { get; set; }\n\t/// \u003Csummary\u003EIndices into \u003Csee cref=\u0022Obstacles\u0022/\u003E whose rect blocks line of sight.\u003C/summary\u003E\n\tpublic int[] Vision { get; set; }\n\t/// \u003Csummary\u003EIndices into \u003Csee cref=\u0022Obstacles\u0022/\u003E flagged as fences (solid only to blocks). Their\n\t/// colour is NOT stored here \u2014 templates are layout-only, and the fence colour rolls with the\n\t/// rest of the day\u0027s palette (see \u003Csee cref=\u0022LevelCosmeticsRandomizer.RollColors\u0022/\u003E).\u003C/summary\u003E\n\tpublic int[] Fences { get; set; }\n\t/// \u003Csummary\u003EIndices into \u003Csee cref=\u0022Obstacles\u0022/\u003E flagged as GLASS (solid only to players).\n\t/// Layout-only like \u003Csee cref=\u0022Fences\u0022/\u003E \u2014 the glass colour rolls with the day\u0027s palette.\u003C/summary\u003E\n\tpublic int[] Glass { get; set; }\n\tpublic float[][] Spawns { get; set; }\n\t/// \u003Csummary\u003ESingle player spawn [x,y] (the common case).\u003C/summary\u003E\n\tpublic float[] Player { get; set; }\n\t/// \u003Csummary\u003ECandidate player spawns [[x,y],...] \u2014 written instead of \u003Csee cref=\u0022Player\u0022/\u003E when\n\t/// there\u0027s more than one; the run picks one from the run Rng.\u003C/summary\u003E\n\tpublic float[][] Players { get; set; }\n\tpublic string[] SpikeableWalls { get; set; }\n\tpublic ObstacleSpikeJson[] SpikeableObstacleSides { get; set; }\n\tpublic float[][] AltRects { get; set; }\n\t/// \u003Csummary\u003EPinned blocks (sparse: spawn index \u002B type/phase/pressed) \u2014 always spawn on this\n\t/// template\u0027s days, independent of the rolled block count.\u003C/summary\u003E\n\tpublic PinJson[] Pins { get; set; }\n\t/// \u003Csummary\u003ECollectible coin centres [[x,y],...]. Null/omitted = no coins.\u003C/summary\u003E\n\tpublic float[][] Coins { get; set; }\n}\n\n/// \u003Csummary\u003E\n/// The daily generator\u0027s obstacle-template library, loaded from \u003Cc\u003EAssets/daily-templates/*.json\u003C/c\u003E\n/// (sorted by id). IMPORTANT: the file set AND content are generation inputs \u2014 adding, renaming,\n/// editing or deleting a template changes every past and future generated day, so template changes\n/// ship with a LEADERBOARD_VERSION bump like any generator change (see \u003Csee cref=\u0022DailyLevelGenerator\u0022/\u003E).\n/// Templates are authored in the level editor (Save/Load Template in the inspector) and validated\n/// by the daily_validate_templates ConCmd.\n/// \u003C/summary\u003E\npublic static class DailyTemplates\n{\n\tpublic const string DirName = \u0022daily-templates\u0022;\n\n\tprivate static List\u003CObstacleTemplate\u003E _templates;\n\n\t/// \u003Csummary\u003EEvery loaded template, sorted by id (deterministic generation order).\u003C/summary\u003E\n\tpublic static IReadOnlyList\u003CObstacleTemplate\u003E All\n\t{\n\t\tget\n\t\t{\n\t\t\tEnsureLoaded();\n\t\t\treturn _templates ?? (IReadOnlyList\u003CObstacleTemplate\u003E)Array.Empty\u003CObstacleTemplate\u003E();\n\t\t}\n\t}\n\n\tpublic static ObstacleTemplate Find( string id )\n\t\t=\u003E string.IsNullOrWhiteSpace( id ) ? null : All.FirstOrDefault( t =\u003E t.Id == id );\n\n\t/// \u003Csummary\u003EDrop the loaded set so the next access rescans the mounted folder (hotload hygiene).\u003C/summary\u003E\n\tpublic static void Reload() =\u003E _templates = null;\n\n\t/// \u003Csummary\u003EWhether a scan has actually found templates (attempts a load, so polling here\n\t/// self-heals once the mount lands). While false the library may just not be mounted yet, so\n\t/// dailies must not be generated at all (see \u003Csee cref=\u0022DailyLevels.Get\u0022/\u003E).\u003C/summary\u003E\n\tpublic static bool IsLoaded { get { EnsureLoaded(); return _templates is not null; } }\n\n\t/// \u003Csummary\u003EHot-insert/replace a just-saved template so it\u0027s usable immediately \u2014 the mounted\n\t/// asset filesystem can lag behind the disk write (mirrors \u003Cc\u003ELevels.ApplySavedLevel\u003C/c\u003E).\u003C/summary\u003E\n\tpublic static void ApplySaved( ObstacleTemplate template )\n\t{\n\t\tif ( template is null || string.IsNullOrEmpty( template.Id ) )\n\t\t\treturn;\n\n\t\tEnsureLoaded();\n\t\t_templates ??= new List\u003CObstacleTemplate\u003E();\n\t\t_templates.RemoveAll( t =\u003E t.Id == template.Id );\n\t\t_templates.Add( template );\n\t\t_templates.Sort( ( a, b ) =\u003E string.CompareOrdinal( a.Id, b.Id ) );\n\t}\n\n\t/// \u003Csummary\u003EHot-remove a just-deleted template so the browser stops offering it immediately \u2014 the\n\t/// mounted asset filesystem can lag behind the disk delete (the counterpart of \u003Csee cref=\u0022ApplySaved\u0022/\u003E).\u003C/summary\u003E\n\tpublic static void ApplyDeleted( string id )\n\t{\n\t\tif ( string.IsNullOrEmpty( id ) )\n\t\t\treturn;\n\t\t_templates?.RemoveAll( t =\u003E t.Id == id );\n\t}\n\n\t// Latch the library only once a scan actually FINDS templates, so a boot-before-mount race can\u0027t\n\t// latch an empty or partial library \u2014 mirrors the\n\t// content-based \u0060Levels._loaded\u0060 retry for the same race. An empty scan retries on the next access.\n\tprivate static void EnsureLoaded()\n\t{\n\t\tif ( _templates is not null )\n\t\t\treturn;\n\n\t\ttry\n\t\t{\n\t\t\tif ( !FileSystem.Mounted.DirectoryExists( DirName ) )\n\t\t\t\treturn;\n\n\t\t\tvar loaded = new List\u003CObstacleTemplate\u003E();\n\t\t\tforeach ( var name in FileSystem.Mounted.FindFile( DirName, \u0022*.json\u0022 ) )\n\t\t\t{\n\t\t\t\tvar path = $\u0022{DirName}/{name}\u0022;\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tvar json = Json.Deserialize\u003CObstacleTemplateJson\u003E( FileSystem.Mounted.ReadAllText( path ) );\n\t\t\t\t\tvar id = name.EndsWith( \u0022.json\u0022, StringComparison.OrdinalIgnoreCase ) ? name[..^\u0022.json\u0022.Length] : name;\n\t\t\t\t\tloaded.Add( FromJson( id, json ) );\n\t\t\t\t}\n\t\t\t\tcatch ( Exception ex )\n\t\t\t\t{\n\t\t\t\t\tLog.Warning( $\u0022[BlockParty] daily template \u0027{path}\u0027 skipped: {ex.Message}\u0022 );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( loaded.Count == 0 )\n\t\t\t{\n\t\t\t\t// Folder visible but nothing loaded: either the mount is mid-race (retry silently) or\n\t\t\t\t// every template file is gone/broken (a real authoring problem \u2014 the warnings above /\n\t\t\t\t// daily_validate_templates cover the broken-file case).\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tloaded.Sort( ( a, b ) =\u003E string.CompareOrdinal( a.Id, b.Id ) );\n\t\t\t_templates = loaded;\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Warning( $\u0022[BlockParty] scanning \u0027{DirName}\u0027: {ex.Message}\u0022 );\n\t\t}\n\t}\n\n\t// \u2500\u2500 JSON \u2194 runtime/editor conversions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n\tpublic static ObstacleTemplate FromJson( string id, ObstacleTemplateJson json )\n\t{\n\t\tvar spawns = (json.Spawns ?? Array.Empty\u003Cfloat[]\u003E()).Select( Point ).ToArray();\n\n\t\t// Sparse pins \u2192 aligned-with-spawns array (nulls = free slots).\n\t\tvar pins = new PinnedBlock[spawns.Length];\n\t\tforeach ( var pin in json.Pins ?? Array.Empty\u003CPinJson\u003E() )\n\t\t{\n\t\t\tif ( pin is null || pin.Spawn \u003C 0 || pin.Spawn \u003E= pins.Length ) continue;\n\t\t\tif ( !Enum.TryParse\u003CBlockType\u003E( pin.Type, true, out var type ) ) continue;\n\t\t\tpins[pin.Spawn] = new PinnedBlock\n\t\t\t{\n\t\t\t\tType = type,\n\t\t\t\tPhase = Math.Clamp( pin.Phase, 0, Block.NUM_PHASES - 1 ),\n\t\t\t\tPressedSides = (pin.Pressed ?? Array.Empty\u003Cstring\u003E()).Select( ParseDirection ).Where( d =\u003E d != Direction.None ).ToArray(),\n\t\t\t};\n\t\t}\n\n\t\treturn new()\n\t\t{\n\t\t\tId = id,\n\t\t\tWeight = Math.Max( 0f, json.Weight ),\n\t\t\tAllowMirrorX = json.AllowMirrorX,\n\t\t\tAuthoredSpikes = json.AuthoredSpikes,\n\t\t\tObstacles = (json.Obstacles ?? Array.Empty\u003Cfloat[]\u003E()).Select( Rect ).ToArray(),\n\t\t\tVisionObstacles = json.Vision ?? Array.Empty\u003Cint\u003E(),\n\t\t\tFenceObstacles = json.Fences ?? Array.Empty\u003Cint\u003E(),\n\t\t\tGlassObstacles = json.Glass ?? Array.Empty\u003Cint\u003E(),\n\t\t\tSpawnPositions = spawns,\n\t\t\tSpawnPins = pins.Any( p =\u003E p is not null ) ? pins : Array.Empty\u003CPinnedBlock\u003E(),\n\t\t\tPlayerSpawns = json.Players is { Length: \u003E 0 } list\n\t\t\t\t? list.Select( Point ).ToArray()\n\t\t\t\t: new[] { json.Player is { Length: 2 } p ? new Vector2( p[0], p[1] ) : new Vector2( 16, 40 ) },\n\t\t\tSpikeableWalls = (json.SpikeableWalls ?? Array.Empty\u003Cstring\u003E()).Select( ParseDirection ).Where( d =\u003E d != Direction.None ).ToArray(),\n\t\t\tSpikeableObstacleSides = (json.SpikeableObstacleSides ?? Array.Empty\u003CObstacleSpikeJson\u003E())\n\t\t\t\t.Select( s =\u003E (s.Obstacle, (s.Sides ?? Array.Empty\u003Cstring\u003E()).Select( ParseDirection ).Where( d =\u003E d != Direction.None ).ToArray()) )\n\t\t\t\t.ToArray(),\n\t\t\tAlternatePlayfieldRects = (json.AltRects ?? Array.Empty\u003Cfloat[]\u003E()).Select( Rect ).ToArray(),\n\t\t\tCoins = (json.Coins ?? Array.Empty\u003Cfloat[]\u003E()).Select( Point ).ToArray(),\n\t\t};\n\t}\n\n\t/// \u003Csummary\u003EBuild the on-disk shape from an editor level. The editor\u0027s spiked walls/faces are\n\t/// spike CANDIDATES rolled per-side each day, unless the template\u0027s AuthoredSpikes toggle\n\t/// makes them spiked exactly as authored on every day.\u003C/summary\u003E\n\tpublic static ObstacleTemplateJson BuildJson( EditorLevel level ) =\u003E new()\n\t{\n\t\tObstacles = level.Obstacles.Select( Arr ).ToArray(),\n\t\tVision = level.VisionObstacles.OrderBy( i =\u003E i ).ToArray(),\n\t\tFences = level.FenceObstacles.OrderBy( i =\u003E i ).ToArray(),\n\t\tGlass = level.GlassObstacles.OrderBy( i =\u003E i ).ToArray(),\n\t\tSpawns = level.SpawnPositions.Select( Arr ).ToArray(),\n\t\tPlayer = level.PlayerSpawns.Count == 1 ? Arr( level.PlayerSpawns[0] ) : null,\n\t\tPlayers = level.PlayerSpawns.Count \u003E 1 ? level.PlayerSpawns.Select( Arr ).ToArray() : null,\n\t\tSpikeableWalls = level.SpikedWalls.Select( d =\u003E d.ToString() ).ToArray(),\n\t\tSpikeableObstacleSides = level.SpikedObstacleSides\n\t\t\t.OrderBy( s =\u003E s.Obstacle )\n\t\t\t.Select( s =\u003E new ObstacleSpikeJson { Obstacle = s.Obstacle, Sides = s.Sides.Select( d =\u003E d.ToString() ).ToArray() } )\n\t\t\t.ToArray(),\n\t\tAltRects = level.AlternatePlayfieldRects.Select( Arr ).ToArray(),\n\t\tCoins = level.Coins.Count \u003E 0 ? level.Coins.Select( Arr ).ToArray() : null,\n\t\tPins = Enumerable.Range( 0, level.SpawnPositions.Count )\n\t\t\t.Where( i =\u003E level.SpawnPin( i ) is not null )\n\t\t\t.Select( i =\u003E new PinJson\n\t\t\t{\n\t\t\t\tSpawn = i,\n\t\t\t\tType = level.SpawnPin( i ).Type.ToString(),\n\t\t\t\tPhase = level.SpawnPin( i ).Phase,\n\t\t\t\tPressed = level.SpawnPin( i ).Pressed.Count \u003E 0 ? level.SpawnPin( i ).Pressed.Select( d =\u003E d.ToString() ).ToArray() : null,\n\t\t\t} )\n\t\t\t.ToArray(),\n\t};\n\n\t/// \u003Csummary\u003EReplace an editor level\u0027s LAYOUT with a template\u0027s: obstacles, vision flags, spawn\n\t/// grid, player spawn, spikes (candidates become the editor\u0027s spiked walls/faces) and alternate\n\t/// rects. Blocks, cosmetics, id/name etc. are left untouched so a template can seed a map level.\u003C/summary\u003E\n\tpublic static void ApplyToEditor( ObstacleTemplate template, EditorLevel level )\n\t{\n\t\tlevel.Obstacles = new List\u003CRectF\u003E( template.Obstacles );\n\t\tlevel.VisionObstacles = new List\u003Cint\u003E( template.VisionObstacles );\n\t\tlevel.FenceObstacles = new List\u003Cint\u003E( template.FenceObstacles );\n\t\tlevel.GlassObstacles = new List\u003Cint\u003E( template.GlassObstacles );\n\t\tlevel.SpikedWalls = new List\u003CDirection\u003E( template.SpikeableWalls );\n\t\tlevel.SpikedObstacleSides = template.SpikeableObstacleSides.Select( s =\u003E\n\t\t{\n\t\t\tvar spike = new ObstacleSpike { Obstacle = s.Obstacle };\n\t\t\tspike.Sides.AddRange( s.Sides );\n\t\t\treturn spike;\n\t\t} ).ToList();\n\n\t\t// Every per-slot list must be cleared together with SpawnPositions: AddSpawn APPENDS to all of\n\t\t// them, so leaving one populated shifts it out of step and the old level\u0027s entries end up read\n\t\t// as the template\u0027s. Templates carry no start direction / turn-mode authoring (daily levels\n\t\t// don\u0027t use them), so both simply reset to their defaults.\n\t\tlevel.SpawnPositions.Clear();\n\t\tlevel.SpawnPins.Clear();\n\t\tlevel.SpawnAbilityTimers.Clear();\n\t\tlevel.SpawnDirections.Clear();\n\t\tlevel.SpawnTurnModes.Clear();\n\t\tfor ( int i = 0; i \u003C template.SpawnPositions.Length; i\u002B\u002B )\n\t\t{\n\t\t\tlevel.AddSpawn( template.SpawnPositions[i] );\n\t\t\tvar pin = i \u003C template.SpawnPins.Length ? template.SpawnPins[i] : null;\n\t\t\tif ( pin is not null )\n\t\t\t\tlevel.SetSpawnPin( i, new EditorBlock( pin.Type ) { Phase = pin.Phase, Pressed = pin.PressedSides.ToList() } );\n\t\t}\n\n\t\tlevel.PlayerSpawns = template.PlayerSpawns.Length \u003E 0\n\t\t\t? new List\u003CVector2\u003E( template.PlayerSpawns )\n\t\t\t: new List\u003CVector2\u003E { new( 16, 40 ) };\n\n\t\t// The alt playfield is part of the LAYOUT, so it follows the template both ways: a template\n\t\t// without alt rects switches it back off, or the previous level\u0027s toggle would leave the\n\t\t// editor claiming a second pattern this template never had.\n\t\tlevel.AlternatePlayfieldRects = new List\u003CRectF\u003E( template.AlternatePlayfieldRects );\n\t\tlevel.AlternatePlayfieldEnabled = level.AlternatePlayfieldRects.Count \u003E 0;\n\n\t\tlevel.Coins = new List\u003CVector2\u003E( template.Coins );\n\t}\n\n\t/// \u003Csummary\u003EKebab-case a user-typed template name into a file/id-safe slug.\u003C/summary\u003E\n\tpublic static string SanitizeId( string name )\n\t{\n\t\tvar chars = (name ?? \u0022\u0022).Trim().ToLowerInvariant()\n\t\t\t.Select( c =\u003E char.IsLetterOrDigit( c ) ? c : \u0027-\u0027 )\n\t\t\t.ToArray();\n\t\tvar id = new string( chars );\n\t\twhile ( id.Contains( \u0022--\u0022 ) ) id = id.Replace( \u0022--\u0022, \u0022-\u0022 );\n\t\treturn id.Trim( \u0027-\u0027 );\n\t}\n\n\tprivate static RectF Rect( float[] a ) =\u003E a is { Length: 4 }\n\t\t? new RectF( a[0], a[1], a[2], a[3] )\n\t\t: throw new FormatException( \u0022rect must be [left,bottom,right,top]\u0022 );\n\n\tprivate static Vector2 Point( float[] a ) =\u003E a is { Length: 2 }\n\t\t? new Vector2( a[0], a[1] )\n\t\t: throw new FormatException( \u0022point must be [x,y]\u0022 );\n\n\tprivate static float[] Arr( RectF r ) =\u003E new[] { r.Left, r.Bottom, r.Right, r.Top };\n\tprivate static float[] Arr( Vector2 p ) =\u003E new[] { p.x, p.y };\n\n\tprivate static Direction ParseDirection( string name )\n\t\t=\u003E Enum.TryParse\u003CDirection\u003E( name, ignoreCase: true, out var d ) ? d : Direction.None;\n}\n"},{"Ident":"facepunch.blockparty","Path":"Game/GifEncoder.cs","FileName":"GifEncoder.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Pure C# GIF89a encoder with LZW compression and animation support. Writes animated GIFs with per-frame\r\n/// local color tables and transparency.\r\n///\r\n/// Frames are delta-encoded: after the first frame, only the bounding rectangle of pixels that changed\r\n/// since the previous frame is written, with unchanged pixels inside it marked transparent (\u0022keep the\r\n/// previous pixel\u0022, disposal method 1). Static backgrounds therefore cost almost nothing per frame \u2014\r\n/// the same trick gif optimizers (gifsicle etc.) apply, ~10x smaller on gameplay footage. Source frames\r\n/// that themselves contain transparent pixels can\u0027t be delta-encoded (transparency already means\r\n/// \u0022keep previous\u0022 in a delta) and fall back to whole-frame writes.\r\n///\r\n/// Incremental: each frame is quantized \u002B LZW-compressed and appended the moment it\u0027s handed to\r\n/// \u003Csee cref=\u0022AddFrame\u0022/\u003E, so raw frames are never buffered \u2014 memory stays ~the size of the growing\r\n/// compressed output plus one retained copy of the previous frame for delta comparison.\r\n/// (Construct, AddFrame per frame, Finish.)\r\n///\r\n/// Ported from the PixelShitter editor\u0027s encoder, but rewritten to build into a plain \u003Cc\u003Ebyte[]\u003C/c\u003E buffer\r\n/// instead of a \u003Csee cref=\u0022System.IO.Stream\u0022/\u003E/\u003Cc\u003EBinaryWriter\u003C/c\u003E \u2014 the game runs in the sbox sandbox,\r\n/// which keeps gameplay code off System.IO streams (see \u003Csee cref=\u0022RunInputCodec\u0022/\u003E).\r\n/// \u003C/summary\u003E\r\npublic sealed class GifEncoder\r\n{\r\n\tprivate readonly ByteBuffer _w;\r\n\tprivate readonly int _width;\r\n\tprivate readonly int _height;\r\n\r\n\t// Previous frame\u0027s pixels, kept for delta encoding. Null until an opaque frame has been written\r\n\t// (and reset by whole-frame fallbacks, which invalidate what\u0027s on the canvas for diffing).\r\n\tprivate Color32[] _prev;\r\n\r\n\t// 1\u00D71 transparent frame body: emitted when a frame is identical to the previous one, purely to carry its delay.\r\n\tprivate static readonly Color32[] NoChangePixel = { new Color32( 0, 0, 0, 0 ) };\r\n\r\n\t/// \u003Csummary\u003EBegin an animated GIF of the given size. \u003Cparamref name=\u0022loopCount\u0022/\u003E: 0 = infinite loop,\r\n\t/// 1 = play once, N = loop N times.\u003C/summary\u003E\r\n\tpublic GifEncoder( int width, int height, int loopCount = 0 )\r\n\t{\r\n\t\t_width = width;\r\n\t\t_height = height;\r\n\t\t_w = new ByteBuffer( 1 \u003C\u003C 16 );\r\n\r\n\t\t// \u2500\u2500 Header \u2500\u2500\r\n\t\t_w.WriteAscii( \u0022GIF89a\u0022 );\r\n\r\n\t\t// \u2500\u2500 Logical Screen Descriptor \u2500\u2500 (no global color table \u2014 we use local tables per frame)\r\n\t\t_w.WriteUInt16( (ushort)width );\r\n\t\t_w.WriteUInt16( (ushort)height );\r\n\t\t_w.WriteByte( 0x70 ); // packed: no GCT; color resolution 8-bit (bits 4-6 \u2014 tools report this as the file\u0027s bit depth)\r\n\t\t_w.WriteByte( 0x00 ); // background color index\r\n\t\t_w.WriteByte( 0x00 ); // pixel aspect ratio\r\n\r\n\t\t// \u2500\u2500 Netscape Application Extension (looping) \u2500\u2500\r\n\t\tif ( loopCount != 1 )\r\n\t\t{\r\n\t\t\t_w.WriteByte( 0x21 ); // extension introducer\r\n\t\t\t_w.WriteByte( 0xFF ); // application extension\r\n\t\t\t_w.WriteByte( 11 );   // block size\r\n\t\t\t_w.WriteAscii( \u0022NETSCAPE2.0\u0022 );\r\n\t\t\t_w.WriteByte( 3 );    // sub-block size\r\n\t\t\t_w.WriteByte( 1 );    // sub-block ID\r\n\t\t\t_w.WriteUInt16( (ushort)(loopCount == 0 ? 0 : loopCount - 1) );\r\n\t\t\t_w.WriteByte( 0 );    // block terminator\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EQuantize and append one frame. \u003Cparamref name=\u0022pixels\u0022/\u003E is RGBA, row-major top-down, length\r\n\t/// = width * height. The pixel array isn\u0027t retained past this call.\u003C/summary\u003E\r\n\tpublic void AddFrame( Color32[] pixels, int delayCentiseconds )\r\n\t{\r\n\t\t// Source transparency collides with the delta\u0027s keep-previous transparency \u2014 write those whole,\r\n\t\t// with the pre-delta disposal (restore to background), and restart diffing on the next opaque frame.\r\n\t\tif ( HasTransparentPixels( pixels ) )\r\n\t\t{\r\n\t\t\tWriteFrame( _w, 0, 0, _width, _height, pixels, delayCentiseconds, disposal: 2 );\r\n\t\t\t_prev = null;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _prev is null )\r\n\t\t{\r\n\t\t\tWriteFrame( _w, 0, 0, _width, _height, pixels, delayCentiseconds, disposal: 1 );\r\n\t\t\t_prev = new Color32[pixels.Length];\r\n\t\t\tArray.Copy( pixels, _prev, pixels.Length );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !ComputeDiffRect( pixels, _prev, _width, _height, out int left, out int top, out int rectW, out int rectH ) )\r\n\t\t{\r\n\t\t\t// Identical frame \u2014 emit a 1\u00D71 transparent frame just to carry the delay.\r\n\t\t\tWriteFrame( _w, 0, 0, 1, 1, NoChangePixel, delayCentiseconds, disposal: 1 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Delta frame: the changed bounding rect, with still-matching pixels inside it transparent\r\n\t\t// so they keep the previous frame\u0027s color (and LZW-compress to almost nothing).\r\n\t\tvar rect = new Color32[rectW * rectH];\r\n\t\tfor ( int y = 0; y \u003C rectH; y\u002B\u002B )\r\n\t\t{\r\n\t\t\tint src = (top \u002B y) * _width \u002B left;\r\n\t\t\tint dst = y * rectW;\r\n\t\t\tfor ( int x = 0; x \u003C rectW; x\u002B\u002B, src\u002B\u002B, dst\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar px = pixels[src];\r\n\t\t\t\tvar pv = _prev[src];\r\n\t\t\t\trect[dst] = (px.r == pv.r \u0026\u0026 px.g == pv.g \u0026\u0026 px.b == pv.b) ? default : px;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tWriteFrame( _w, left, top, rectW, rectH, rect, delayCentiseconds, disposal: 1 );\r\n\t\tArray.Copy( pixels, _prev, pixels.Length );\r\n\t}\r\n\r\n\tprivate static bool HasTransparentPixels( Color32[] pixels )\r\n\t{\r\n\t\tforeach ( var px in pixels )\r\n\t\t\tif ( px.a \u003C 128 )\r\n\t\t\t\treturn true;\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EBounding rectangle of pixels whose RGB differs between the two frames.\r\n\t/// Returns false when the frames are identical.\u003C/summary\u003E\r\n\tprivate static bool ComputeDiffRect( Color32[] cur, Color32[] prev, int width, int height,\r\n\t\tout int left, out int top, out int rectW, out int rectH )\r\n\t{\r\n\t\tint minX = width, minY = height, maxX = -1, maxY = -1;\r\n\t\tint i = 0;\r\n\t\tfor ( int y = 0; y \u003C height; y\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int x = 0; x \u003C width; x\u002B\u002B, i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tif ( cur[i].r == prev[i].r \u0026\u0026 cur[i].g == prev[i].g \u0026\u0026 cur[i].b == prev[i].b )\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\tif ( x \u003C minX ) minX = x;\r\n\t\t\t\tif ( x \u003E maxX ) maxX = x;\r\n\t\t\t\tif ( y \u003C minY ) minY = y;\r\n\t\t\t\tif ( y \u003E maxY ) maxY = y;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tleft = minX;\r\n\t\ttop = minY;\r\n\t\trectW = maxX - minX \u002B 1;\r\n\t\trectH = maxY - minY \u002B 1;\r\n\t\treturn maxX \u003E= 0;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EBytes written so far (header \u002B frames appended to date). Lets callers meter how much each\r\n\t/// AddFrame cost \u2014 the size estimator samples a few frames and extrapolates from the growth.\u003C/summary\u003E\r\n\tpublic int Length =\u003E _w.Count;\r\n\r\n\t/// \u003Csummary\u003EWrite the trailer and return the finished GIF bytes.\u003C/summary\u003E\r\n\tpublic byte[] Finish()\r\n\t{\r\n\t\t_w.WriteByte( 0x3B );\r\n\t\treturn _w.ToArray();\r\n\t}\r\n\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// Frame writing\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\tprivate static void WriteFrame( ByteBuffer w, int left, int top, int width, int height, Color32[] pixels,\r\n\t\tint delayCentiseconds, int disposal )\r\n\t{\r\n\t\t// Quantize RGBA \u2192 palette \u002B indices\r\n\t\tQuantize( pixels, out var palette, out var indices, out int transparentIndex );\r\n\r\n\t\tint paletteBits = PaletteBits( palette.Length );\r\n\t\tint paletteSize = 1 \u003C\u003C paletteBits;\r\n\r\n\t\t// \u2500\u2500 Graphic Control Extension \u2500\u2500\r\n\t\tw.WriteByte( 0x21 ); // extension introducer\r\n\t\tw.WriteByte( 0xF9 ); // graphic control label\r\n\t\tw.WriteByte( 4 );    // block size\r\n\r\n\t\t// Packed: disposal method (1 = do not dispose for delta frames, 2 = restore to bg), transparent flag\r\n\t\tbyte packed = (byte)((disposal \u0026 0x7) \u003C\u003C 2);\r\n\t\tif ( transparentIndex \u003E= 0 )\r\n\t\t\tpacked |= 0x01; // transparent color flag\r\n\t\tw.WriteByte( packed );\r\n\r\n\t\tint delay = Math.Max( delayCentiseconds, 2 ); // browsers ignore delay \u003C 2cs\r\n\t\tw.WriteUInt16( (ushort)delay );\r\n\t\tw.WriteByte( (byte)(transparentIndex \u003E= 0 ? transparentIndex : 0) );\r\n\t\tw.WriteByte( 0 ); // block terminator\r\n\r\n\t\t// \u2500\u2500 Image Descriptor \u2500\u2500\r\n\t\tw.WriteByte( 0x2C ); // image separator\r\n\t\tw.WriteUInt16( (ushort)left );\r\n\t\tw.WriteUInt16( (ushort)top );\r\n\t\tw.WriteUInt16( (ushort)width );\r\n\t\tw.WriteUInt16( (ushort)height );\r\n\r\n\t\t// Packed: local color table flag \u002B size\r\n\t\tw.WriteByte( (byte)(0x80 | (paletteBits - 1)) ); // LCT flag \u002B LCT size\r\n\r\n\t\t// \u2500\u2500 Local Color Table \u2500\u2500\r\n\t\tfor ( int i = 0; i \u003C paletteSize; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( i \u003C palette.Length )\r\n\t\t\t{\r\n\t\t\t\tw.WriteByte( palette[i].r );\r\n\t\t\t\tw.WriteByte( palette[i].g );\r\n\t\t\t\tw.WriteByte( palette[i].b );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tw.WriteByte( 0 );\r\n\t\t\t\tw.WriteByte( 0 );\r\n\t\t\t\tw.WriteByte( 0 );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// \u2500\u2500 LZW Image Data \u2500\u2500\r\n\t\tint minCodeSize = Math.Max( paletteBits, 2 ); // GIF minimum is 2\r\n\t\tw.WriteByte( (byte)minCodeSize );\r\n\t\tWriteLzwData( w, indices, minCodeSize );\r\n\t\tw.WriteByte( 0 ); // block terminator\r\n\t}\r\n\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// Color quantization\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Quantize RGBA pixels to a palette of up to 256 colors. Index 0 is reserved for transparency if any\r\n\t/// pixel has alpha below 128. Uses a simple popularity-based approach (most frequent colors first), which\r\n\t/// works very well for pixel art with few unique colors; falls back to median-cut when there are many.\r\n\t/// \u003C/summary\u003E\r\n\tprivate static void Quantize( Color32[] pixels, out Color32[] palette, out byte[] indices, out int transparentIndex )\r\n\t{\r\n\t\ttransparentIndex = -1;\r\n\t\tbool hasTransparency = false;\r\n\r\n\t\t// Count unique opaque colors\r\n\t\tvar colorCounts = new Dictionary\u003Cuint, int\u003E();\r\n\t\tforeach ( var px in pixels )\r\n\t\t{\r\n\t\t\tif ( px.a \u003C 128 )\r\n\t\t\t{\r\n\t\t\t\thasTransparency = true;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t\tuint key = PackRgb( px );\r\n\t\t\tcolorCounts.TryGetValue( key, out int count );\r\n\t\t\tcolorCounts[key] = count \u002B 1;\r\n\t\t}\r\n\r\n\t\tint maxColors = hasTransparency ? 255 : 256;\r\n\t\ttransparentIndex = hasTransparency ? 0 : -1;\r\n\t\tint paletteOffset = hasTransparency ? 1 : 0;\r\n\r\n\t\t// Build palette from most popular colors\r\n\t\tColor32[] uniqueColors;\r\n\t\tif ( colorCounts.Count \u003C= maxColors )\r\n\t\t{\r\n\t\t\t// All colors fit \u2014 perfect quantization\r\n\t\t\tuniqueColors = colorCounts.Keys.Select( UnpackRgb ).ToArray();\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t// Too many colors \u2014 use median-cut\r\n\t\t\tuniqueColors = MedianCut( colorCounts, maxColors );\r\n\t\t}\r\n\r\n\t\t// Build final palette\r\n\t\tpalette = new Color32[paletteOffset \u002B uniqueColors.Length];\r\n\t\tif ( hasTransparency )\r\n\t\t\tpalette[0] = new Color32( 0, 0, 0, 0 );\r\n\t\tfor ( int i = 0; i \u003C uniqueColors.Length; i\u002B\u002B )\r\n\t\t\tpalette[paletteOffset \u002B i] = uniqueColors[i];\r\n\r\n\t\t// Build a fast lookup map\r\n\t\tvar colorToIndex = new Dictionary\u003Cuint, byte\u003E( palette.Length );\r\n\t\tfor ( int i = paletteOffset; i \u003C palette.Length; i\u002B\u002B )\r\n\t\t\tcolorToIndex[PackRgb( palette[i] )] = (byte)i;\r\n\r\n\t\t// Map pixels to palette indices\r\n\t\tindices = new byte[pixels.Length];\r\n\t\tfor ( int i = 0; i \u003C pixels.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( pixels[i].a \u003C 128 )\r\n\t\t\t{\r\n\t\t\t\tindices[i] = (byte)transparentIndex;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tuint key = PackRgb( pixels[i] );\r\n\t\t\tif ( colorToIndex.TryGetValue( key, out byte idx ) )\r\n\t\t\t\tindices[i] = idx;\r\n\t\t\telse\r\n\t\t\t\tindices[i] = FindNearest( palette, pixels[i], paletteOffset ); // nearest palette color\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static uint PackRgb( Color32 c ) =\u003E (uint)(c.r \u003C\u003C 16 | c.g \u003C\u003C 8 | c.b);\r\n\tprivate static Color32 UnpackRgb( uint v ) =\u003E new Color32( (byte)(v \u003E\u003E 16), (byte)(v \u003E\u003E 8 \u0026 0xFF), (byte)(v \u0026 0xFF), 255 );\r\n\r\n\tprivate static byte FindNearest( Color32[] palette, Color32 target, int startIndex )\r\n\t{\r\n\t\tint bestDist = int.MaxValue;\r\n\t\tbyte bestIdx = (byte)startIndex;\r\n\t\tfor ( int i = startIndex; i \u003C palette.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tint dr = palette[i].r - target.r;\r\n\t\t\tint dg = palette[i].g - target.g;\r\n\t\t\tint db = palette[i].b - target.b;\r\n\t\t\tint dist = dr * dr \u002B dg * dg \u002B db * db;\r\n\t\t\tif ( dist \u003C bestDist )\r\n\t\t\t{\r\n\t\t\t\tbestDist = dist;\r\n\t\t\t\tbestIdx = (byte)i;\r\n\t\t\t\tif ( dist == 0 ) break;\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn bestIdx;\r\n\t}\r\n\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// Median-cut quantization\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\tprivate static Color32[] MedianCut( Dictionary\u003Cuint, int\u003E colorCounts, int maxColors )\r\n\t{\r\n\t\t// Build initial box of all colors with their counts\r\n\t\tvar allColors = colorCounts.Select( kv =\u003E (color: UnpackRgb( kv.Key ), count: kv.Value) ).ToList();\r\n\t\tvar boxes = new List\u003CList\u003C(Color32 color, int count)\u003E\u003E { allColors };\r\n\r\n\t\twhile ( boxes.Count \u003C maxColors )\r\n\t\t{\r\n\t\t\t// Find the box with the largest color range\r\n\t\t\tint bestBox = 0;\r\n\t\t\tint bestRange = -1;\r\n\t\t\tfor ( int b = 0; b \u003C boxes.Count; b\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tif ( boxes[b].Count \u003C= 1 ) continue;\r\n\t\t\t\tint range = BoxRange( boxes[b] );\r\n\t\t\t\tif ( range \u003E bestRange )\r\n\t\t\t\t{\r\n\t\t\t\t\tbestRange = range;\r\n\t\t\t\t\tbestBox = b;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tif ( bestRange \u003C= 0 ) break;\r\n\r\n\t\t\t// Split the box along its widest channel\r\n\t\t\tvar box = boxes[bestBox];\r\n\t\t\tint channel = WidestChannel( box );\r\n\t\t\tbox.Sort( ( a, b ) =\u003E ChannelValue( a.color, channel ).CompareTo( ChannelValue( b.color, channel ) ) );\r\n\r\n\t\t\tint totalCount = box.Sum( c =\u003E c.count );\r\n\t\t\tint halfCount = totalCount / 2;\r\n\t\t\tint cumulative = 0;\r\n\t\t\tint splitAt = 0;\r\n\t\t\tfor ( int i = 0; i \u003C box.Count; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tcumulative \u002B= box[i].count;\r\n\t\t\t\tif ( cumulative \u003E= halfCount )\r\n\t\t\t\t{\r\n\t\t\t\t\tsplitAt = Math.Max( 1, i );\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tboxes[bestBox] = box.GetRange( 0, splitAt );\r\n\t\t\tboxes.Add( box.GetRange( splitAt, box.Count - splitAt ) );\r\n\t\t}\r\n\r\n\t\t// Average each box to produce a palette color\r\n\t\treturn boxes.Select( box =\u003E\r\n\t\t{\r\n\t\t\tlong tr = 0, tg = 0, tb = 0, total = 0;\r\n\t\t\tforeach ( var (color, count) in box )\r\n\t\t\t{\r\n\t\t\t\ttr \u002B= color.r * count;\r\n\t\t\t\ttg \u002B= color.g * count;\r\n\t\t\t\ttb \u002B= color.b * count;\r\n\t\t\t\ttotal \u002B= count;\r\n\t\t\t}\r\n\t\t\treturn new Color32( (byte)(tr / total), (byte)(tg / total), (byte)(tb / total), 255 );\r\n\t\t} ).ToArray();\r\n\t}\r\n\r\n\tprivate static int BoxRange( List\u003C(Color32 color, int count)\u003E box )\r\n\t{\r\n\t\tint rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0;\r\n\t\tforeach ( var (c, _) in box )\r\n\t\t{\r\n\t\t\tif ( c.r \u003C rMin ) rMin = c.r; if ( c.r \u003E rMax ) rMax = c.r;\r\n\t\t\tif ( c.g \u003C gMin ) gMin = c.g; if ( c.g \u003E gMax ) gMax = c.g;\r\n\t\t\tif ( c.b \u003C bMin ) bMin = c.b; if ( c.b \u003E bMax ) bMax = c.b;\r\n\t\t}\r\n\t\treturn Math.Max( rMax - rMin, Math.Max( gMax - gMin, bMax - bMin ) );\r\n\t}\r\n\r\n\tprivate static int WidestChannel( List\u003C(Color32 color, int count)\u003E box )\r\n\t{\r\n\t\tint rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0;\r\n\t\tforeach ( var (c, _) in box )\r\n\t\t{\r\n\t\t\tif ( c.r \u003C rMin ) rMin = c.r; if ( c.r \u003E rMax ) rMax = c.r;\r\n\t\t\tif ( c.g \u003C gMin ) gMin = c.g; if ( c.g \u003E gMax ) gMax = c.g;\r\n\t\t\tif ( c.b \u003C bMin ) bMin = c.b; if ( c.b \u003E bMax ) bMax = c.b;\r\n\t\t}\r\n\t\tint rRange = rMax - rMin, gRange = gMax - gMin, bRange = bMax - bMin;\r\n\t\tif ( rRange \u003E= gRange \u0026\u0026 rRange \u003E= bRange ) return 0;\r\n\t\tif ( gRange \u003E= bRange ) return 1;\r\n\t\treturn 2;\r\n\t}\r\n\r\n\tprivate static int ChannelValue( Color32 c, int channel ) =\u003E channel switch\r\n\t{\r\n\t\t0 =\u003E c.r,\r\n\t\t1 =\u003E c.g,\r\n\t\t_ =\u003E c.b,\r\n\t};\r\n\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// LZW compression\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\tprivate static void WriteLzwData( ByteBuffer w, byte[] indices, int minCodeSize )\r\n\t{\r\n\t\tint clearCode = 1 \u003C\u003C minCodeSize;\r\n\t\tint eoiCode = clearCode \u002B 1;\r\n\t\tint nextCode = eoiCode \u002B 1;\r\n\t\tint codeSize = minCodeSize \u002B 1;\r\n\t\tint maxCode = (1 \u003C\u003C codeSize) - 1;\r\n\r\n\t\t// String table: maps (prefix code, suffix byte) \u2192 code.\r\n\t\tvar table = new Dictionary\u003Clong, int\u003E( 4096 );\r\n\r\n\t\tvar bitBuffer = new LzwBitBuffer();\r\n\r\n\t\t// Emit clear code\r\n\t\tbitBuffer.WriteBits( clearCode, codeSize );\r\n\r\n\t\tvoid ResetTable()\r\n\t\t{\r\n\t\t\ttable.Clear();\r\n\t\t\tnextCode = eoiCode \u002B 1;\r\n\t\t\tcodeSize = minCodeSize \u002B 1;\r\n\t\t\tmaxCode = (1 \u003C\u003C codeSize) - 1;\r\n\t\t}\r\n\r\n\t\tif ( indices.Length == 0 )\r\n\t\t{\r\n\t\t\tbitBuffer.WriteBits( eoiCode, codeSize );\r\n\t\t\tbitBuffer.FlushToSubBlocks( w );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tint prefix = indices[0];\r\n\r\n\t\tfor ( int i = 1; i \u003C indices.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tbyte suffix = indices[i];\r\n\t\t\tlong key = ((long)prefix \u003C\u003C 8) | suffix;\r\n\r\n\t\t\tif ( table.TryGetValue( key, out int existingCode ) )\r\n\t\t\t{\r\n\t\t\t\tprefix = existingCode;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\t// Emit the code for the prefix\r\n\t\t\t\tbitBuffer.WriteBits( prefix, codeSize );\r\n\r\n\t\t\t\t// Add new entry if table isn\u0027t full\r\n\t\t\t\tif ( nextCode \u003C= 4095 )\r\n\t\t\t\t{\r\n\t\t\t\t\ttable[key] = nextCode\u002B\u002B;\r\n\t\t\t\t\tif ( nextCode \u003E maxCode \u002B 1 \u0026\u0026 codeSize \u003C 12 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tcodeSize\u002B\u002B;\r\n\t\t\t\t\t\tmaxCode = (1 \u003C\u003C codeSize) - 1;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t\telse\r\n\t\t\t\t{\r\n\t\t\t\t\t// Table is full \u2014 emit clear code and reset\r\n\t\t\t\t\tbitBuffer.WriteBits( clearCode, codeSize );\r\n\t\t\t\t\tResetTable();\r\n\t\t\t\t}\r\n\r\n\t\t\t\tprefix = suffix;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// Emit final prefix code and EOI\r\n\t\tbitBuffer.WriteBits( prefix, codeSize );\r\n\t\tbitBuffer.WriteBits( eoiCode, codeSize );\r\n\r\n\t\tbitBuffer.FlushToSubBlocks( w );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Accumulates LZW codes as a bit stream and flushes them as GIF sub-blocks (max 255 bytes each).\r\n\t/// \u003C/summary\u003E\r\n\tprivate sealed class LzwBitBuffer\r\n\t{\r\n\t\tprivate readonly List\u003Cbyte\u003E _bytes = new( 4096 );\r\n\t\tprivate int _currentBits;\r\n\t\tprivate int _bitCount;\r\n\r\n\t\tpublic void WriteBits( int code, int codeSize )\r\n\t\t{\r\n\t\t\t_currentBits |= code \u003C\u003C _bitCount;\r\n\t\t\t_bitCount \u002B= codeSize;\r\n\r\n\t\t\twhile ( _bitCount \u003E= 8 )\r\n\t\t\t{\r\n\t\t\t\t_bytes.Add( (byte)(_currentBits \u0026 0xFF) );\r\n\t\t\t\t_currentBits \u003E\u003E= 8;\r\n\t\t\t\t_bitCount -= 8;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tpublic void FlushToSubBlocks( ByteBuffer w )\r\n\t\t{\r\n\t\t\t// Flush remaining bits\r\n\t\t\tif ( _bitCount \u003E 0 )\r\n\t\t\t\t_bytes.Add( (byte)(_currentBits \u0026 0xFF) );\r\n\r\n\t\t\t// Write as GIF sub-blocks (max 255 bytes per block)\r\n\t\t\tint offset = 0;\r\n\t\t\twhile ( offset \u003C _bytes.Count )\r\n\t\t\t{\r\n\t\t\t\tint blockSize = Math.Min( 255, _bytes.Count - offset );\r\n\t\t\t\tw.WriteByte( (byte)blockSize );\r\n\t\t\t\tfor ( int i = 0; i \u003C blockSize; i\u002B\u002B )\r\n\t\t\t\t\tw.WriteByte( _bytes[offset \u002B i] );\r\n\t\t\t\toffset \u002B= blockSize;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// Helpers\r\n\t// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\tprivate static int PaletteBits( int colorCount )\r\n\t{\r\n\t\t// GIF requires palette size to be a power of 2 (min 2 = 2^1)\r\n\t\tint bits = 1;\r\n\t\twhile ( (1 \u003C\u003C bits) \u003C colorCount )\r\n\t\t\tbits\u002B\u002B;\r\n\t\treturn Math.Min( bits, 8 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EA growable little-endian byte sink \u2014 the sandbox-safe stand-in for BinaryWriter/Stream.\u003C/summary\u003E\r\n\tprivate sealed class ByteBuffer\r\n\t{\r\n\t\tprivate readonly List\u003Cbyte\u003E _bytes;\r\n\r\n\t\tpublic ByteBuffer( int capacity ) =\u003E _bytes = new List\u003Cbyte\u003E( capacity );\r\n\r\n\t\tpublic int Count =\u003E _bytes.Count;\r\n\r\n\t\tpublic void WriteByte( byte b ) =\u003E _bytes.Add( b );\r\n\r\n\t\tpublic void WriteUInt16( ushort value )\r\n\t\t{\r\n\t\t\t_bytes.Add( (byte)(value \u0026 0xFF) );\r\n\t\t\t_bytes.Add( (byte)(value \u003E\u003E 8) );\r\n\t\t}\r\n\r\n\t\tpublic void WriteAscii( string s )\r\n\t\t{\r\n\t\t\tforeach ( char c in s )\r\n\t\t\t\t_bytes.Add( (byte)c );\r\n\t\t}\r\n\r\n\t\tpublic byte[] ToArray() =\u003E _bytes.ToArray();\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Game/LocalLevels.cs","FileName":"LocalLevels.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System.Collections.Generic;\nusing Sandbox;\n\nnamespace BlockParty;\n\n/// \u003Csummary\u003E\n/// STANDALONE (non-editor) local level files. A packaged build has no editor assembly, so the\n/// in-game level editor can\u0027t write the project\u0027s \u003Cc\u003EAssets/levels\u003C/c\u003E folder \u2014 instead levels save\n/// as \u003Cc\u003Elevels/{id}.json\u003C/c\u003E in the game data folder (\u003Csee cref=\u0022FileSystem.Data\u0022/\u003E), beside the\n/// \u003Cc\u003Egifs/\u003C/c\u003E export folder, so a player\u0027s creations are easy to find, hand-edit and share.\n/// \u003Csee cref=\u0022Levels.Reload\u0022/\u003E folds these files into the registry (marked\n/// \u003Csee cref=\u0022LevelDef.IsLocal\u0022/\u003E), which makes a local level loadable BY ID everywhere the editor\n/// loads levels (the picker, \u003Cc\u003ELoadFromLevel\u003C/c\u003E, the last-opened restore) and browsable in the\n/// level picker\u0027s Local grid view. A local file whose id collides with a shipped level is skipped \u2014\n/// it must never shadow map/leaderboard content.\n/// \u003C/summary\u003E\npublic static class LocalLevels\n{\n\t/// \u003Csummary\u003EPlayer level files live in their own subfolder of the game data directory, beside\n\t/// the \u003Csee cref=\u0022GifExporter.DirName\u0022/\u003E export folder.\u003C/summary\u003E\n\tpublic const string DirName = \u0022levels\u0022;\n\n\t/// \u003Csummary\u003EWhether local level files are in play: standalone builds only \u2014 in the editor the\n\t/// project\u0027s Assets folder is the single source of truth.\u003C/summary\u003E\n\tpublic static bool Enabled =\u003E !Game.IsEditor;\n\n\t/// \u003Csummary\u003EData-folder path of a local level\u0027s JSON file.\u003C/summary\u003E\n\tpublic static string PathFor( string id ) =\u003E $\u0022{DirName}/{id}.json\u0022;\n\n\t/// \u003Csummary\u003ERead every parseable level JSON in the data folder\u0027s \u003Csee cref=\u0022DirName\u0022/\u003E directory.\n\t/// A hand-dropped file that isn\u0027t a level either fails to parse or comes out with no id, so it\u0027s\n\t/// skipped without a warning. Registry policy (collision handling, timestamps) belongs to the\n\t/// caller (\u003Csee cref=\u0022Levels.Reload\u0022/\u003E).\u003C/summary\u003E\n\tpublic static List\u003CLevelDef\u003E LoadAll()\n\t{\n\t\tvar result = new List\u003CLevelDef\u003E();\n\t\tif ( !Enabled ) return result;\n\n\t\ttry\n\t\t{\n\t\t\tforeach ( var name in FileSystem.Data.FindFile( DirName, \u0022*.json\u0022 ) )\n\t\t\t{\n\t\t\t\tvar path = $\u0022{DirName}/{name}\u0022;\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tvar e = EditorLevel.FromJsonString( FileSystem.Data.ReadAllText( path ), out _ );\n\t\t\t\t\tif ( e is null || string.IsNullOrWhiteSpace( e.Id ) ) continue;\n\n\t\t\t\t\tvar def = e.ToLevelDef();\n\t\t\t\t\tdef.SourcePath = path;\n\t\t\t\t\tdef.IsLocal = true;\n\t\t\t\t\tresult.Add( def );\n\t\t\t\t}\n\t\t\t\tcatch { /* not a level file \u2014 skip quietly */ }\n\t\t\t}\n\t\t}\n\t\tcatch ( System.Exception ex )\n\t\t{\n\t\t\tLog.Warning( $\u0022[BlockParty] scanning local levels: {ex.Message}\u0022 );\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// \u003Csummary\u003EStandalone counterpart of the editor-assembly \u003Cc\u003Elevel_save\u003C/c\u003E: write the open\n\t/// editor level to \u003Cc\u003Elevels/{id}.json\u003C/c\u003E in the game data folder and fold it into the live\n\t/// registry so it can be re-opened / test-played immediately. Same refusals as the editor path:\n\t/// unnamed or unsafe ids, and overwriting an EXISTING file that this document wasn\u0027t loaded from.\u003C/summary\u003E\n\tpublic static void Save( LevelEditorStage stage )\n\t{\n\t\tvar id = stage.Level.Id?.Trim();\n\t\tif ( !IsSafeLevelId( id ) )\n\t\t{\n\t\t\tstage.ReportProjectError( string.IsNullOrWhiteSpace( id ) ? \u0022level has no id\u0022 : $\u0022invalid level id \u0027{id}\u0027\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\t// Shipped ids are permanently taken: a local file must never shadow map/leaderboard content\n\t\t// (the loader skips such files, so allowing the save would just write an unloadable file).\n\t\tif ( Levels.IsShippedLevel( id ) )\n\t\t{\n\t\t\tstage.ReportProjectError( $\u0022\u0027{id}\u0027 is a built-in level; pick another id\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\t// The ws{fileId} namespace belongs to installed workshop levels \u2014 a local file there could\n\t\t// shadow (or be shadowed by) a downloaded level at the registry fold-in, depending on order.\n\t\tif ( WorkshopLevels.IsWorkshopId( id ) )\n\t\t{\n\t\t\tstage.ReportProjectError( $\u0022\u0027{id}\u0027 is a reserved workshop id; pick another id\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\tvar path = PathFor( id );\n\t\tbool updatingSource = stage.OwnsProjectFile( id );\n\t\tif ( FileSystem.Data.FileExists( path ) \u0026\u0026 !updatingSource )\n\t\t{\n\t\t\tstage.ReportProjectError( $\u0022level \u0027{id}\u0027 already exists; load it before overwriting\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\t// The document id IS the file name from here on: serialize and register the trimmed form so a\n\t\t// reopened level\u0027s id (and ProjectSourceId) matches the file it came from.\n\t\tstage.Level.Id = id;\n\t\tFileSystem.Data.CreateDirectory( DirName );\n\t\tFileSystem.Data.WriteAllText( path, stage.Level.ToJsonString() );\n\t\tstage.MarkSavedToProject( id );\n\n\t\t// Stamp the save time so the picker\u0027s Recent sort works for local levels too (standalone has\n\t\t// no editor assembly to stat files, so the save moment IS the timestamp).\n\t\tEditorPrefs.Current.LevelBrowserLevelTimes ??= new();\n\t\tEditorPrefs.Current.LevelBrowserLevelTimes[id] = System.DateTimeOffset.UtcNow.Ticks;\n\t\tEditorPrefs.Save();\n\n\t\tvar def = stage.Level.ToLevelDef();\n\t\tdef.SourcePath = path;\n\t\tdef.IsLocal = true;\n\t\tLevels.ApplySavedLevel( def );\n\t\tLog.Info( $\u0022[BlockParty] Saved local level \u0027{id}\u0027 -\u003E {FileSystem.Data.GetFullPath( path )}.\u0022 );\n\t}\n\n\t/// \u003Csummary\u003EStandalone counterpart of the editor-assembly \u003Cc\u003Elevel_delete\u003C/c\u003E: remove the open\n\t/// level\u0027s local JSON file and drop it from the live registry.\u003C/summary\u003E\n\tpublic static void Delete( LevelEditorStage stage )\n\t{\n\t\tvar id = stage.Level.Id?.Trim();\n\t\tif ( !IsSafeLevelId( id ) )\n\t\t{\n\t\t\tstage.ReportProjectError( string.IsNullOrWhiteSpace( id ) ? \u0022level has no id\u0022 : $\u0022invalid level id \u0027{id}\u0027\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\t// Same claim rule as Save: only the file this document came from can be deleted.\n\t\tif ( !stage.OwnsProjectFile( id ) )\n\t\t{\n\t\t\tstage.ReportProjectError( $\u0022\u0027{id}\u0027 isn\u0027t the file this level was opened from; load it before deleting\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\tvar path = PathFor( id );\n\t\tif ( !FileSystem.Data.FileExists( path ) )\n\t\t{\n\t\t\tstage.ReportProjectError( $\u0022no local file for level \u0027{id}\u0027\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\tFileSystem.Data.DeleteFile( path );\n\t\tEditorPrefs.Current.LevelBrowserLevelTimes?.Remove( id );\n\t\tEditorPrefs.Save();\n\n\t\t// Only drop a LOCAL registry entry: if the id somehow matches a shipped level (its local\n\t\t// file was collision-skipped at load), the registry entry isn\u0027t ours to remove.\n\t\tif ( Levels.Get( id )?.IsLocal == true )\n\t\t\tLevels.RemoveDeletedLevel( id );\n\n\t\tstage.ResetToDefault();\n\t\tLog.Info( $\u0022[BlockParty] Deleted local level \u0027{id}\u0027.\u0022 );\n\t}\n\n\t/// \u003Csummary\u003EFilename- and console-token-safe id: ASCII letters, digits, \u0027-\u0027, \u0027_\u0027 and \u0027.\u0027, never\n\t/// \u0022.\u0022 / \u0022..\u0022. The game assembly can\u0027t consult Path.GetInvalidFileNameChars, so this is a\n\t/// stricter allowlist mirror of the editor writer\u0027s rules (which also refuse whitespace).\u003C/summary\u003E\n\tpublic static bool IsSafeLevelId( string id )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( id ) || id == \u0022.\u0022 || id == \u0022..\u0022 ) return false;\n\t\tforeach ( var c in id )\n\t\t{\n\t\t\tbool ok = (c \u003E= \u0027A\u0027 \u0026\u0026 c \u003C= \u0027Z\u0027) || (c \u003E= \u0027a\u0027 \u0026\u0026 c \u003C= \u0027z\u0027) || (c \u003E= \u00270\u0027 \u0026\u0026 c \u003C= \u00279\u0027)\n\t\t\t\t|| c == \u0027-\u0027 || c == \u0027_\u0027 || c == \u0027.\u0027;\n\t\t\tif ( !ok ) return false;\n\t\t}\n\t\treturn true;\n\t}\n}\n"},{"Ident":"facepunch.blockparty","Path":"Game/RectF.cs","FileName":"RectF.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"namespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Axis-aligned float rectangle matching the original GameAPI \u003Cc\u003ERectF\u003C/c\u003E used by the\r\n/// collision code. Y is up (Bottom is less than Top). Constructed as (left, bottom, right, top).\r\n/// \u003C/summary\u003E\r\npublic struct RectF\r\n{\r\n\tpublic float Left;\r\n\tpublic float Bottom;\r\n\tpublic float Right;\r\n\tpublic float Top;\r\n\r\n\tpublic RectF( float left, float bottom, float right, float top )\r\n\t{\r\n\t\tLeft = left;\r\n\t\tBottom = bottom;\r\n\t\tRight = right;\r\n\t\tTop = top;\r\n\t}\r\n\r\n\tpublic float Width =\u003E Right - Left;\r\n\tpublic float Height =\u003E Top - Bottom;\r\n\r\n\tpublic readonly Vector2 BottomLeft =\u003E new Vector2( Left, Bottom );\r\n\tpublic readonly Vector2 TopLeft =\u003E new Vector2( Left, Top );\r\n\tpublic readonly Vector2 BottomRight =\u003E new Vector2( Right, Bottom );\r\n\tpublic readonly Vector2 TopRight =\u003E new Vector2( Right, Top );\r\n\r\n\t/// \u003Csummary\u003ETrue if this rect overlaps \u003Cparamref name=\u0022other\u0022/\u003E (open intervals \u2014 touching edges do not count).\u003C/summary\u003E\r\n\tpublic readonly bool Intersects( RectF other )\r\n\t{\r\n\t\treturn Left \u003C other.Right \u0026\u0026 Right \u003E other.Left \u0026\u0026\r\n\t\t\t   Bottom \u003C other.Top \u0026\u0026 Top \u003E other.Bottom;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETrue if the point lies within this rect (inclusive edges).\u003C/summary\u003E\r\n\tpublic readonly bool Touches( Vector2 p )\r\n\t{\r\n\t\treturn p.x \u003E= Left \u0026\u0026 p.x \u003C= Right \u0026\u0026 p.y \u003E= Bottom \u0026\u0026 p.y \u003C= Top;\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Game/ReplayReactionTimeline.cs","FileName":"ReplayReactionTimeline.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace BlockParty;\n\n/// \u003Csummary\u003EBound reaction markers to the timeline\u0027s visible resolution, rather than one UI\n/// element per player. Equal-width slices make nearby reactions readable as a density plot.\u003C/summary\u003E\npublic static class ReplayReactionTimeline\n{\n\tpublic readonly record struct Marker( float Fraction, int Count, int FirstFrame, int LastFrame )\n\t{\n\t\tpublic int Height =\u003E 4 \u002B Math.Min( 4, (int)Math.Log2( Math.Max( 1, Count ) ) ) * 3;\n\t\t// Land just past fade-in so a paused marker click actually displays the reaction.\n\t\tpublic int SeekFrame( int length ) =\u003E Math.Clamp( FirstFrame \u002B 6, 0, Math.Max( 0, length - 1 ) );\n\t}\n\n\tpublic static IReadOnlyList\u003CMarker\u003E Build( IReadOnlyList\u003CKeyValuePair\u003Clong, ReplayReaction\u003E\u003E entries, int length, int slices )\n\t{\n\t\tif ( length \u003C= 0 || entries is null || entries.Count == 0 ) return Array.Empty\u003CMarker\u003E();\n\t\tslices = Math.Clamp( slices, 1, 128 );\n\t\tvar counts = new int[slices];\n\t\tvar first = new int[slices];\n\t\tvar last = new int[slices];\n\t\tforeach ( var (_, reaction) in entries )\n\t\t{\n\t\t\tif ( reaction is null || reaction.Removed || reaction.Frame \u003C 0 || reaction.Frame \u003E= length ) continue;\n\t\t\tint slice = (int)((long)reaction.Frame * slices / length);\n\t\t\tif ( counts[slice]\u002B\u002B == 0 ) first[slice] = last[slice] = reaction.Frame;\n\t\t\telse\n\t\t\t{\n\t\t\t\tfirst[slice] = Math.Min( first[slice], reaction.Frame );\n\t\t\t\tlast[slice] = Math.Max( last[slice], reaction.Frame );\n\t\t\t}\n\t\t}\n\t\tvar markers = new List\u003CMarker\u003E();\n\t\tfor ( int i = 0; i \u003C slices; i\u002B\u002B )\n\t\t\tif ( counts[i] \u003E 0 ) markers.Add( new Marker(\n\t\t\t\tcounts[i] == 1 ? first[i] / (float)length : (i \u002B 0.5f) / slices, counts[i], first[i], last[i] ) );\n\t\treturn markers;\n\t}\n\n\tpublic static Marker? Nearest( IReadOnlyList\u003CMarker\u003E markers, float fraction, float tolerance )\n\t{\n\t\tMarker? nearest = null;\n\t\tforeach ( var marker in markers )\n\t\t{\n\t\t\tfloat distance = Math.Abs( marker.Fraction - fraction );\n\t\t\tif ( distance \u003E tolerance ) continue;\n\t\t\tnearest = marker;\n\t\t\ttolerance = distance;\n\t\t}\n\t\treturn nearest;\n\t}\n}\n"},{"Ident":"facepunch.blockparty","Path":"Game/RunDataStore.cs","FileName":"RunDataStore.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Threading.Tasks;\r\nusing Sandbox;\r\n\r\nnamespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Lazy, cached fetcher for run payloads. Leaderboard entries expose a \u003Cc\u003EDataUrl\u003C/c\u003E (the JSON file\r\n/// the payload was submitted with); this fetches/caches it per URL so the score list only loads each\r\n/// row once and a repaint is cheap. Failures cache as null until a board reload permits a retry.\r\n/// (Mirrors SS2\u0027s \u003Cc\u003ERunResultStore\u003C/c\u003E.)\r\n/// \u003C/summary\u003E\r\npublic static class RunDataStore\r\n{\r\n\t/// \u003Csummary\u003EChanges when a payload/failure arrives, is replaced, or a failure is invalidated.\u003C/summary\u003E\r\n\tpublic static int Revision { get; private set; }\r\n\tprivate static readonly Dictionary\u003Cstring, RunData\u003E _cache = new();\r\n\tprivate static readonly HashSet\u003Cstring\u003E _inFlight = new();\r\n\t// Every consumer waiting on an in-flight url (a row component \u002B the host\u0027s ms-dedup can both want\r\n\t// the same payload). All are notified when it lands \u2014 a single-callback store would leave the\r\n\t// later callers (e.g. the child row component) un-repainted.\r\n\tprivate static readonly Dictionary\u003Cstring, List\u003CAction\u003E\u003E _pending = new();\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Returns the cached payload for this DataUrl, or null while it loads (or if it has no data /\r\n\t/// failed to load). Kicks off a one-time fetch on the first miss; \u003Cparamref name=\u0022onLoaded\u0022/\u003E\r\n\t/// fires when it lands (pass a repaint callback so the row updates once the time arrives). Multiple\r\n\t/// distinct callbacks for the same url are all invoked; the same callback isn\u0027t queued twice.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static RunData Get( string dataUrl, Action onLoaded = null )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( dataUrl ) )\r\n\t\t\treturn null;\r\n\r\n\t\tif ( _cache.TryGetValue( dataUrl, out var cached ) )\r\n\t\t\treturn cached;\r\n\r\n\t\tif ( onLoaded is not null )\r\n\t\t{\r\n\t\t\tif ( !_pending.TryGetValue( dataUrl, out var list ) )\r\n\t\t\t{\r\n\t\t\t\tlist = new List\u003CAction\u003E();\r\n\t\t\t\t_pending[dataUrl] = list;\r\n\t\t\t}\r\n\t\t\tif ( !list.Contains( onLoaded ) )\r\n\t\t\t\tlist.Add( onLoaded );\r\n\t\t}\r\n\r\n\t\tif ( !_inFlight.Contains( dataUrl ) )\r\n\t\t{\r\n\t\t\t_inFlight.Add( dataUrl );\r\n\t\t\t_ = Fetch( dataUrl );\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Cache-only lookup: the payload if it has already landed, otherwise null \u2014 unlike\r\n\t/// \u003Csee cref=\u0022Get\u0022/\u003E this never starts a fetch. For callers that will happily use a payload that\r\n\t/// happens to be cached but must not pull one in themselves (the level-select footer\u0027s ms-dedup\r\n\t/// over its off-screen rows, which would otherwise fetch a whole board per highlighted level).\r\n\t/// \u003C/summary\u003E\r\n\tpublic static RunData Peek( string dataUrl ) =\u003E TryPeek( dataUrl, out var cached ) ? cached : null;\r\n\r\n\t/// \u003Csummary\u003ECache-only lookup that distinguishes a completed failure (true, null) from a\r\n\t/// payload not yet loaded (false). Neither form starts a request.\u003C/summary\u003E\r\n\tpublic static bool TryPeek( string dataUrl, out RunData data )\r\n\t{\r\n\t\tdata = null;\r\n\t\treturn !string.IsNullOrEmpty( dataUrl ) \u0026\u0026 _cache.TryGetValue( dataUrl, out data );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EAllow one new lazy fetch after a board reload. Does not fetch off-screen rows,\r\n\t/// evict successful payloads, or disturb requests already in flight. Never call per frame.\u003C/summary\u003E\r\n\tpublic static void InvalidateFailure( string dataUrl )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( dataUrl ) || _inFlight.Contains( dataUrl )\r\n\t\t\t|| !_cache.TryGetValue( dataUrl, out var data ) || data is not null ) return;\r\n\t\t_cache.Remove( dataUrl );\r\n\t\tRevision\u002B\u002B;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Seed the store with a payload we already hold in memory, keyed by a synthetic url that a row\r\n\t/// then carries as its DataUrl. Used for a locally-spliced row \u2014 a run we submitted that the\r\n\t/// board hasn\u0027t served back to us yet (see \u003Csee cref=\u0022LeaderboardRows.SpliceLocalBest\u0022/\u003E) \u2014 so it\r\n\t/// resolves its time / character / block tally and offers replay exactly like a backend row, with\r\n\t/// no fetch. Overwrites, so a later better run under the same key replaces the earlier one.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Prime( string dataUrl, RunData data )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( dataUrl ) || data is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( !_cache.TryGetValue( dataUrl, out var previous ) || !ReferenceEquals( previous, data ) )\r\n\t\t{\r\n\t\t\t_cache[dataUrl] = data;\r\n\t\t\tRevision\u002B\u002B;\r\n\t\t}\r\n\r\n\t\t// Belt-and-braces: if anything already asked for this key before we primed it, retire the\r\n\t\t// fetch and repaint those waiters rather than leaving them hanging on a url that can\u0027t load.\r\n\t\t_inFlight.Remove( dataUrl );\r\n\t\tif ( _pending.Remove( dataUrl, out var waiting ) )\r\n\t\t\tforeach ( var cb in waiting )\r\n\t\t\t\tcb?.Invoke();\r\n\t}\r\n\r\n\tprivate static async Task Fetch( string dataUrl )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\t_cache[dataUrl] = await Http.RequestJsonAsync\u003CRunData\u003E( dataUrl );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\u0022BlockParty: failed to fetch run data from {dataUrl}: {e.Message}\u0022 );\r\n\t\t\t_cache[dataUrl] = null; // cache the failure so we don\u0027t retry it every frame\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tRevision\u002B\u002B;\r\n\t\t\t_inFlight.Remove( dataUrl );\r\n\t\t\tif ( _pending.Remove( dataUrl, out var list ) )\r\n\t\t\t\tforeach ( var cb in list )\r\n\t\t\t\t\tcb?.Invoke();\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Game/TestLevels.cs","FileName":"TestLevels.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System.Collections.Generic;\r\n\r\nnamespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// DEBUG-ONLY regression-repro levels: the ConCmds that jump into them plus any per-tick scripting\r\n/// they need live HERE. The level DEFINITIONS now live in \u003Cc\u003EAssets/levels/test/*.json\u003C/c\u003E (loaded\r\n/// like any level); the only shipping hooks are \u003Csee cref=\u0022OnStageEnter\u0022/\u003E / \u003Csee cref=\u0022OnStageTick\u0022/\u003E\r\n/// (both no-ops on non-test levels) and \u003Csee cref=\u0022IsTestLevel\u0022/\u003E.\r\n///\r\n/// The levels live in the normal id registry (so replay/desync machinery treats them like any level)\r\n/// but are excluded from the level-select map, so they\u0027re reached only via their ConCmds.\r\n/// \u003C/summary\u003E\r\npublic static class TestLevels\r\n{\r\n\t/// \u003Csummary\u003EId of the crush-repro test level. Load with the \u003Cc\u003Ecrush_repro\u003C/c\u003E\r\n\t/// console command.\u003C/summary\u003E\r\n\tpublic const string CrushReproId = \u0022crush-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the wrap-repro test level. Load with the \u003Cc\u003Ewrap_repro\u003C/c\u003E\r\n\t/// console command.\u003C/summary\u003E\r\n\tpublic const string WrapReproId = \u0022wrap-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the tile-overhang-repro test level. Load with the\r\n\t/// \u003Cc\u003Etile_overhang_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string TileOverhangReproId = \u0022tile-overhang-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the reverse-detach-repro test level. Load with the\r\n\t/// \u003Cc\u003Ereverse_detach_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string ReverseDetachReproId = \u0022reverse-detach-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the edge-hang-repro test level. Load with the \u003Cc\u003Eedge_hang_repro\u003C/c\u003E\r\n\t/// console command.\u003C/summary\u003E\r\n\tpublic const string EdgeHangReproId = \u0022edge-hang-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the Shifter underside corner-sliver repro level. Load with the\r\n\t/// \u003Cc\u003Eshifter_underside_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string ShifterUndersideReproId = \u0022shifter-underside-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the Shifter tight-gap adoption repro level. Load with the\r\n\t/// \u003Cc\u003Eshifter_gap_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string ShifterGapReproId = \u0022shifter-gap-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the Shifter reverse-field ping-pong repro level. Load with the\r\n\t/// \u003Cc\u003Eshifter_reverse_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string ShifterReverseReproId = \u0022shifter-reverse-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the riding ceiling-glue misfire repro level. Load with the\r\n\t/// \u003Cc\u003Eride_cling_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string RideClingReproId = \u0022ride-cling-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EId of the block-vs-glass-pillar pinch repro level (the glass-pillars witch report).\r\n\t/// Load with the \u003Cc\u003Epillar_pinch_repro\u003C/c\u003E console command.\u003C/summary\u003E\r\n\tpublic const string PillarPinchReproId = \u0022pillar-pinch-repro\u0022;\r\n\r\n\t/// \u003Csummary\u003EIs this level one of the repro test levels? Used by GameStage to exempt them from\r\n\t/// the DEBUG_STARTING_BLOCKS spawn override \u2014 their whole point is their exact authored layout,\r\n\t/// so the repro ConCmds must work even with that override on.\u003C/summary\u003E\r\n\tpublic static bool IsTestLevel( string levelId )\r\n\t\t=\u003E levelId == CrushReproId || levelId == WrapReproId || levelId == TileOverhangReproId\r\n\t\t|| levelId == ReverseDetachReproId || levelId == EdgeHangReproId\r\n\t\t|| levelId == ShifterUndersideReproId || levelId == ShifterGapReproId\r\n\t\t|| levelId == ShifterReverseReproId || levelId == RideClingReproId\r\n\t\t|| levelId == PillarPinchReproId;\r\n\r\n\r\n\t// --- crush repro ---------------------------------------------------------------------\r\n\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump straight into the phantom-crush regression level (the\r\n\t/// crush-repro level). Once it loads, don\u0027t\r\n\t/// touch anything: the block slams the left-wall lip within ~2s. The wedged player surviving\r\n\t/// every slam = healthy; an instant crush death with nothing moving = the stale-stopped-block\r\n\t/// velocity bug is back.\u003C/summary\u003E\r\n\t[ConCmd( \u0022crush_repro\u0022 )]\r\n\tpublic static void LoadCrushReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] crush_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( CrushReproId );\r\n\t\tLog.Info( \u0022[BlockParty] Loading crush-repro level. Don\u0027t move: surviving the slam = healthy; phantom crush death within ~2s = stale-velocity regression.\u0022 );\r\n\t}\r\n\r\n\t// --- wrap-escape repro ---------------------------------------------------------------\r\n\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump straight into the wrap-escape regression level (the wrap-repro\r\n\t/// level) as WRAPPER, the wrap character \u2014 regardless of which character\r\n\t/// is selected on the title screen. Once it loads, don\u0027t touch anything: the player free-falls\r\n\t/// toward the floor, and spikes start growing there just before they cross it. The player\r\n\t/// wrapping to the top of the arena = healthy; the player sailing out through the floor and\r\n\t/// dying offscreen ~0.4s later = the inconsistent-spike-gate escape bug (wrap gate counts\r\n\t/// growing/retracting spikes as spiked, kill check doesn\u0027t). The verdict is logged either way.\u003C/summary\u003E\r\n\t[ConCmd( \u0022wrap_repro\u0022 )]\r\n\tpublic static void LoadWrapReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] wrap_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( WrapReproId, Characters.Wrapper.Id );\r\n\t\tLog.Info( \u0022[BlockParty] Loading wrap-repro level as WRAPPER. Don\u0027t move: wrapping to the top = healthy; escaping through the floor into the void = wrap/kill spike-gate mismatch. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t// --- spike-tile overhang repro ---------------------------------------------------------\r\n\r\n\t// The short-face bar: free-standing mid-arena, 40px wide but only 3px tall, so its LEFT and\r\n\t// RIGHT faces are 3px \u2014 shorter than one 5px tooth tile. Shared between Obstacles and\r\n\t// SpikedObstacleSides (matched by value), and read back by the OnStageEnter verdict check.\r\n\tprivate static readonly RectF TileOverhangBar = new( 100f, 100f, 140f, 103f );\r\n\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump straight into the spike-tile overhang regression level (the\r\n\t/// tile-overhang-repro level). The verdict is measured and logged immediately on\r\n\t/// load \u2014 no interaction needed. Tiles centred on their sub-tile faces (overhang split evenly,\r\n\t/// \u2264 1px per corner here) = healthy; the whole 2px excess poking past one corner (visible as\r\n\t/// spikes jutting below the bar\u0027s bottom corners) = the one-sided flush-clamp bug.\u003C/summary\u003E\r\n\t[ConCmd( \u0022tile_overhang_repro\u0022 )]\r\n\tpublic static void LoadTileOverhangReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] tile_overhang_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( TileOverhangReproId );\r\n\t\tLog.Info( \u0022[BlockParty] Loading tile-overhang-repro level. Verdict logs immediately; also look at the bar\u0027s bottom corners for spikes poking past them.\u0022 );\r\n\t}\r\n\r\n\t// --- reverse-gravity underside-ride detach repro ----------------------------------------\r\n\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump straight into the underside-ride detach regression level (the\r\n\t/// reverse-detach-repro level) as the Original character \u2014 regardless of the\r\n\t/// title-screen pick. Once it loads, don\u0027t touch anything: the reverse field lifts the player onto\r\n\t/// the boxed block\u0027s underside, then the script kills the block to cut the field mid-hang. The\r\n\t/// player detaching and falling back to the pedestal = healthy; the player snapping THROUGH the\r\n\t/// block onto its top in one tick = the missing un-reverse detach in HandleMovingPlatforms. The\r\n\t/// verdict is logged either way.\u003C/summary\u003E\r\n\t[ConCmd( \u0022reverse_detach_repro\u0022 )]\r\n\tpublic static void LoadReverseDetachReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] reverse_detach_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( ReverseDetachReproId, Characters.OriginalId );\r\n\t\tLog.Info( \u0022[BlockParty] Loading reverse-detach-repro level as Original. Don\u0027t move: the field lifts you under the boxed block, then cuts out mid-hang. Falling back down = healthy; teleporting through the block onto its top = underside-ride detach bug. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t// --- moving-platform edge-hang repro ----------------------------------------------------\r\n\r\n\t// Seeded travel speeds: the floor block starts 25 px/s faster than the wall block (mirroring the\r\n\t// replay this came from: ~97 vs ~120) and crosses the 120 px/s line \u2014 one lean-margin per tick,\r\n\t// where the old stale-position ledge release false-fired \u2014 within ~0.2s of load. Both accelerate\r\n\t// at the shared 50 px/s\u00B2 from there, so the differential holds until the floor block slams the\r\n\t// right arena wall (~165 px/s, ~1.1s in).\r\n\tprivate const float EDGE_HANG_WALL_BLOCK_SPEED = 85f;\r\n\tprivate const float EDGE_HANG_FLOOR_BLOCK_SPEED = 100f;\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump straight into the moving-platform edge-hang repro level with the\r\n\t/// CURRENTLY SELECTED character (it exercises shared riding code, so test it with several). Layout:\r\n\t/// the player starts in the corner of a wall block (standing against its right face, near its\r\n\t/// bottom) and a flush floor block whose left edge sits at the player\u0027s feet \u2014 both heading right,\r\n\t/// the floor block seeded faster so it pulls ahead, exactly the replay this reproduces. SHIFTER:\r\n\t/// hold Down (walk toward the floor block); the corner squeeze flips gravity to Down and parks you\r\n\t/// on the floor block\u0027s trailing edge as it outruns you \u2014 riding along near the trailing edge =\r\n\t/// healthy; rocketing rightward across the block with no input = the stale-position ledge release\r\n\t/// is back. OTHER characters: you spawn riding the floor block; hold Left (against its travel) to\r\n\t/// walk to its trailing edge \u2014 one clean release when you genuinely cross the lip = healthy;\r\n\t/// releasing early with a rightward velocity kick while visibly still on the block = regression.\r\n\t/// Doing nothing rides the block into the right-wall slam (inertia fling right = intended).\u003C/summary\u003E\r\n\t[ConCmd( \u0022edge_hang_repro\u0022 )]\r\n\tpublic static void LoadEdgeHangReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] edge_hang_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( EdgeHangReproId );\r\n\t\tLog.Info( \u0022[BlockParty] Loading edge-hang-repro level with the selected character. SHIFTER: hold Down in the corner and watch the trailing-edge ride. Others: hold Left while riding the fast floor block to walk to its trailing edge. Clean single release at the lip = healthy; a no-input rightward rocket = stale-position ledge release regression.\u0022 );\r\n\t}\r\n\r\n\t// --- shifter repros ----------------------------------------------------------------------\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump into the Shifter underside corner-sliver repro as GYRO (the\r\n\t/// Shifter), regardless of the title-screen pick. Jump to stick to the left slab\u0027s underside,\r\n\t/// hold Right to walk off its lip, and LET GO of Right once you\u0027re off \u2014 you up-fall into the\r\n\t/// wedged block\u0027s bottom-left corner with only a ~1px sliver of overlap, centre past the lean\r\n\t/// margin. Deflecting past the corner and rising to the top wall = healthy; freezing in mid-air\r\n\t/// at the corner (attach/release churn in shifter_trace) = the missing up-gravity catch refusal.\r\n\t/// The verdict is logged either way; overshooting onto the block underside logs a retry hint\r\n\t/// (press Down to jump back to the pedestal).\u003C/summary\u003E\r\n\t[ConCmd( \u0022shifter_underside_repro\u0022 )]\r\n\tpublic static void LoadShifterUndersideReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] shifter_underside_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( ShifterUndersideReproId, Characters.Shifter.Id );\r\n\t\tLog.Info( \u0022[BlockParty] Loading shifter-underside-repro as GYRO. Jump to grab the slab\u0027s underside, hold Right off its lip, then let go. Rising to the top wall = healthy; freezing at the block\u0027s corner = underside catch bug. shifter_trace \u002B crush_trace give the tick detail. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump into the Shifter tight-gap adoption repro as GYRO. The player\r\n\t/// spawns standing in a 9px slot between two obstacle walls \u2014 1px narrower than the sideways\r\n\t/// 10px hitbox. Both walls are inside the 1px contact probe, so the side adoption fires by\r\n\t/// itself on the first tick: don\u0027t touch anything. Surviving (the wall touch simply doesn\u0027t\r\n\t/// take) = healthy; an instant crush death on load = the rotate-then-resolve-or-die adoption.\r\n\t/// The verdict is logged either way.\u003C/summary\u003E\r\n\t[ConCmd( \u0022shifter_gap_repro\u0022 )]\r\n\tpublic static void LoadShifterGapReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] shifter_gap_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( ShifterGapReproId, Characters.Shifter.Id );\r\n\t\tLog.Info( \u0022[BlockParty] Loading shifter-gap-repro as GYRO. Don\u0027t touch anything: the 9px slot adopts a side wall on tick one. Still standing after a few seconds = healthy; instant squish on load = tight-gap adoption death. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump into the Shifter reverse-field repro as GYRO. Same boxed\r\n\t/// Reverse block layout as reverse_detach_repro, but the player spawns AIRBORNE under the box\u0027s\r\n\t/// left bottom-lip obstacle. Don\u0027t touch anything: the field (airborne-only for the Shifter)\r\n\t/// lifts them into the lip\u0027s underside, the touch adopts it as the real floor, and the script\r\n\t/// then kills the Reverse block to cut the field. Still hanging afterwards = healthy; falling on\r\n\t/// the cut = the rest was only field-held; floor\u2194ceiling bouncing before any settle = the old\r\n\t/// in-field adoption ping-pong. The verdict is logged either way.\u003C/summary\u003E\r\n\t[ConCmd( \u0022shifter_reverse_repro\u0022 )]\r\n\tpublic static void LoadShifterReverseReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] shifter_reverse_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( ShifterReverseReproId, Characters.Shifter.Id );\r\n\t\tLog.Info( \u0022[BlockParty] Loading shifter-reverse-repro as GYRO. Don\u0027t move: the field lifts you into the lip\u0027s underside, you adopt it, then the field is cut. Still hanging = healthy; dropping on the cut (or endless bouncing) = regression. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t// --- riding ceiling-glue misfire repro ---------------------------------------------------\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump into the riding ceiling-glue misfire repro as JACK (the Spring),\r\n\t/// regardless of the title-screen pick. HOLD LEFT \u002B UP from the moment the level loads (while\r\n\t/// still falling \u2014 a grounded Up press would jump instead). You land on the block and walk left\r\n\t/// while it accelerates rightward, carrying your head under the static overhang\u0027s left edge at\r\n\t/// ~0.9s. That first brush tick samples CollidingUp BEFORE your leftward walk is applied, so the\r\n\t/// fresh-grab probe misses at the walked position: the ceiling glue then runs against the RIDDEN\r\n\t/// block. One-tick teleport to the block\u0027s underside (a ~50px down \u002B rideDX sideways snap) = BUG\r\n\t/// REPRODUCED; briefly clinging/surfing the overhang\u0027s edge at head height with no snap = healthy.\r\n\t/// The verdict is logged either way.\u003C/summary\u003E\r\n\t[ConCmd( \u0022ride_cling_repro\u0022 )]\r\n\tpublic static void LoadRideClingReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] ride_cling_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( RideClingReproId, Characters.Spring.Id );\r\n\t\tLog.Info( \u0022[BlockParty] Loading ride-cling-repro as JACK. Hold LEFT \u002B UP from load (press while falling!). The block carries you under the overhang\u0027s edge: a one-frame snap to the block\u0027s underside = ceiling glue fired while Riding; hanging/surfing at head height = healthy. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t// --- block-vs-glass-pillar pinch repro ---------------------------------------------------\r\n\r\n\r\n\t/// \u003Csummary\u003EConsole command: jump into the glass-pillar pinch repro (the glass-pillars witch\r\n\t/// report) as the WITCH. Geometry is glass-pillars\u0027 obstacle #3 verbatim (glass, x 158\u2013173,\r\n\t/// y 32\u2013238) plus one Sad block spawned in the bottom-right corner and driven LEFT at a clamped\r\n\t/// crawl. Hold RIGHT from load (one continuous hold \u2014 a double-tap would blink): you wall-hug\r\n\t/// the block\u0027s left face while the script pins the reported pose (bottom edge 2px below the\r\n\t/// block\u0027s top, slight upward velocity), lets the face grind your left edge a sub-residual-EPS\r\n\t/// hair into the glass, then flips the block rightward \u2014 the Hunter-brake/block-bounce reversal\r\n\t/// from the video. On a FIXED build the pinch resolves first: the embed is cleaned to flush\r\n\t/// every tick and the corner deflect pops the player 2px onto the block\u0027s top (NO TELEPORT \u2014\r\n\t/// the flip is never even reached). On an unfixed build the embed survives, the flip fires,\r\n\t/// and the sweep takes the pillar\u0027s along-move push-out as a wall hit, wall-hug-attaches to it\r\n\t/// face-Right, and glue-snaps to its LEFT side = BUG REPRODUCED. The verdict is logged either\r\n\t/// way \u2014 run \u003Cc\u003Ecrush_trace\u003C/c\u003E first for the per-tick resolve detail.\u003C/summary\u003E\r\n\t[ConCmd( \u0022pillar_pinch_repro\u0022 )]\r\n\tpublic static void LoadPillarPinchReproCmd()\r\n\t{\r\n\t\tif ( !Game.IsEditor ) return;\r\n\r\n\t\tvar gm = GameManager.Instance;\r\n\t\tif ( gm is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[BlockParty] pillar_pinch_repro: no GameManager in the scene.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tgm.StartLevel( PillarPinchReproId, Characters.Blinker.Id );\r\n\t\tLog.Info( \u0022[BlockParty] Loading pillar-pinch-repro as WITCH. Press and HOLD RIGHT from load (no tapping \u2014 that blinks). The script grinds you a hair into the glass pillar, then flips the block away. Staying right of the pillar = healthy; snapping through to its left = the reported bug. Watch for the verdict log.\u0022 );\r\n\t}\r\n\r\n\t// Measure the built tile sprites of one short face against its authored segment and log any\r\n\t// overhang past either corner. Returns true if every tile respects the centring contract,\r\n\t// false if one overhangs, or null if the face wasn\u0027t found at all (repro broken, no verdict).\r\n\tprivate static bool? CheckTileOverhang( GameStage stage, Obstacle ob, Direction normal )\r\n\t{\r\n\t\tfloat faceX = normal == Direction.Left ? TileOverhangBar.Left : TileOverhangBar.Right;\r\n\t\tvar face = stage.FindObstacleFace( ob, normal, new Vector2( faceX, (TileOverhangBar.Bottom \u002B TileOverhangBar.Top) / 2f ) );\r\n\t\tif ( face is null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\u0022[BlockParty] tile_overhang_repro: no {normal} face found on the bar \u2014 level geometry changed?\u0022 );\r\n\t\t\treturn null;\r\n\t\t}\r\n\r\n\t\t// Vertical faces tile along Y; each tile quad is centred on its sprite\u0027s world position,\r\n\t\t// with an along-extent of the Size box\u0027s Y (an aspect-fit box the 4x5 side-tile art fills\r\n\t\t// vertically). A face shorter than the tile CANNOT avoid overhanging (the tile stays\r\n\t\t// full-size by design), so the healthy criterion is the fix\u0027s contract: the tile is\r\n\t\t// centred, i.e. neither end overhangs by more than the unavoidable (tile - face) / 2.\r\n\t\t// The bug dumped the whole excess past the START corner.\r\n\t\tbool ok = true;\r\n\t\tfloat faceLen = face.Segment.YMax - face.Segment.YMin;\r\n\t\tforeach ( var s in face.TeethSprites )\r\n\t\t{\r\n\t\t\tfloat c = s.GameObject.WorldPosition.y;\r\n\t\t\tfloat half = s.Size.y / 2f;\r\n\t\t\tfloat allowed = MathF.Max( 0f, (half * 2f - faceLen) / 2f );\r\n\t\t\tfloat startOverhang = face.Segment.YMin - (c - half);\r\n\t\t\tfloat endOverhang = (c \u002B half) - face.Segment.YMax;\r\n\t\t\tif ( startOverhang \u003E allowed \u002B 0.01f || endOverhang \u003E allowed \u002B 0.01f )\r\n\t\t\t{\r\n\t\t\t\tok = false;\r\n\t\t\t\tLog.Info( $\u0022[BlockParty] tile_overhang_repro: {normal} face tile at y={c} overhangs its segment (y {face.Segment.YMin}..{face.Segment.YMax}) by {startOverhang:0.##}px past the start corner, {endOverhang:0.##}px past the end (allowed: {allowed:0.##}px per end).\u0022 );\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn ok;\r\n\t}\r\n\r\n\t// One-shot latches for the wrap repro\u0027s scripted spike graft and console verdict, reset by\r\n\t// OnStageEnter. Static (not per-stage) is fine here: only one GameStage exists at a time.\r\n\tprivate static bool _wrapSpikesAdded;\r\n\tprivate static bool _wrapVerdictLogged;\r\n\r\n\t// Scripted-driver state for the reverse-detach repro, reset by OnStageEnter (static is fine:\r\n\t// one GameStage at a time). The hang counter must SETTLE (consecutive ticks at hanging height)\r\n\t// before the kill, so the one-off bottom-face press bounce on the way up can\u0027t cut the field\r\n\t// while the player is still un-attached mid-air.\r\n\tprivate static int _reverseHangTicks;\r\n\tprivate static bool _reverseFieldCut;\r\n\tprivate static bool _reverseVerdictLogged;\r\n\tprivate static int _reverseTicksSinceCut;\r\n\r\n\t// Hanging under the boxed block (underside y=100) puts the player centre at y\u224895; above this\r\n\t// height they can only be pressed against / attached to the underside.\r\n\tprivate const float REVERSE_HANG_MIN_Y = 92f;\r\n\t// Consecutive ticks at hanging height before the scripted kill (~0.5s: attach is stable by then).\r\n\tprivate const int REVERSE_HANG_SETTLE_TICKS = 30;\r\n\t// Verdict bands after the cut: above the block\u0027s top region = teleported through it (the glue\r\n\t// snap puts the centre at \u2248145); back below the hang = detached and falling, as a fixed build\r\n\t// should. The gap between the bands can only be crossed moving toward one of them.\r\n\tprivate const float REVERSE_BUG_MIN_Y = 120f;\r\n\tprivate const float REVERSE_HEALTHY_MAX_Y = 85f;\r\n\t// Ticks after the cut before giving up on a verdict (~4s; the real outcome lands within ~1 tick\r\n\t// for the bug, ~15 for the healthy fall).\r\n\tprivate const int REVERSE_VERDICT_TIMEOUT_TICKS = 240;\r\n\r\n\t/// \u003Csummary\u003EThe repro\u0027s Reverse block, or null if the level geometry changed out from under it.\u003C/summary\u003E\r\n\tprivate static BlockReverse FindReverseBlock( GameStage stage )\r\n\t{\r\n\t\tforeach ( var b in stage.GetBlocks() )\r\n\t\t\tif ( b is BlockReverse br )\r\n\t\t\t\treturn br;\r\n\t\treturn null;\r\n\t}\r\n\r\n\t// Scripted-verdict state for the three Shifter repros, reset by OnStageEnter (static is fine:\r\n\t// one GameStage at a time).\r\n\t// Underside: armed once the player has walked off the slab\u0027s lip toward the wedged block; then\r\n\t// either they rise free past the block (healthy), park motionless at its corner sliver (bug), or\r\n\t// land solidly on the underside (overshoot \u2014 retry hint).\r\n\tprivate static bool _shifterUndersideArmed;\r\n\tprivate static bool _shifterUndersideVerdictLogged;\r\n\tprivate static int _shifterUndersideStillTicks;\r\n\tprivate static float _shifterUndersideLastY;\r\n\tprivate static int _shifterUndersideTicksSinceArm;\r\n\t// Gap: the slot adopts a side wall on tick one by itself; death = bug, surviving = healthy.\r\n\tprivate static bool _shifterGapVerdictLogged;\r\n\tprivate static int _shifterGapTicks;\r\n\t// Reverse: the airborne spawn must rise (field affects airborne players), ADOPT the box\u0027s lip\r\n\t// underside for real, and then survive the scripted field cut still attached. Oscillation before\r\n\t// the cut = the in-field adoption regressed (old ping-pong); falling after the cut = field exit\r\n\t// detached them (old field-held-only rest).\r\n\tprivate static bool _shifterReverseVerdictLogged;\r\n\tprivate static int _shifterReverseTicks;\r\n\tprivate static int _shifterReverseCrossings;\r\n\tprivate static bool _shifterReverseWasAbove;\r\n\tprivate static int _shifterReverseSettleTicks;\r\n\tprivate static bool _shifterReverseFieldCut;\r\n\tprivate static float _shifterReverseLastY;\r\n\tprivate static int _shifterReverseTicksSinceCut;\r\n\r\n\t// Underside repro geometry (must match shifter-underside-repro.json): the wedged block spans\r\n\t// (106,92)-(146,132), so its exposed bottom-left corner sliver is at (106, 92). The player walks\r\n\t// off the slab (right edge x=88) and up-falls into it.\r\n\tprivate const float UNDERSIDE_BLOCK_LEFT = 106f;\r\n\tprivate const float UNDERSIDE_BLOCK_BOTTOM = 92f;\r\n\tprivate const int UNDERSIDE_STILL_VERDICT_TICKS = 45;\r\n\tprivate const int UNDERSIDE_TIMEOUT_TICKS = 600;\r\n\tprivate const int GAP_VERDICT_TICKS = 300;\r\n\t// Reverse repro bands: the player spawns airborne at y=85 under the box\u0027s left bottom-lip\r\n\t// obstacle (underside y=92); the field lifts them 2px and the adoption hangs them at centre\r\n\t// y\u224887. Crossing y=50 repeatedly (arena floor \u2194 lip) = ping-pong; y\u003C70 after the field cut =\r\n\t// they fell, i.e. the attachment was only field-held.\r\n\tprivate const float REVERSE_SETTLE_MIN_Y = 84f;\r\n\tprivate const int REVERSE_SETTLE_TICKS = 30;\r\n\tprivate const float REVERSE_PINGPONG_MIDLINE_Y = 50f;\r\n\tprivate const int REVERSE_PINGPONG_BUG_CROSSINGS = 4;\r\n\tprivate const float REVERSE_DETACH_BUG_MAX_Y = 70f;\r\n\tprivate const int REVERSE_HOLD_HEALTHY_TICKS = 120;\r\n\tprivate const int REVERSE_PINGPONG_TIMEOUT_TICKS = 600;\r\n\r\n\t// Scripted-verdict state for the ride-cling repro, reset by OnStageEnter (static is fine: one\r\n\t// GameStage at a time). Armed once the player is riding the block at head height near the\r\n\t// overhang while actually walking left (the leftward walk IS the stale-probe offset the bug\r\n\t// needs \u2014 without it the first-contact grab succeeds in every build and there\u0027s no verdict).\r\n\tprivate static bool _rideClingVerdictLogged;\r\n\tprivate static bool _rideClingArmed;\r\n\tprivate static int _rideClingTicks;\r\n\tprivate static int _rideClingTicksSinceArm;\r\n\tprivate static float _rideClingLastX;\r\n\tprivate static float _rideClingLastY;\r\n\r\n\t// Ride-cling repro geometry/tuning (must match ride-cling-repro.json): the block (top y=80)\r\n\t// carries the player (standing centre y=85) under the overhang (bottom y=90, left edge x=80).\r\n\t// The buggy glue snaps the centre to the block\u0027s underside (y=35) in ONE tick \u2014 a 50px drop; no\r\n\t// legitimate motion in this layout moves 30px in a tick (max freefall here is ~7px/tick).\r\n\tprivate const float RIDE_CLING_SNAP_DY = 30f;\r\n\t// Armed: at standing/hanging height (y\u224885), near the overhang\u0027s left edge, walking left.\r\n\tprivate const float RIDE_CLING_ARM_MIN_Y = 83f;\r\n\tprivate const float RIDE_CLING_ARM_MAX_Y = 87f;\r\n\tprivate const float RIDE_CLING_ARM_MIN_X = 72f;\r\n\tprivate const float RIDE_CLING_ARM_MAX_VELX = -15f;\r\n\t// The brush happens ~11 ticks after arming; 2s of no snap after arming = the glue never fired.\r\n\tprivate const int RIDE_CLING_HEALTHY_TICKS = 120;\r\n\t// Ticks before giving up when the arm condition never comes (inputs not held).\r\n\tprivate const int RIDE_CLING_TIMEOUT_TICKS = 600;\r\n\r\n\t// Scripted-driver state for the pillar-pinch repro, reset by OnStageEnter (static is fine: one\r\n\t// GameStage at a time). Pose phase: every tick until the block face reaches the arm line, the\r\n\t// script re-pins the reported pose (bottom edge 2px below the block\u0027s top, slight rise) and\r\n\t// re-clamps the block\u0027s speed (its own acceleration would blow past the half-pixel window the\r\n\t// bug needs) while the human\u0027s held RIGHT does the hugging; X is never touched so the real\r\n\t// attach/press machinery engages. Armed: watch the grind until the player\u0027s left edge is a\r\n\t// SUB-RESIDUAL-EPS hair (0.15..0.5px) inside the pillar, then FLIP the block to the right \u2014\r\n\t// the Hunter brake / block-bounce reversal from the video. The receding face opens a gap, the\r\n\t// held-right sweep gets a Right push-out from the pillar on the LEFT, and an unfixed build\r\n\t// wall-hug-attaches face-Right to the pillar and glue-snaps through it next tick.\r\n\tprivate static bool _pillarPinchArmed;\r\n\tprivate static bool _pillarPinchFlipped;\r\n\tprivate static bool _pillarPinchVerdictLogged;\r\n\tprivate static int _pillarPinchTicksSinceFlip;\r\n\tprivate static float _pillarPinchLastX;\r\n\r\n\t// Pillar-pinch repro geometry (must match pillar-pinch-repro.json): the glass pillar is\r\n\t// glass-pillars\u0027 obstacle #3 verbatim \u2014 x 158..173, y 32..238 \u2014 and the 40x40 Sad block rests on\r\n\t// the floor (top y=42), driven LEFT from the bottom-right corner.\r\n\tprivate const float PILLAR_PINCH_PILLAR_RIGHT = 173f;\r\n\t// Reported pose, held until arm: player bottom = block top - 2 (centre = top \u002B 3), rising gently.\r\n\tprivate const float PILLAR_PINCH_POSE_RISE_VELY = 20f;\r\n\t// Arm when the block\u0027s left face reaches here: the hugged player\u0027s left edge sits at ~173.5,\r\n\t// half a pixel off the pillar \u2014 the exact \u0022left side meets the pillar\u0027s right side\u0022 moment.\r\n\tprivate const float PILLAR_PINCH_ARM_BLOCK_LEFT = 181.5f;\r\n\t// Clamped block speed: 18 px/s = ~0.31px/tick, so the per-tick pillar embedding lands inside\r\n\t// [FLIP_MIN_PEN, RESIDUAL_PEN_EPS) with room to spare (worst first crossing \u2248 0.46px). Deeper\r\n\t// than the EPS at a tick start and the residual net crushes first \u2014 the (already-verified)\r\n\t// designed outcome, not the bug.\r\n\tprivate const float PILLAR_PINCH_BLOCK_SPEED = 18f;\r\n\tprivate const float PILLAR_PINCH_FLIP_MIN_PEN = 0.15f;\r\n\t// A hugged player\u0027s left edge can legally end up at most a whisker inside the pillar; the centre\r\n\t// crossing the pillar\u0027s midline (165.5) is reachable only by going THROUGH it. One-tick sideways\r\n\t// jumps past this also flag immediately (the buggy glue snap is a ~23px single-tick move).\r\n\tprivate const float PILLAR_PINCH_BUG_MAX_X = 165f;\r\n\tprivate const float PILLAR_PINCH_TELEPORT_DX = 8f;\r\n\t// Healthy: two post-flip seconds still on the pillar\u0027s right side. Anything unresolved after 10s = stuck.\r\n\tprivate const int PILLAR_PINCH_HEALTHY_TICKS = 120;\r\n\tprivate const int PILLAR_PINCH_TIMEOUT_TICKS = 600;\r\n\r\n\t// Graft the floor spikes when the falling player is this close above the floor: from here the\r\n\t// fall to the wall (~57px, arriving ~0.18s later at ~300px/s) sits comfortably inside the 0.44s\r\n\t// grow-in, so the face is still \u0022switching\u0022 for the whole crossing.\r\n\tprivate const float WRAP_SPIKE_TRIGGER_Y = 60f;\r\n\tprivate const float WRAP_SPIKE_LIVE_TIME = 2f;\r\n\r\n\t/// \u003Csummary\u003ETest-level scripted setup, called at the end of \u003Cc\u003EGameStage.OnEnter\u003C/c\u003E (after\r\n\t/// blocks and player are spawned). No-op on non-test levels.\u003C/summary\u003E\r\n\tpublic static void OnStageEnter( GameStage stage )\r\n\t{\r\n\t\tswitch ( stage.Level?.Id )\r\n\t\t{\r\n\t\t\tcase CrushReproId:\r\n\t\t\t\t// The block must open by slamming LEFT into the wall lip (see BuildCrushRepro) \u2014\r\n\t\t\t\t// a random opening direction would delay the repro by several bounces.\r\n\t\t\t\tstage.GetBlocks()[0].DebugSetMoveDirection( Direction.Left );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase WrapReproId:\r\n\t\t\t\t_wrapSpikesAdded = false;\r\n\t\t\t\t_wrapVerdictLogged = false;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase ReverseDetachReproId:\r\n\t\t\t{\r\n\t\t\t\t_reverseHangTicks = 0;\r\n\t\t\t\t_reverseFieldCut = false;\r\n\t\t\t\t_reverseVerdictLogged = false;\r\n\t\t\t\t_reverseTicksSinceCut = 0;\r\n\r\n\t\t\t\t// The field is off at phase 0 and there\u0027s no way to press the boxed block\u0027s sides\r\n\t\t\t\t// without input, so spawn it straight at phase 1 (the mimic-swap helper \u2014 no phase-up\r\n\t\t\t\t// transition or scoring). Its 120px field then covers the pedestal \u002B underside.\r\n\t\t\t\tvar reverse = FindReverseBlock( stage );\r\n\t\t\t\tif ( reverse is null )\r\n\t\t\t\t\tLog.Warning( \u0022[BlockParty] reverse_detach_repro: no Reverse block spawned \u2014 level def changed? No repro.\u0022 );\r\n\t\t\t\telse\r\n\t\t\t\t\treverse.SetInitialPhase( 1 );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase EdgeHangReproId:\r\n\t\t\t{\r\n\t\t\t\t// Both blocks head RIGHT with a seeded speed differential (see the constants above) \u2014\r\n\t\t\t\t// random opening directions or a standing start would take several bounces to\r\n\t\t\t\t// reassemble the corner \u002B faster-floor-block situation the repro is about. The wall\r\n\t\t\t\t// block is the HIGH one (y=217 vs the floor block\u0027s 177); picked by height rather than\r\n\t\t\t\t// spawn index so a reordered level def can\u0027t silently swap the roles.\r\n\t\t\t\tforeach ( var block in stage.GetBlocks() )\r\n\t\t\t\t{\r\n\t\t\t\t\tbool isWallBlock = block.Y \u003E 200f;\r\n\t\t\t\t\tblock.DebugSetMoveDirection( Direction.Right );\r\n\t\t\t\t\tblock.DebugSetMoveSpeed( isWallBlock ? EDGE_HANG_WALL_BLOCK_SPEED : EDGE_HANG_FLOOR_BLOCK_SPEED );\r\n\t\t\t\t}\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase ShifterUndersideReproId:\r\n\t\t\t{\r\n\t\t\t\t_shifterUndersideArmed = false;\r\n\t\t\t\t_shifterUndersideVerdictLogged = false;\r\n\t\t\t\t_shifterUndersideStillTicks = 0;\r\n\t\t\t\t_shifterUndersideLastY = 0f;\r\n\t\t\t\t_shifterUndersideTicksSinceArm = 0;\r\n\r\n\t\t\t\t// Wedge the block vertically between the flush top/bottom obstacles (it spawns with\r\n\t\t\t\t// zero clearance): a vertical travel axis means it never moves horizontally either,\r\n\t\t\t\t// so its exposed bottom-left corner stays exactly where the repro needs it.\r\n\t\t\t\tstage.GetBlocks()[0].DebugSetMoveDirection( Direction.Up );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase ShifterGapReproId:\r\n\t\t\t\t_shifterGapVerdictLogged = false;\r\n\t\t\t\t_shifterGapTicks = 0;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase ShifterReverseReproId:\r\n\t\t\t{\r\n\t\t\t\t_shifterReverseVerdictLogged = false;\r\n\t\t\t\t_shifterReverseTicks = 0;\r\n\t\t\t\t_shifterReverseCrossings = 0;\r\n\t\t\t\t_shifterReverseWasAbove = false;\r\n\t\t\t\t_shifterReverseSettleTicks = 0;\r\n\t\t\t\t_shifterReverseFieldCut = false;\r\n\t\t\t\t_shifterReverseLastY = 0f;\r\n\t\t\t\t_shifterReverseTicksSinceCut = 0;\r\n\r\n\t\t\t\t// Same as reverse_detach_repro: the boxed block\u0027s field is off at phase 0 and nothing\r\n\t\t\t\t// can press its sides, so spawn it straight at phase 1.\r\n\t\t\t\tvar reverse = FindReverseBlock( stage );\r\n\t\t\t\tif ( reverse is null )\r\n\t\t\t\t\tLog.Warning( \u0022[BlockParty] shifter_reverse_repro: no Reverse block spawned \u2014 level def changed? No repro.\u0022 );\r\n\t\t\t\telse\r\n\t\t\t\t\treverse.SetInitialPhase( 1 );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase RideClingReproId:\r\n\t\t\t{\r\n\t\t\t\t_rideClingVerdictLogged = false;\r\n\t\t\t\t_rideClingArmed = false;\r\n\t\t\t\t_rideClingTicks = 0;\r\n\t\t\t\t_rideClingTicksSinceArm = 0;\r\n\t\t\t\t_rideClingLastX = 0f;\r\n\t\t\t\t_rideClingLastY = 0f;\r\n\r\n\t\t\t\t// The block must carry the player rightward INTO the overhang\u0027s left edge. No speed\r\n\t\t\t\t// seed: the timing math relies on the natural accelerate-from-rest ramp (50 px/s\u00B2),\r\n\t\t\t\t// which keeps the closing speed on the overhang below one tick of walk speed at the\r\n\t\t\t\t// brush (~0.87s in) \u2014 the guarantee that the first contact tick is a stale one.\r\n\t\t\t\tstage.GetBlocks()[0].DebugSetMoveDirection( Direction.Right );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase PillarPinchReproId:\r\n\t\t\t{\r\n\t\t\t\t_pillarPinchArmed = false;\r\n\t\t\t\t_pillarPinchFlipped = false;\r\n\t\t\t\t_pillarPinchVerdictLogged = false;\r\n\t\t\t\t_pillarPinchTicksSinceFlip = 0;\r\n\t\t\t\t_pillarPinchLastX = 0f;\r\n\r\n\t\t\t\t// Drive the block LEFT; the tick script re-clamps the speed every tick until the\r\n\t\t\t\t// scripted reversal (see PILLAR_PINCH_BLOCK_SPEED for why it must stay this slow).\r\n\t\t\t\tstage.GetBlocks()[0].DebugSetMoveDirection( Direction.Left );\r\n\t\t\t\tstage.GetBlocks()[0].DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase TileOverhangReproId:\r\n\t\t\t{\r\n\t\t\t\t// Faces are all built by now (arena \u002B obstacles precede this hook); measure the\r\n\t\t\t\t// bar\u0027s two sub-tile faces and log the verdict immediately.\r\n\t\t\t\tvar ob = stage.GetObstacles()[0];\r\n\t\t\t\tbool? left = CheckTileOverhang( stage, ob, Direction.Left );\r\n\t\t\t\tbool? right = CheckTileOverhang( stage, ob, Direction.Right );\r\n\t\t\t\tif ( left is null || right is null )\r\n\t\t\t\t\tLog.Warning( \u0022[BlockParty] tile_overhang_repro: INCONCLUSIVE \u2014 a short face is missing (see warning above), so the overhang contract wasn\u0027t measured. Fix the repro before trusting any verdict.\u0022 );\r\n\t\t\t\telse\r\n\t\t\t\t\tLog.Info( left.Value \u0026\u0026 right.Value\r\n\t\t\t\t\t\t? \u0022[BlockParty] tile_overhang_repro: HEALTHY \u2014 the sub-tile faces\u0027 tiles are centred; no corner overhangs more than the unavoidable (tile - face) / 2.\u0022\r\n\t\t\t\t\t\t: \u0022[BlockParty] tile_overhang_repro: BUG REPRODUCED \u2014 a tooth tile dumps its whole excess past one face corner (one-sided flush-clamp in BuildTiledFace); see the bar\u0027s bottom corners.\u0022 );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETest-level scripted driver, called once per fixed step from \u003Cc\u003EGameStage.Tick\u003C/c\u003E\r\n\t/// (after entities have ticked). No-op on non-test levels.\u003C/summary\u003E\r\n\tpublic static void OnStageTick( GameStage stage )\r\n\t{\r\n\t\tswitch ( stage.Level?.Id )\r\n\t\t{\r\n\t\t\tcase WrapReproId: TickWrapRepro( stage ); break;\r\n\t\t\tcase ReverseDetachReproId: TickReverseDetachRepro( stage ); break;\r\n\t\t\tcase ShifterUndersideReproId: TickShifterUndersideRepro( stage ); break;\r\n\t\t\tcase ShifterGapReproId: TickShifterGapRepro( stage ); break;\r\n\t\t\tcase ShifterReverseReproId: TickShifterReverseRepro( stage ); break;\r\n\t\t\tcase RideClingReproId: TickRideClingRepro( stage ); break;\r\n\t\t\tcase PillarPinchReproId: TickPillarPinchRepro( stage ); break;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERide-cling repro\u0027s per-tick verdict: any one-tick drop of 30px\u002B = BUG REPRODUCED\r\n\t/// (the ceiling glue ran on the Riding attachment and snapped the player to the ridden block\u0027s\r\n\t/// underside \u2014 nothing legitimate in this layout teleports). Armed once the player is riding at\r\n\t/// head height near the overhang while walking left; two snap-free seconds after arming =\r\n\t/// HEALTHY. Never arming (inputs not held) or dying logs INCONCLUSIVE.\u003C/summary\u003E\r\n\tprivate static void TickRideClingRepro( GameStage stage )\r\n\t{\r\n\t\tvar player = stage.Player;\r\n\t\tif ( player is null || player.IsDead )\r\n\t\t{\r\n\t\t\tif ( !_rideClingVerdictLogged \u0026\u0026 player is not null )\r\n\t\t\t{\r\n\t\t\t\t_rideClingVerdictLogged = true;\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] ride_cling_repro: INCONCLUSIVE \u2014 the player died, which this layout should never allow. Fix the repro before trusting any verdict.\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( _rideClingVerdictLogged ) return;\r\n\t\t_rideClingTicks\u002B\u002B;\r\n\r\n\t\t// Teleport watch, live from tick 2: the buggy glue moves the centre from y\u224885 to y\u224835 (and\r\n\t\t// yanks X by the block\u0027s displacement since ride-attach) inside a single tick.\r\n\t\tfloat dY = player.Y - _rideClingLastY;\r\n\t\tfloat dX = player.X - _rideClingLastX;\r\n\t\tbool teleported = _rideClingTicks \u003E 1 \u0026\u0026 dY \u003C= -RIDE_CLING_SNAP_DY;\r\n\t\t_rideClingLastX = player.X;\r\n\t\t_rideClingLastY = player.Y;\r\n\t\tif ( teleported )\r\n\t\t{\r\n\t\t\t_rideClingVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] ride_cling_repro: BUG REPRODUCED \u2014 one-tick teleport to the ridden block\u0027s underside (dY={dY:0.0}, dX={dX:0.0}, now y={player.Y:0.0}): the ceiling-cling glue ran while _attach == Riding, treating the platform as the clung ceiling (stale CollidingUp \u002B missed fresh-grab probe).\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !_rideClingArmed )\r\n\t\t{\r\n\t\t\tif ( player.Y \u003E RIDE_CLING_ARM_MIN_Y \u0026\u0026 player.Y \u003C RIDE_CLING_ARM_MAX_Y\r\n\t\t\t\t\u0026\u0026 player.X \u003E RIDE_CLING_ARM_MIN_X \u0026\u0026 player.VelX \u003C RIDE_CLING_ARM_MAX_VELX )\r\n\t\t\t{\r\n\t\t\t\t_rideClingArmed = true;\r\n\t\t\t\tLog.Info( \u0022[BlockParty] ride_cling_repro: riding toward the overhang while walking left \u2014 the edge brush comes within a quarter second. Keep holding LEFT \u002B UP...\u0022 );\r\n\t\t\t}\r\n\t\t\telse if ( _rideClingTicks \u003E RIDE_CLING_TIMEOUT_TICKS )\r\n\t\t\t{\r\n\t\t\t\t_rideClingVerdictLogged = true;\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] ride_cling_repro: INCONCLUSIVE \u2014 never saw the ride-and-walk-left setup (were LEFT \u002B UP held from load, with UP pressed while still falling?). Reload to retry.\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_rideClingTicksSinceArm\u002B\u002B;\r\n\t\tif ( _rideClingTicksSinceArm \u003E= RIDE_CLING_HEALTHY_TICKS )\r\n\t\t{\r\n\t\t\t_rideClingVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] ride_cling_repro: HEALTHY \u2014 the edge brush passed with no teleport (y={player.Y:0.0}); the glue stayed off the Riding attachment. (Valid only if LEFT \u002B UP stayed held through the brush.)\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EShifter underside repro\u0027s per-tick verdict: once the player has walked off the slab\u0027s\r\n\t/// lip (armed), rising well past the wedged block = HEALTHY (the corner-sliver catch was refused\r\n\t/// and deflected); parking motionless at the block\u0027s bottom-left corner with the centre still past\r\n\t/// the lean margin = BUG REPRODUCED; parking on the underside with the centre over the block =\r\n\t/// overshoot, retry. Timeout logs INCONCLUSIVE with the parked position.\u003C/summary\u003E\r\n\tprivate static void TickShifterUndersideRepro( GameStage stage )\r\n\t{\r\n\t\tvar player = stage.Player;\r\n\t\tif ( player is null || player.IsDead )\r\n\t\t{\r\n\t\t\tif ( !_shifterUndersideVerdictLogged \u0026\u0026 player is not null )\r\n\t\t\t{\r\n\t\t\t\t_shifterUndersideVerdictLogged = true;\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] shifter_underside_repro: INCONCLUSIVE \u2014 the player died, which this layout should never allow. Fix the repro before trusting any verdict.\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( _shifterUndersideVerdictLogged ) return;\r\n\r\n\t\t// Armed once clearly past the slab\u0027s right edge (x=88) while at underside height \u2014 i.e. they\r\n\t\t// walked off the lip and are up-falling toward the block\u0027s corner.\r\n\t\tif ( !_shifterUndersideArmed )\r\n\t\t{\r\n\t\t\tif ( player.X \u003E 96f \u0026\u0026 player.Y \u003E 80f ) _shifterUndersideArmed = true;\r\n\t\t\telse return;\r\n\t\t}\r\n\t\t_shifterUndersideTicksSinceArm\u002B\u002B;\r\n\r\n\t\tbool still = Math.Abs( player.Y - _shifterUndersideLastY ) \u003C 0.05f;\r\n\t\t_shifterUndersideLastY = player.Y;\r\n\t\t_shifterUndersideStillTicks = still ? _shifterUndersideStillTicks \u002B 1 : 0;\r\n\r\n\t\tif ( player.Y \u003E 150f )\r\n\t\t{\r\n\t\t\t_shifterUndersideVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] shifter_underside_repro: HEALTHY \u2014 the corner-sliver catch was refused and deflected; the player rose free past the block (y={player.Y:0.0}).\u0022 );\r\n\t\t}\r\n\t\telse if ( _shifterUndersideStillTicks \u003E= UNDERSIDE_STILL_VERDICT_TICKS\r\n\t\t\t\u0026\u0026 player.Y \u003E 82f \u0026\u0026 player.Y \u003C UNDERSIDE_BLOCK_BOTTOM )\r\n\t\t{\r\n\t\t\t_shifterUndersideVerdictLogged = true;\r\n\t\t\t// The lean margin is 2px: parked with the centre still short of Left-2 means the catch\r\n\t\t\t// held a sliver it should have refused; centre at/over the margin is a legitimate rest.\r\n\t\t\tif ( player.X \u003C UNDERSIDE_BLOCK_LEFT - 2f )\r\n\t\t\t\tLog.Info( $\u0022[BlockParty] shifter_underside_repro: BUG REPRODUCED \u2014 pinned at the block\u0027s bottom-left corner sliver (x={player.X:0.0}, centre {UNDERSIDE_BLOCK_LEFT - player.X:0.0}px short of its edge): the up-gravity underside catch refusal is missing. shifter_trace shows the attach/release churn.\u0022 );\r\n\t\t\telse\r\n\t\t\t\tLog.Info( $\u0022[BlockParty] shifter_underside_repro: OVERSHOOT (no verdict) \u2014 you landed with your centre over the block (x={player.X:0.0}), a legitimate corner rest in both builds. Press Down to jump back to the pedestal and retry with an earlier let-go of Right.\u0022 );\r\n\t\t}\r\n\t\telse if ( _shifterUndersideTicksSinceArm \u003E UNDERSIDE_TIMEOUT_TICKS )\r\n\t\t{\r\n\t\t\t_shifterUndersideVerdictLogged = true;\r\n\t\t\tLog.Warning( $\u0022[BlockParty] shifter_underside_repro: INCONCLUSIVE \u2014 no verdict band hit after the walk-off (parked at x={player.X:0.0}, y={player.Y:0.0}). If you rose past the block on the LEFT of x=106 that\u0027s still healthy behaviour; reload to retry, and use shifter_trace/crush_trace for the tick detail.\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EShifter gap repro\u0027s per-tick verdict: the 9px slot triggers the side-wall adoption by\r\n\t/// itself on the first tick (both walls sit inside the 1px contact probe). The rotated 10px hitbox\r\n\t/// doesn\u0027t fit, so an instant squish = BUG REPRODUCED (rotate-then-resolve-or-die), and still\r\n\t/// standing after a few seconds = HEALTHY (the adoption was refused and reverted).\u003C/summary\u003E\r\n\tprivate static void TickShifterGapRepro( GameStage stage )\r\n\t{\r\n\t\tif ( _shifterGapVerdictLogged ) return;\r\n\t\tvar player = stage.Player;\r\n\t\tif ( player is null ) return;\r\n\r\n\t\t_shifterGapTicks\u002B\u002B;\r\n\t\tif ( player.IsDead )\r\n\t\t{\r\n\t\t\t_shifterGapVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] shifter_gap_repro: BUG REPRODUCED \u2014 the side-wall adoption rotated the hitbox into a slot it doesn\u0027t fit and resolve-or-die crushed the player (tick {_shifterGapTicks}).\u0022 );\r\n\t\t}\r\n\t\telse if ( _shifterGapTicks \u003E= GAP_VERDICT_TICKS )\r\n\t\t{\r\n\t\t\t_shifterGapVerdictLogged = true;\r\n\t\t\tLog.Info( \u0022[BlockParty] shifter_gap_repro: HEALTHY \u2014 the tight-gap side adoption was refused and reverted; the player is still standing in the slot.\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EShifter reverse repro\u0027s per-tick verdict, in two phases. PHASE 1: the airborne spawn\r\n\t/// (y=85) must rise \u2014 the field affects airborne surface-gravity players \u2014 and ADOPT the box\u0027s\r\n\t/// lip underside (settle at y\u224887); repeated midline crossings instead = BUG (the in-field\r\n\t/// adoption regressed to the floor\u2194ceiling ping-pong). PHASE 2: once settled the script kills\r\n\t/// the Reverse block, cutting the field; still hanging afterwards = HEALTHY (the touch adopted\r\n\t/// the surface for real, so field exit doesn\u0027t detach), falling = BUG (the rest was only\r\n\t/// field-held).\u003C/summary\u003E\r\n\tprivate static void TickShifterReverseRepro( GameStage stage )\r\n\t{\r\n\t\tvar player = stage.Player;\r\n\t\tif ( player is null || player.IsDead )\r\n\t\t{\r\n\t\t\tif ( !_shifterReverseVerdictLogged \u0026\u0026 player is not null )\r\n\t\t\t{\r\n\t\t\t\t_shifterReverseVerdictLogged = true;\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] shifter_reverse_repro: INCONCLUSIVE \u2014 the player died, which this layout should never allow. Fix the repro before trusting any verdict.\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( _shifterReverseVerdictLogged ) return;\r\n\t\t_shifterReverseTicks\u002B\u002B;\r\n\r\n\t\t// Ping-pong watch: oscillating between the arena floor and the lip crosses y=50 repeatedly.\r\n\t\tbool above = player.Y \u003E REVERSE_PINGPONG_MIDLINE_Y;\r\n\t\tif ( _shifterReverseTicks \u003E 1 \u0026\u0026 above != _shifterReverseWasAbove ) _shifterReverseCrossings\u002B\u002B;\r\n\t\t_shifterReverseWasAbove = above;\r\n\t\tif ( !_shifterReverseFieldCut \u0026\u0026 _shifterReverseCrossings \u003E= REVERSE_PINGPONG_BUG_CROSSINGS )\r\n\t\t{\r\n\t\t\t_shifterReverseVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] shifter_reverse_repro: BUG REPRODUCED \u2014 {_shifterReverseCrossings} midline crossings before any settle: the in-field touch isn\u0027t adopting the surface (floor\u2194ceiling ping-pong).\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// PHASE 1: wait for a settled hang against the lip underside, then cut the field.\r\n\t\tif ( !_shifterReverseFieldCut )\r\n\t\t{\r\n\t\t\tbool still = Math.Abs( player.Y - _shifterReverseLastY ) \u003C 0.05f;\r\n\t\t\t_shifterReverseLastY = player.Y;\r\n\t\t\t_shifterReverseSettleTicks = still \u0026\u0026 player.Y \u003E REVERSE_SETTLE_MIN_Y ? _shifterReverseSettleTicks \u002B 1 : 0;\r\n\t\t\tif ( _shifterReverseSettleTicks \u003E= REVERSE_SETTLE_TICKS )\r\n\t\t\t{\r\n\t\t\t\tvar reverse = FindReverseBlock( stage );\r\n\t\t\t\tif ( reverse is null || reverse.IsDead )\r\n\t\t\t\t{\r\n\t\t\t\t\t_shifterReverseFieldCut = true; // fall through and let the post-cut watch report\r\n\t\t\t\t\tLog.Warning( \u0022[BlockParty] shifter_reverse_repro: Reverse block missing/already dead at the scripted kill \u2014 level def changed?\u0022 );\r\n\t\t\t\t\treturn;\r\n\t\t\t\t}\r\n\r\n\t\t\t\treverse.Die();\r\n\t\t\t\t_shifterReverseFieldCut = true;\r\n\t\t\t\tLog.Info( $\u0022[BlockParty] shifter_reverse_repro: adopted the lip underside (y={player.Y:0.0}); field cut \u2014 watching whether the attachment survives...\u0022 );\r\n\t\t\t}\r\n\t\t\telse if ( _shifterReverseTicks \u003E REVERSE_PINGPONG_TIMEOUT_TICKS )\r\n\t\t\t{\r\n\t\t\t\t_shifterReverseVerdictLogged = true;\r\n\t\t\t\tLog.Warning( $\u0022[BlockParty] shifter_reverse_repro: INCONCLUSIVE \u2014 never settled against the lip underside (y={player.Y:0.0}, {_shifterReverseCrossings} crossings). Did the airborne lift not happen (check the field phase), or did the layout change?\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// PHASE 2: the field is gone; a real adoption keeps hanging, a field-held rest drops.\r\n\t\t_shifterReverseTicksSinceCut\u002B\u002B;\r\n\t\tif ( player.Y \u003C REVERSE_DETACH_BUG_MAX_Y )\r\n\t\t{\r\n\t\t\t_shifterReverseVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] shifter_reverse_repro: BUG REPRODUCED \u2014 the player fell (y={player.Y:0.0}) when the field died: the underside rest was only field-held instead of a real base adoption.\u0022 );\r\n\t\t}\r\n\t\telse if ( _shifterReverseTicksSinceCut \u003E= REVERSE_HOLD_HEALTHY_TICKS )\r\n\t\t{\r\n\t\t\t_shifterReverseVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] shifter_reverse_repro: HEALTHY \u2014 still hanging from the lip underside (y={player.Y:0.0}) two seconds after the field died: the in-field touch adopted the surface for real.\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWrap repro\u0027s per-tick script: grafts spikes onto the bottom wall just before the\r\n\t/// free-falling player crosses it (so the crossing happens during the grow-in transition), then\r\n\t/// logs a verdict \u2014 BUG REPRODUCED if the player left the arena alive, HEALTHY if they wrapped\r\n\t/// to the top mid grow-in.\u003C/summary\u003E\r\n\tprivate static void TickWrapRepro( GameStage stage )\r\n\t{\r\n\t\tvar player = stage.Player;\r\n\t\tif ( player is null || player.IsDead ) return;\r\n\r\n\t\tif ( !_wrapSpikesAdded )\r\n\t\t{\r\n\t\t\tif ( player.Y \u003C WRAP_SPIKE_TRIGGER_Y )\r\n\t\t\t{\r\n\t\t\t\t_wrapSpikesAdded = true;\r\n\t\t\t\tstage.AddSpikesToFace( stage.FindArenaFace( Direction.Down, player.Pos ), WRAP_SPIKE_LIVE_TIME );\r\n\t\t\t\tLog.Info( \u0022[BlockParty] wrap_repro: floor spikes growing in while the player falls through the wall...\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _wrapVerdictLogged ) return;\r\n\r\n\t\t// Fully below the arena floor, still alive: refused the wrap, wasn\u0027t clamped, wasn\u0027t killed.\r\n\t\tif ( player.GetRect().Top \u003C 0f )\r\n\t\t{\r\n\t\t\t_wrapVerdictLogged = true;\r\n\t\t\tLog.Info( \u0022[BlockParty] wrap_repro: BUG REPRODUCED \u2014 player escaped the arena through the switching spikes and is falling in the void (delayed offscreen death when they land).\u0022 );\r\n\t\t}\r\n\t\t// Back in the top half of the arena: the wrap fired during the grow-in, as it should.\r\n\t\telse if ( player.Y \u003E Arena.HEIGHT / 2f )\r\n\t\t{\r\n\t\t\t_wrapVerdictLogged = true;\r\n\t\t\tLog.Info( \u0022[BlockParty] wrap_repro: HEALTHY \u2014 player wrapped to the top wall while the floor spikes were still growing in.\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EReverse-detach repro\u0027s per-tick script: wait for the field-lifted player to settle\r\n\t/// hanging under the boxed Reverse block, kill the block to cut its field mid-hang, then log the\r\n\t/// verdict \u2014 BUG REPRODUCED if the player snapped up THROUGH the block onto its top (the top-ride\r\n\t/// glue ran on a face-Up attachment), HEALTHY if they detached and fell.\u003C/summary\u003E\r\n\tprivate static void TickReverseDetachRepro( GameStage stage )\r\n\t{\r\n\t\tvar player = stage.Player;\r\n\t\tif ( player is null || player.IsDead )\r\n\t\t{\r\n\t\t\t// Nothing in the level can kill the player, so a death means the repro geometry broke.\r\n\t\t\tif ( !_reverseVerdictLogged \u0026\u0026 player is not null )\r\n\t\t\t{\r\n\t\t\t\t_reverseVerdictLogged = true;\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] reverse_detach_repro: INCONCLUSIVE \u2014 the player died, which this layout should never allow. Fix the repro before trusting any verdict.\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// PHASE 1: wait for a SETTLED hang under the block (the one-off bottom-face press on the way\r\n\t\t// up can bounce them briefly, so require consecutive ticks at hanging height), then cut the\r\n\t\t// field by killing its block \u2014 the same \u0022flag drops while attached face-Up\u0022 edge as the field\r\n\t\t// drifting off a hanging player mid-game.\r\n\t\tif ( !_reverseFieldCut )\r\n\t\t{\r\n\t\t\t_reverseHangTicks = player.Y \u003E REVERSE_HANG_MIN_Y ? _reverseHangTicks \u002B 1 : 0;\r\n\t\t\tif ( _reverseHangTicks \u003C REVERSE_HANG_SETTLE_TICKS ) return;\r\n\r\n\t\t\tvar reverse = FindReverseBlock( stage );\r\n\t\t\tif ( reverse is null || reverse.IsDead )\r\n\t\t\t{\r\n\t\t\t\t_reverseFieldCut = true; // no block to kill \u2014 fall through and let the timeout report\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] reverse_detach_repro: Reverse block missing/already dead at the scripted kill \u2014 level def changed?\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\treverse.Die();\r\n\t\t\t_reverseFieldCut = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] reverse_detach_repro: field cut while the player hangs under the block (y={player.Y:0.0}). Watching where they end up...\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _reverseVerdictLogged ) return;\r\n\t\t_reverseTicksSinceCut\u002B\u002B;\r\n\r\n\t\t// Above the block\u0027s top region: only reachable by the glue snapping the player through the\r\n\t\t// solid block (the bug puts their centre at \u2248145 in a single tick).\r\n\t\tif ( player.Y \u003E REVERSE_BUG_MIN_Y )\r\n\t\t{\r\n\t\t\t_reverseVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] reverse_detach_repro: BUG REPRODUCED \u2014 player teleported through the block onto its top (y={player.Y:0.0}) the tick the field dropped: HandleMovingPlatforms ran the top-ride glue (Bottom = parent.Top) on a face-Up attachment instead of detaching.\u0022 );\r\n\t\t}\r\n\t\t// Back below the hang: they detached and fell, as a fixed build should.\r\n\t\telse if ( player.Y \u003C REVERSE_HEALTHY_MAX_Y )\r\n\t\t{\r\n\t\t\t_reverseVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] reverse_detach_repro: HEALTHY \u2014 player detached and fell (y={player.Y:0.0}) when the field dropped mid-hang.\u0022 );\r\n\t\t}\r\n\t\telse if ( _reverseTicksSinceCut \u003E REVERSE_VERDICT_TIMEOUT_TICKS )\r\n\t\t{\r\n\t\t\t_reverseVerdictLogged = true;\r\n\t\t\tLog.Warning( $\u0022[BlockParty] reverse_detach_repro: INCONCLUSIVE \u2014 player parked at y={player.Y:0.0} after the cut, in neither verdict band. Did the attach never happen (check the hang height), or did the layout change?\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EPillar-pinch repro\u0027s per-tick driver \u002B verdict. POSE PHASE (block face right of the\r\n\t/// arm line): re-pin the reported pose every tick \u2014 centre 3px above the block top (bottom edge\r\n\t/// 2px below it) with a gentle rise \u2014 and re-clamp the block\u0027s speed, while the held RIGHT does\r\n\t/// the actual hugging; X is left to the real press/attach machinery. ARMED: let the face grind\r\n\t/// the player\u0027s left edge a sub-residual-EPS hair into the pillar, then FLIP the block rightward\r\n\t/// (the video\u0027s Hunter-brake / block-bounce reversal). An unfixed build\u0027s rightward sweep now\r\n\t/// takes a Right push-out FROM THE PILLAR ON THE LEFT, wall-hug-attaches face-Right to it, and\r\n\t/// the glue snaps Right = pillar.Left next tick \u2014 the one-tick through-the-glass teleport.\r\n\t/// Fixed builds treat the along-sweep push-out as progress and stay on the right side.\u003C/summary\u003E\r\n\tprivate static void TickPillarPinchRepro( GameStage stage )\r\n\t{\r\n\t\tif ( _pillarPinchVerdictLogged ) return;\r\n\t\tvar player = stage.Player;\r\n\t\tvar blocks = stage.GetBlocks();\r\n\t\tif ( player is null || blocks.Count == 0 )\r\n\t\t\treturn;\r\n\t\tBlock block = blocks[0];\r\n\r\n\t\tif ( !_pillarPinchArmed )\r\n\t\t{\r\n\t\t\tif ( player.IsDead )\r\n\t\t\t{\r\n\t\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\t\tLog.Warning( \u0022[BlockParty] pillar_pinch_repro: INCONCLUSIVE \u2014 the player died before the pinch was even set up, which this layout should never allow. Fix the repro before trusting any verdict.\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\t// Pin the FULL flush hug pose, X included: centre exactly Width/2 left of the face, so\r\n\t\t\t// there is zero horizontal overlap with the block. Pinning Y alone let X drift a fraction\r\n\t\t\t// INTO the block, which read as floor (OnFloor from the down-probe overlap) \u2014 the hug\u0027s\r\n\t\t\t// grounded release kept detaching, the attach gate refused, and the first pillar contact\r\n\t\t\t// hit the corner deflect UN-exempted, popping the player harmlessly on top instead of\r\n\t\t\t// grinding (the first repro run\u0027s failure). Flush \u002B airborne, the held-RIGHT sweep\r\n\t\t\t// wall-stops on the face each tick and the real attach machinery keeps the hug live.\r\n\t\t\tblock.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );\r\n\t\t\tplayer.X = block.Left - player.Width / 2f;\r\n\t\t\tplayer.Y = block.Top \u002B 3f;\r\n\t\t\tplayer.VelY = PILLAR_PINCH_POSE_RISE_VELY;\r\n\t\t\tif ( block.Left \u003E PILLAR_PINCH_ARM_BLOCK_LEFT )\r\n\t\t\t\treturn;\r\n\r\n\t\t\t_pillarPinchArmed = true;\r\n\t\t\t_pillarPinchLastX = player.X;\r\n\t\t\tLog.Info( $\u0022[BlockParty] pillar_pinch_repro: armed \u2014 hugging the face at (x={player.X:0.0}, y={player.Y:0.0}), left edge {player.X - 4f - PILLAR_PINCH_PILLAR_RIGHT:\u002B0.0;-0.0}px off the pillar. Grinding until a sub-EPS embed, then flipping the block...\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat dX = player.X - _pillarPinchLastX;\r\n\t\t_pillarPinchLastX = player.X;\r\n\r\n\t\t// Grind phase: keep the approach speed clamped; flip the moment the player\u0027s left edge is\r\n\t\t// meaningfully but sub-residual-EPS inside the pillar (pen = 177 - centre X).\r\n\t\tif ( !_pillarPinchFlipped )\r\n\t\t{\r\n\t\t\tif ( player.IsDead )\r\n\t\t\t{\r\n\t\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\t\tLog.Warning( $\u0022[BlockParty] pillar_pinch_repro: INCONCLUSIVE \u2014 crushed before the scripted reversal (the embed overshot the residual EPS window; the approach speed drifted?). Reload to retry.\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\t// Popped onto the block top mid-grind: the corner deflect resolved the pinch UPWARD \u2014\r\n\t\t\t// the reported \u0022depenetrated upwards\u0022 outcome, and the FIXED build\u0027s expected ending\r\n\t\t\t// here (the along-sweep push-out rule cleans the embed to flush each tick, so the\r\n\t\t\t// pinch never survives to the scripted reversal; the next blind pass\u0027s deflect target\r\n\t\t\t// is then clear and fires). On an unfixed build the embed survives the tick instead,\r\n\t\t\t// keeps the deflect target blocked, and the grind reaches the flip below.\r\n\t\t\tif ( player.Y \u003E block.Top \u002B 4.5f )\r\n\t\t\t{\r\n\t\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\t\tLog.Info( $\u0022[BlockParty] pillar_pinch_repro: NO TELEPORT \u2014 the pinch resolved with the designed upward deflect onto the block\u0027s top (x={player.X:0.0}, y={player.Y:0.0}) before any embed could survive a tick. This is the fixed build\u0027s expected outcome. (Valid only if RIGHT was held from load \u2014 with no input the pinch never forms and this pop is trivial.)\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tfloat pen = 4f \u002B PILLAR_PINCH_PILLAR_RIGHT - player.X;\r\n\t\t\tif ( pen \u003C PILLAR_PINCH_FLIP_MIN_PEN )\r\n\t\t\t{\r\n\t\t\t\tblock.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tblock.DebugSetMoveDirection( Direction.Right );\r\n\t\t\tblock.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );\r\n\t\t\t_pillarPinchFlipped = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] pillar_pinch_repro: flipped the block rightward with the player {pen:0.00}px inside the pillar (x={player.X:0.00}). Watching the sweep...\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_pillarPinchTicksSinceFlip\u002B\u002B;\r\n\r\n\t\tif ( player.IsDead )\r\n\t\t{\r\n\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\tLog.Warning( $\u0022[BlockParty] pillar_pinch_repro: INCONCLUSIVE \u2014 died after the reversal (tick {_pillarPinchTicksSinceFlip}), which the sub-EPS embed should never allow. Re-run with crush_trace.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Through the glass: nothing legal moves the centre past the pillar\u0027s midline (legal motion\r\n\t\t// parks flush at its right face), and nothing legal jumps 8px\u002B sideways in one tick.\r\n\t\tif ( player.X \u003C= PILLAR_PINCH_BUG_MAX_X || dX \u003C= -PILLAR_PINCH_TELEPORT_DX )\r\n\t\t{\r\n\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] pillar_pinch_repro: BUG REPRODUCED \u2014 one-tick pass THROUGH the glass pillar (x={player.X:0.0}, dX={dX:0.0}, tick {_pillarPinchTicksSinceFlip} after the flip): the rightward sweep took the pillar\u0027s Right push-out as a wall hit, attached a face-Right WallHug to the pillar on the LEFT, and the glue snapped Right = pillar.Left.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _pillarPinchTicksSinceFlip \u003E= PILLAR_PINCH_HEALTHY_TICKS \u0026\u0026 player.X \u003E PILLAR_PINCH_PILLAR_RIGHT - 3f )\r\n\t\t{\r\n\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\tLog.Info( $\u0022[BlockParty] pillar_pinch_repro: HEALTHY \u2014 {PILLAR_PINCH_HEALTHY_TICKS} ticks after the reversal the player is still on the pillar\u0027s right side (x={player.X:0.0}, y={player.Y:0.0}); the along-sweep push-out was treated as progress, not a wall grab.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _pillarPinchTicksSinceFlip \u003E PILLAR_PINCH_TIMEOUT_TICKS )\r\n\t\t{\r\n\t\t\t_pillarPinchVerdictLogged = true;\r\n\t\t\tLog.Warning( $\u0022[BlockParty] pillar_pinch_repro: INCONCLUSIVE \u2014 no verdict band hit {PILLAR_PINCH_TIMEOUT_TICKS} ticks after the flip (parked at x={player.X:0.0}, y={player.Y:0.0}, block face at {block.Left:0.0}). crush_trace will show what\u0027s holding the state.\u0022 );\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"facepunch.blockparty","Path":"Stages/ScoreStage.cs","FileName":"ScoreStage.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":439976,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\n\r\nnamespace BlockParty;\r\n\r\n/// \u003Csummary\u003E\r\n/// Score-tally screen shown after a run (CONTINUE leads back to the map, or to the daily hub\r\n/// for a daily run). Port of the original\r\n/// \u003Cc\u003EScoreStage\u003C/c\u003E: the five blocks are laid out and their points are counted out one\r\n/// side at a time (with floating \u0022\u002BN\u0022 popups), then a time bonus if every block was maxed.\r\n///\r\n/// The original\u0027s grey\u2192colour reveal used runtime palette swaps; our block art is baked into\r\n/// PNGs, so we bake the four \u0022tally\u0022 tint levels (grey \u2192 faint teal \u2192 stronger teal \u2192 the\r\n/// block\u0027s own phase-2 colours, eyes shut throughout) and step a block up a level as its turn\r\n/// begins and as each phase of side buttons is completed. Coroutines are replaced by a\r\n/// precomputed list of timed steps driven from \u003Csee cref=\u0022Tick\u0022/\u003E (same pattern as the block\r\n/// eye-cycle), and Confirm fast-forwards.\r\n///\r\n/// World-space block sprites are drawn under the stage Root; the running score, floating\r\n/// popups, and the time-bonus banner are Razor (\u003Csee cref=\u0022ScoreScreen\u0022/\u003E), positioned by\r\n/// projecting world coords into the 1080-reference UI space.\r\n///\r\n/// A run that unlocked a character (a first win with them) also owes the player a reveal: the\r\n/// first RESTART / CONTINUE is queued behind the shared \u0022YOU UNLOCKED\u0022 overlay instead of leaving\r\n/// immediately \u2014 see \u003Csee cref=\u0022DeferForUnlock\u0022/\u003E and \u003Csee cref=\u0022UnlockReveal\u0022/\u003E.\r\n/// \u003C/summary\u003E\r\npublic sealed class ScoreStage : MenuStageBase\r\n{\r\n\t// Original ScoreStage timing.\r\n\tprivate const float DELAY_BEFORE_BLOCKS = 0.10f;\r\n\tprivate const float DELAY_BETWEEN_SIDES = 0.10f;\r\n\tprivate const float DELAY_BEFORE_PHASES = 0.18f;\r\n\tprivate const float DELAY_AFTER_BLOCKS = 0.05f;\r\n\tprivate const float TIME_BONUS_TICK = 0.05f;\r\n\t// On a win, hold on the VICTORY! banner \u002B block bounce before the TIME BONUS line drops in.\r\n\tprivate const float DELAY_VICTORY_HOLD = 0.45f;\r\n\t// One coin counted out per tick \u2014 slower than the time bonus so short coin runs still read.\r\n\tprivate const float COIN_TICK = 0.08f;\r\n\r\n\tprivate const float FLOATER_LIFE = 0.9f;\r\n\tprivate const float FLOATER_RISE = 22f;       // logical px / sec\r\n\tprivate const float FLOATER_DISTANCE = 30f;   // offset from block centre toward the side\r\n\r\n\tprivate static readonly Vector2 SCORE_WORLD_POS = new Vector2( Arena.WIDTH * 0.5f, Arena.HEIGHT * 0.88f );\r\n\r\n\tpublic sealed class Floater\r\n\t{\r\n\t\tpublic Vector2 World;\r\n\t\tpublic string Text;\r\n\t\tpublic Color Color;\r\n\t\tpublic float Age;\r\n\t}\r\n\r\n\tprivate readonly List\u003CBlockResult\u003E _blocks;\r\n\tprivate readonly List\u003CBlockResult\u003E _tallyBlocks;\r\n\t// Precise (un-rounded) elapsed game time in seconds; floored for the visible bonus, its\r\n\t// sub-second remainder feeds the leaderboard tiebreaker (see OnTallyComplete).\r\n\tprivate readonly float _gameTime;\r\n\tprivate readonly RunContext _runContext;\r\n\tprivate readonly bool _firstTutorialVictory;\r\n\tprivate readonly bool _isDebugRun;\r\n\tprivate readonly int _coins;\r\n\tprivate readonly bool _levelWasBeatenOnEntry;\r\n\t// This run\u0027s recorded payload (the local replay banked at submit time), so the footer\u0027s replay\r\n\t// button can watch the run that was just played. Null when the run wasn\u0027t recorded.\r\n\tprivate readonly RunData _runData;\r\n\t// Set when this tally is being REBUILT on the way back from the leaderboard / this run\u0027s replay:\r\n\t// the whole count-out is flushed silently in OnEnter so the player lands on the finished screen.\r\n\tprivate readonly bool _skipAnimation;\r\n\r\n\t// --- end-of-tally buttons (RESTART / CONTINUE) --------------------------------------\r\n\t// Once the tally finishes we show a row of text buttons, driven by the same \u0022smiling cube\u0022\r\n\t// selector the menus use. Most normal runs offer RESTART \u002B CONTINUE; a first-time victory starts\r\n\t// on CONTINUE, while losses and victories on previously beaten levels start on RESTART. Daily runs\r\n\t// and the first Tutorial victory show CONTINUE only.\r\n\tpublic enum ScoreButton { Restart, Continue }\r\n\tprivate readonly List\u003CScoreButton\u003E _buttons = new();\r\n\tprivate readonly MenuSelectorAnim _buttonSelector = new();\r\n\tprivate int _selectedButton;\r\n\r\n\t/// \u003Csummary\u003EButtons shown at the bottom once the tally finishes (read by the Razor screen).\u003C/summary\u003E\r\n\tpublic IReadOnlyList\u003CScoreButton\u003E Buttons =\u003E _buttons;\r\n\t/// \u003Csummary\u003EIndex of the currently selected button.\u003C/summary\u003E\r\n\tpublic int SelectedButton =\u003E _selectedButton;\r\n\t/// \u003Csummary\u003ESelector slide position in button-index units (read by the Razor screen).\u003C/summary\u003E\r\n\tpublic float ButtonSelectorPos =\u003E _buttonSelector.Pos;\r\n\t/// \u003Csummary\u003ESelector sprite-sheet frame (0..6), read by the Razor screen.\u003C/summary\u003E\r\n\tpublic int ButtonSelectorFrame =\u003E _buttonSelector.Frame;\r\n\r\n\tprivate readonly List\u003CTallyDisplay\u003E _displays = new();\r\n\tprivate readonly List\u003CVector2\u003E _positions = new();\r\n\r\n\tprivate struct Step { public float Wait; public Action Act; }\r\n\tprivate readonly List\u003CStep\u003E _steps = new();\r\n\tprivate int _cursor;\r\n\tprivate float _stepTimer;\r\n\tprivate bool _fast;\r\n\r\n\t// Fast-forward (pressing Confirm) flushes every remaining step in a single frame, which\r\n\t// would otherwise fire all the queued tally sounds at once into a deafening pile. Cap how\r\n\t// many tally sfx may play once fast-forwarding so the skip gives a single blip of feedback.\r\n\tprivate const int FAST_SFX_BUDGET = 1;\r\n\tprivate int _fastSfxBudget = FAST_SFX_BUDGET;\r\n\r\n\t// State the Razor screen reads.\r\n\tpublic int Score { get; private set; }\r\n\tpublic bool Finished { get; private set; }\r\n\tpublic string Banner { get; private set; }\r\n\t/// \u003Csummary\u003EThe coin count-out line (\u0022COINS: \u002Bn\u0022), shown under \u003Csee cref=\u0022Banner\u0022/\u003E once the\r\n\t/// coin group starts; empty for coin-less runs.\u003C/summary\u003E\r\n\tpublic string CoinBanner { get; private set; }\r\n\t/// \u003Csummary\u003EElapsed scored run time in seconds. Stops when the run entered its game-over beat.\u003C/summary\u003E\r\n\tpublic float GameTime =\u003E _gameTime;\r\n\t/// \u003Csummary\u003EWhether this run completed every block.\u003C/summary\u003E\r\n\tpublic bool Victory { get; }\r\n\t// True once a winning tally reveals the VICTORY! banner; the screen draws it one line above\r\n\t// where the TIME BONUS banner appears.\r\n\tpublic bool ShowVictory { get; private set; }\r\n\t// Loss counterpart: true once a failed tally reveals the YOU DIED banner in the same slot.\r\n\tpublic bool ShowDeath { get; private set; }\r\n\tpublic IReadOnlyList\u003CFloater\u003E Floaters =\u003E _floaters;\r\n\tpublic Vector2 ScoreWorldPos =\u003E SCORE_WORLD_POS;\r\n\t/// \u003Csummary\u003EThe character this run was played as (null/unknown \u2192 Original), for the icon the\r\n\t/// screen shows above the score.\u003C/summary\u003E\r\n\tpublic CharacterDef RunCharacter =\u003E Characters.Get( _runContext.CharacterId );\r\n\r\n\tprivate readonly List\u003CFloater\u003E _floaters = new();\r\n\tprivate ScoreScreen _screen;\r\n\r\n\tpublic ScoreStage( GameManager manager, List\u003CBlockResult\u003E blocks, float gameTime, RunContext runContext = default,\r\n\t\tbool firstTutorialVictory = false,\r\n\t\tbool levelWasBeatenOnEntry = false, bool isDebugRun = false, int coinsCollected = 0,\r\n\t\tRunData runData = null, bool skipAnimation = false ) : base( manager )\r\n\t{\r\n\t\t_blocks = blocks;\r\n\t\t_tallyBlocks = blocks.FindAll( block =\u003E block.ScoreStartPhase \u003C Block.NUM_PHASES - 1 );\r\n\t\t_gameTime = gameTime;\r\n\t\t_runContext = runContext == default ? RunContext.Normal : runContext;\r\n\t\t_firstTutorialVictory = firstTutorialVictory;\r\n\t\t_isDebugRun = isDebugRun;\r\n\t\t_coins = coinsCollected;\r\n\t\t_levelWasBeatenOnEntry = levelWasBeatenOnEntry;\r\n\t\t_runData = runData;\r\n\t\t_skipAnimation = skipAnimation;\r\n\r\n\t\t// The first Tutorial victory omits restart. A daily run offers it only while the day is still\r\n\t\t// today AND has attempts remaining (the restart\r\n\t\t// consumes one; a DEBUG daily restarts freely) \u2014 DoRestart re-checks both live, so a score\r\n\t\t// screen held across UTC midnight can\u0027t relaunch onto the closed day\u0027s board.\r\n\t\tbool dailyCanRestart = _runContext.IsDaily \u0026\u0026 (_isDebugRun || DailyRestartAllowed( _runContext ));\r\n\t\tif ( (!_runContext.IsDaily || dailyCanRestart) \u0026\u0026 !_firstTutorialVictory )\r\n\t\t\t_buttons.Add( ScoreButton.Restart );\r\n\t\t_buttons.Add( ScoreButton.Continue );\r\n\r\n\t\tVictory = ScoreCalc.AllMaxPhase( _blocks );\r\n\t\tif ( Victory \u0026\u0026 !levelWasBeatenOnEntry )\r\n\t\t\t_selectedButton = _buttons.IndexOf( ScoreButton.Continue );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe tally sits on the dark transition fill grown over the game on game-over, so the\r\n\t/// wipe square appears to persist as the background rather than revealing the playfield.\u003C/summary\u003E\r\n\tpublic override bool CoveredBackground =\u003E true;\r\n\r\n\tprotected override void OnEnter()\r\n\t{\r\n\t\t// The run\u0027s score \u002B replay were already submitted by GameStage the moment the run was decided\r\n\t\t// (the game-over beat ending, or an earlier restart/home bail after a death) \u2014 see\r\n\t\t// GameStage.SubmitRunOnce. This stage is now purely the visual tally; it never submits.\r\n\r\n\t\tvar go = CreateUiRoot();\r\n\t\t_screen = go.Components.Create\u003CScoreScreen\u003E();\r\n\t\t_screen.Stage = this;\r\n\r\n\t\tBuildLayout();\r\n\t\tBuildSteps();\r\n\r\n\t\t_stepTimer = _steps.Count \u003E 0 ? _steps[0].Wait : 0f;\r\n\t\tif ( _steps.Count == 0 )\r\n\t\t\tOnTallyComplete();\r\n\r\n\t\t// Coming back from the leaderboard / this run\u0027s replay: play the whole tally out instantly and\r\n\t\t// silently (no sfx budget, no leftover floaters) so we land exactly where we left \u2014 finished,\r\n\t\t// with RESTART / CONTINUE waiting.\r\n\t\tif ( _skipAnimation )\r\n\t\t{\r\n\t\t\t_fast = true;\r\n\t\t\t_fastSfxBudget = 0;\r\n\t\t\tAdvanceTally( 0f );\r\n\t\t\t_floaters.Clear();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tAudio.PlaySfx( SfxType.EnterGame, Arena.Center );\r\n\t}\r\n\r\n\t// --- layout -------------------------------------------------------------------------\r\n\t// The row shapes are driven by the TALLIED count (_tallyBlocks, not the level\u0027s block count \u2014 a\r\n\t// block a level authored as already complete scores nothing and gets no row), so e.g. a six-block\r\n\t// level whose four pins start maxed lays out as two. One or two blocks form a compact upper row,\r\n\t// three blocks arc around the banner, and 4\u002B split over two rows. Five-block rows use the full\r\n\t// arena width so their 40-unit sprites cannot overlap. The score always occupies the top lane.\r\n\tprivate void BuildLayout()\r\n\t{\r\n\t\tfloat w = Arena.WIDTH, h = Arena.HEIGHT;\r\n\t\tint count = _tallyBlocks.Count;\r\n\r\n\t\tvoid AddRow( int rowCount, float y )\r\n\t\t{\r\n\t\t\tfloat edge = rowCount \u003E= 5 ? 0.10f : 0.20f;\r\n\t\t\tfor ( int i = 0; i \u003C rowCount; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat t = rowCount == 1 ? 0.5f : MathX.Lerp( edge, 1f - edge, i / (float)(rowCount - 1) );\r\n\t\t\t\t_positions.Add( new Vector2( w * t, y ) );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( count == 1 )\r\n\t\t{\r\n\t\t\t_positions.Add( new Vector2( w * 0.50f, h * 0.68f ) );\r\n\t\t}\r\n\t\telse if ( count == 3 )\r\n\t\t{\r\n\t\t\t_positions.Add( new Vector2( w * 0.20f, h * 0.50f ) );\r\n\t\t\t_positions.Add( new Vector2( w * 0.50f, h * 0.70f ) );\r\n\t\t\t_positions.Add( new Vector2( w * 0.80f, h * 0.50f ) );\r\n\t\t}\r\n\t\telse if ( count == 2 )\r\n\t\t{\r\n\t\t\t_positions.Add( new Vector2( w * 0.38f, h * 0.68f ) );\r\n\t\t\t_positions.Add( new Vector2( w * 0.62f, h * 0.68f ) );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tAddRow( count / 2, h * 0.30f );\r\n\t\t\tAddRow( count - count / 2, h * 0.70f );\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i \u003C _tallyBlocks.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\t// Blocks stay in their faded grey, eyes-closed look for the whole tally; only the\r\n\t\t\t// side buttons being counted light up (handled by PressSide / SetSidesOut).\r\n\t\t\t_displays.Add( new TallyDisplay( this, _tallyBlocks[i], _positions[i] ) );\r\n\t\t}\r\n\t}\r\n\r\n\t// --- tally sequence -----------------------------------------------------------------\r\n\tprivate void AddStep( float wait, Action act ) =\u003E _steps.Add( new Step { Wait = wait, Act = act } );\r\n\r\n\tprivate void BuildSteps()\r\n\t{\r\n\t\tfor ( int i = 0; i \u003C _displays.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tint bi = i;\r\n\t\t\tvar br = _tallyBlocks[i];\r\n\r\n\t\t\t// Begin this block\u0027s tally with a blip and a quick scale bounce so it\u0027s clear which block\r\n\t\t\t// the sound refers to \u2014 otherwise a block with no sides pressed gives no visual cue at all.\r\n\t\t\t// The body stays in its dim grey \u0022asleep\u0022 look the whole time; progression is shown by the\r\n\t\t\t// lit side buttons and, once a phase is completed, the block\u0027s real face (SetPhaseFace) \u2014\r\n\t\t\t// not by recolouring the body.\r\n\t\t\tAddStep( DELAY_BEFORE_BLOCKS, () =\u003E\r\n\t\t\t{\r\n\t\t\t\tTallySfx( SfxType.MenuBlip, _positions[bi] );\r\n\t\t\t\t_displays[bi].StartBounce();\r\n\t\t\t} );\r\n\r\n\t\t\tfor ( int phase = 0; phase \u003C= br.Phase \u0026\u0026 phase \u003C Block.NUM_PHASES; phase\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tint ph = phase;\r\n\r\n\t\t\t\t// Sides scored this phase: all four if the phase was fully completed, else the\r\n\t\t\t\t// sides actually pressed when the run ended (shared with the submitted score via\r\n\t\t\t\t// ScoreCalc.ScoredSides so the tally and the leaderboard number can\u0027t diverge).\r\n\t\t\t\tvar sides = ScoreCalc.ScoredSides( br, phase );\r\n\r\n\t\t\t\tAddStep( DELAY_BEFORE_PHASES, () =\u003E\r\n\t\t\t\t{\r\n\t\t\t\t\t// Pop the buttons back out for the new phase only if something was pressed in it.\r\n\t\t\t\t\t// If the block reached phase 1 but pressed nothing, the four buttons that took it\r\n\t\t\t\t\t// up stay depressed (so the progress reads instead of the buttons looking reset).\r\n\t\t\t\t\tif ( ph == 1 \u0026\u0026 br.ScoreStartPhase \u003C 1 \u0026\u0026 sides.Count \u003E 0 ) _displays[bi].SetSidesOut( 1 );\r\n\t\t\t\t} );\r\n\r\n\t\t\t\tif ( phase == 1 )\r\n\t\t\t\t\tAddStep( DELAY_BETWEEN_SIDES, null );\r\n\r\n\t\t\t\tfor ( int j = 0; j \u003C sides.Count; j\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tint jj = j;\r\n\t\t\t\t\tvar dir = sides[j];\r\n\t\t\t\t\tbool completes = ScoreCalc.CompletesPhase( br, ph, jj, sides.Count );\r\n\r\n\t\t\t\t\tAddStep( DELAY_BETWEEN_SIDES, () =\u003E\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tif ( ph \u003C Block.NUM_PHASES - 1 )\r\n\t\t\t\t\t\t\t_displays[bi].PressSide( dir, ph );\r\n\r\n\t\t\t\t\t\tint amount = ScoreCalc.SideAmount( ph, completes );\r\n\t\t\t\t\t\tScore \u002B= amount;\r\n\t\t\t\t\t\tAddFloater( _positions[bi], dir, amount, ph, completes );\r\n\t\t\t\t\t\tTallySfx( CompleteSfx( ph, completes ), _positions[bi] );\r\n\r\n\t\t\t\t\t\t// Completing a phase reveals the block\u0027s real face for the phase it just\r\n\t\t\t\t\t\t// reached: phase-0 done -\u003E phase-1 face; phase-1 done -\u003E phase-2 face \u002B max sides.\r\n\t\t\t\t\t\tif ( completes )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tif ( ph == 0 )\r\n\t\t\t\t\t\t\t\t_displays[bi].SetPhaseFace( 1 );\r\n\t\t\t\t\t\t\telse if ( ph == 1 )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\t_displays[bi].SetPhaseFace( 2 );\r\n\t\t\t\t\t\t\t\t_displays[bi].SetSidesOut( 2 ); // all sides go to \u0022max\u0022 yellow\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t} );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tif ( br.Phase \u003E= Block.NUM_PHASES - 1 )\r\n\t\t\t\tAddStep( 0f, () =\u003E _displays[bi].SetSidesOut( 2 ) );\r\n\r\n\t\t\tAddStep( DELAY_AFTER_BLOCKS, null );\r\n\t\t}\r\n\r\n\t\t// Time bonus only on a full win.\r\n\t\tif ( Victory )\r\n\t\t{\r\n\t\t\tint bonus = ScoreCalc.TimeBonus( _gameTime );\r\n\r\n\t\t\t// Win moment: once every block\u0027s sides are tallied, reveal VICTORY! (one line above\r\n\t\t\t// where TIME BONUS will land), flip every block to its death face \u2014 the expression they\r\n\t\t\t// wear in the live all-max win throes \u2014 and give them a quick celebratory scale bounce.\r\n\t\t\tAddStep( DELAY_BEFORE_PHASES, () =\u003E\r\n\t\t\t{\r\n\t\t\t\tShowVictory = true;\r\n\t\t\t\tTallySfx( SfxType.BlockPhaseReachMax, Arena.Center );\r\n\t\t\t\tforeach ( var d in _displays )\r\n\t\t\t\t{\r\n\t\t\t\t\td.SetDeathFace();\r\n\t\t\t\t\td.StartBounce();\r\n\t\t\t\t}\r\n\t\t\t} );\r\n\r\n\t\t\t// Hold on the bounce, then drop in the TIME BONUS line under the VICTORY! banner.\r\n\t\t\tAddStep( DELAY_VICTORY_HOLD, () =\u003E Banner = $\u0022TIME BONUS: \u002B{bonus}\u0022 );\r\n\r\n\t\t\tint remaining = bonus;\r\n\t\t\twhile ( remaining \u003E 0 )\r\n\t\t\t{\r\n\t\t\t\tint inc = Math.Max( (int)MathF.Floor( remaining * 0.25f ), 1 );\r\n\t\t\t\tremaining -= inc;\r\n\t\t\t\tint add = inc;\r\n\t\t\t\tAddStep( TIME_BONUS_TICK, () =\u003E\r\n\t\t\t\t{\r\n\t\t\t\t\tScore \u002B= add;\r\n\t\t\t\t\tTallySfx( SfxType.AddScoreTimeBonus, Arena.Center );\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t// Loss moment: same beat as the VICTORY! reveal, but a quiet YOU DIED in its place.\r\n\t\t\tAddStep( DELAY_BEFORE_PHASES, () =\u003E ShowDeath = true );\r\n\t\t}\r\n\r\n\t\t// Coins, counted out one per step so the count AND the contribution visibly rise with the\r\n\t\t// total. Appended last on purpose: after the time bonus on a victory, straight after the\r\n\t\t// block sides otherwise. The final Score must equal ScoreCalc.Compute(blocks, time, coins) \u2014\r\n\t\t// the submitted value \u2014 which is why the per-coin add is the same COIN_SCORE constant.\r\n\t\tif ( _coins \u003E 0 )\r\n\t\t{\r\n\t\t\tAddStep( DELAY_VICTORY_HOLD, () =\u003E\r\n\t\t\t{\r\n\t\t\t\tCoinBanner = \u0022COINS: \u002B0\u0022;\r\n\t\t\t\tTallySfx( SfxType.MenuBlip, ScoreWorldPos );\r\n\t\t\t} );\r\n\r\n\t\t\tfor ( int i = 1; i \u003C= _coins; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tint ci = i;\r\n\t\t\t\tAddStep( COIN_TICK, () =\u003E\r\n\t\t\t\t{\r\n\t\t\t\t\tScore \u002B= ScoreCalc.COIN_SCORE;\r\n\t\t\t\t\tCoinBanner = $\u0022COINS: \u002B{ci}\u0022;\r\n\t\t\t\t\tTallySfx( SfxType.CoinTally, ScoreWorldPos );\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tprivate static SfxType CompleteSfx( int phase, bool completes )\r\n\t{\r\n\t\tif ( phase == 0 ) return completes ? SfxType.AddScorePhase0 : SfxType.AddScoreFinishPhase0;\r\n\t\treturn completes ? SfxType.AddScorePhase1 : SfxType.AddScoreFinishPhase1;\r\n\t}\r\n\r\n\t// Plays a tally sound, but once the player has fast-forwarded the remaining steps all run\r\n\t// in one frame; allow only FAST_SFX_BUDGET of them through so they don\u0027t stack into noise.\r\n\tprivate void TallySfx( SfxType type, Vector2 pos )\r\n\t{\r\n\t\tif ( _fast )\r\n\t\t{\r\n\t\t\tif ( _fastSfxBudget \u003C= 0 ) return;\r\n\t\t\t_fastSfxBudget--;\r\n\t\t}\r\n\t\tAudio.PlaySfx( type, pos );\r\n\t}\r\n\r\n\tprivate void AddFloater( Vector2 blockPos, Direction dir, int amount, int phase, bool completes )\r\n\t{\r\n\t\t_floaters.Add( new Floater\r\n\t\t{\r\n\t\t\tWorld = blockPos \u002B Globals.GetVectorForDirection( dir ) * FLOATER_DISTANCE,\r\n\t\t\tText = $\u0022\u002B{amount}\u0022,\r\n\t\t\tColor = FloaterColor( phase, completes ),\r\n\t\t\tAge = 0f,\r\n\t\t} );\r\n\t}\r\n\r\n\tprivate static Color FloaterColor( int phase, bool completes )\r\n\t{\r\n\t\t// Mirrors the original TallyScore swatches.\r\n\t\tif ( phase == 0 )\r\n\t\t\treturn completes ? Rgb( 155, 165, 255 ) : Rgb( 115, 120, 208 ); // lighter blue / blue\r\n\t\t// Phase-2 sides: the \u002B20s take the reddish tint (matching the red buttons) and the final\r\n\t\t// \u002B40 takes the yellowish tint (matching the final max side button).\r\n\t\treturn completes ? Rgb( 221, 221, 153 ) : Rgb( 200, 90, 85 );        // yellow / red\r\n\t}\r\n\r\n\tprivate static Color Rgb( int r, int g, int b ) =\u003E new Color( r / 255f, g / 255f, b / 255f );\r\n\r\n\t// --- update -------------------------------------------------------------------------\r\n\tpublic override void Tick( float dt )\r\n\t{\r\n\t\tbase.Tick( dt );\r\n\r\n\t\t// The unlock reveal owns all input while it\u0027s up (GameManager eats the frame\u0027s menu edges for\r\n\t\t// it, but the Restart key below is read straight from the engine), and the exit it was handed\r\n\t\t// fires when it finishes fading. Keep the tally\u0027s cosmetics moving behind it.\r\n\t\tif ( Manager.UnlockReveal.IsActive )\r\n\t\t{\r\n\t\t\tAgeFloaters( dt );\r\n\t\t\tforeach ( var d in _displays )\r\n\t\t\t\td.TickBounce( dt );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// The Restart key re-rolls a fresh run at any point during the score stage, matching the\r\n\t\t// in-run R: dailies accept it while restartable (today \u002B attempts remaining; debug freely).\r\n\t\t// Results which intentionally offer Continue only ignore it.\r\n\t\tif ( !IsFadingOut \u0026\u0026 !_firstTutorialVictory\r\n\t\t\t\u0026\u0026 (!_runContext.IsDaily || _isDebugRun || DailyRestartAllowed( _runContext ))\r\n\t\t\t\u0026\u0026 Input.Pressed( \u0022Restart\u0022 ) )\r\n\t\t{\r\n\t\t\tDoRestart();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( InputState.ConfirmJust )\r\n\t\t{\r\n\t\t\tConfirm();\r\n\t\t\tif ( Finished ) return;\r\n\t\t}\r\n\r\n\t\t// Once the tally finishes, Up/Down move the selector between the bottom buttons.\r\n\t\tif ( Finished \u0026\u0026 !IsFadingOut )\r\n\t\t{\r\n\t\t\tif ( InputState.NavDownJust ) NavigateButton( 1 );\r\n\t\t\tif ( InputState.NavUpJust ) NavigateButton( -1 );\r\n\t\t}\r\n\r\n\t\tAdvanceTally( dt );\r\n\t\tAgeFloaters( dt );\r\n\r\n\t\tforeach ( var d in _displays )\r\n\t\t\td.TickBounce( dt );\r\n\r\n\t\t_buttonSelector.Tick( dt, _selectedButton );\r\n\t}\r\n\r\n\t// --- unlock reveal --------------------------------------------------------------------\r\n\t/// \u003Csummary\u003EPay off any \u0022YOU UNLOCKED\u0022 reveal this run owes before leaving. Returns true when\r\n\t/// \u003Cparamref name=\u0022exit\u0022/\u003E was handed to the reveal (the caller must then do nothing) \u2014 it runs\r\n\t/// once the player has dismissed the overlay and it has faded out. False when nothing is owed and\r\n\t/// the caller should leave as normal. The queue is persisted, so a reveal skipped here (the player\r\n\t/// bailed out of the won run before the tally) is still delivered on the next menu screen.\u003C/summary\u003E\r\n\tprivate bool DeferForUnlock( Action exit )\r\n\t\t// The IsActive arm is belt-and-braces: a reveal already on screen has its own queued exit, so\r\n\t\t// swallow this one rather than leaving the tally out from under a live overlay.\r\n\t\t=\u003E Manager.UnlockReveal.IsActive || Manager.UnlockReveal.ShowPending( exit );\r\n\r\n\t/// \u003Csummary\u003EFast-forward the tally, or activate the selected button once it has finished.\u003C/summary\u003E\r\n\tpublic void Confirm()\r\n\t{\r\n\t\t// A click landing while the reveal is up belongs to the reveal. Its own overlay swallows the\r\n\t\t// click before this ever runs; this is the backstop.\r\n\t\tif ( Manager.UnlockReveal.IsActive )\r\n\t\t{\r\n\t\t\tManager.UnlockReveal.Dismiss();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( Finished )\r\n\t\t{\r\n\t\t\tActivateSelectedButton();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( !_fast )\r\n\t\t\t// One crisp \u0022skip\u0022 blip as the player confirms, pitched up so it reads as a\r\n\t\t\t// fast-forward rather than another tally tick. The flush\u0027s own sfx are throttled.\r\n\t\t\tAudio.PlaySfx( SfxType.MenuBlip, Arena.Center, pitch: 1.4f );\r\n\t\t_fast = true;\r\n\t}\r\n\r\n\t// --- end-of-tally button navigation -------------------------------------------------\r\n\t/// \u003Csummary\u003EKeyboard/pad navigation between the bottom buttons: move if possible, else wobble.\u003C/summary\u003E\r\n\tprivate void NavigateButton( int delta )\r\n\t{\r\n\t\t_selectedButton = MenuNav.Step( _selectedButton, delta, _buttons.Count, _buttonSelector );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECalled by the screen on hover to sync the selected button (mirrors the menu screens).\u003C/summary\u003E\r\n\tpublic void SelectButton( int index )\r\n\t{\r\n\t\tint next = Math.Clamp( index, 0, _buttons.Count - 1 );\r\n\t\tif ( next == _selectedButton )\r\n\t\t\treturn;\r\n\r\n\t\t_selectedButton = next;\r\n\t\t_buttonSelector.PlayMove();\r\n\t\tAudio.PlaySfx( SfxType.MenuBlip );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECalled by the screen on click: select the clicked button, then activate it.\u003C/summary\u003E\r\n\tpublic void ActivateButton( int index )\r\n\t{\r\n\t\tSelectButton( index );\r\n\t\tActivateSelectedButton();\r\n\t}\r\n\r\n\tprivate void ActivateSelectedButton()\r\n\t{\r\n\t\tif ( IsFadingOut )\r\n\t\t\treturn;\r\n\r\n\t\t_buttonSelector.PlayWobble();\r\n\r\n\t\tif ( _buttons[_selectedButton] == ScoreButton.Restart )\r\n\t\t\tDoRestart();\r\n\t\telse\r\n\t\t\tContinue();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhether a non-debug daily run may relaunch: the day must still be TODAY (live check \u2014\r\n\t/// see \u003Csee cref=\u0022GameManager.StartDailyChallenge\u0022/\u003E\u0027s matching backstop) with attempts remaining.\u003C/summary\u003E\r\n\tprivate static bool DailyRestartAllowed( RunContext context )\r\n\t\t=\u003E context.DailyId == DailyChallenge.TodayId\r\n\t\t\t\u0026\u0026 DailyChallengeProgress.HasAttemptsRemaining( context.DailyId, DailyLevels.Get( context.DailyId )?.MaxAttempts );\r\n\r\n\t/// \u003Csummary\u003EAttempts line shown on a daily run\u0027s tally (\u00222/5 ATTEMPTS LEFT\u0022, or \u0022UNLIMITED\r\n\t/// ATTEMPTS\u0022 on a day with no limit); null on non-daily runs. With daily_debug_nav on this reads\r\n\t/// the display-only debug counter instead of real progress (see\r\n\t/// \u003Csee cref=\u0022DailyChallengeProgress.DisplayedAttemptsUsed\u0022/\u003E).\u003C/summary\u003E\r\n\tpublic string DailyAttemptsText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( !_runContext.IsDaily )\r\n\t\t\t\treturn null;\r\n\t\t\tif ( DailyLevels.Get( _runContext.DailyId )?.MaxAttempts is not int max )\r\n\t\t\t\treturn \u0022UNLIMITED ATTEMPTS\u0022;\r\n\r\n\t\t\tint left = Math.Max( 0, max - DailyChallengeProgress.DisplayedAttemptsUsed( _runContext.DailyId ) );\r\n\t\t\treturn $\u0022{left}/{max} ATTEMPTS LEFT\u0022;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERe-roll a fresh run on the same level. The run\u0027s score was already banked by\r\n\t/// GameStage, so this just starts a new game. A daily restart re-guards and consumes one of the\r\n\t/// day\u0027s attempts (see \u003Csee cref=\u0022GameManager.StartDailyChallenge\u0022/\u003E); a DEBUG daily restarts freely.\u003C/summary\u003E\r\n\tprivate void DoRestart()\r\n\t{\r\n\t\tif ( _firstTutorialVictory )\r\n\t\t\treturn;\r\n\r\n\t\tif ( _runContext.IsDaily )\r\n\t\t{\r\n\t\t\tif ( !_isDebugRun \u0026\u0026 !DailyRestartAllowed( _runContext ) )\r\n\t\t\t\treturn;\r\n\r\n\t\t\t// Show the unlock first; this same method re-runs (guards and all) after the fade.\r\n\t\t\tif ( DeferForUnlock( DoRestart ) )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tAudio.PlaySfx( SfxType.MenuStart, Arena.Center );\r\n\t\t\tif ( _isDebugRun )\r\n\t\t\t\tManager.StartDailyChallengeDebug( _runContext.DailyId );\r\n\t\t\telse\r\n\t\t\t\tManager.StartDailyChallenge( _runContext.DailyId );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( DeferForUnlock( DoRestart ) )\r\n\t\t\treturn;\r\n\r\n\t\tAudio.PlaySfx( SfxType.MenuStart, Arena.Center );\r\n\t\t// Carry the run\u0027s character forward so a per-run override (e.g. a repro ConCmd\u0027s forced\r\n\t\t// character) survives a score-screen restart, matching the in-run R restart path.\r\n\t\tManager.StartLevel( _runContext.LevelId, _runContext.CharacterId );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELeave the tally: a daily run returns to the daily hub; a workshop run returns to the\r\n\t/// workshop browser; a normal run returns straight to the level-select map it was launched from\r\n\t/// (win or loss \u2014 the map\u0027s footer trophy button reaches the full leaderboard).\u003C/summary\u003E\r\n\tprivate void Continue()\r\n\t{\r\n\t\tif ( DeferForUnlock( Continue ) )\r\n\t\t\treturn;\r\n\r\n\t\tAudio.PlaySfx( SfxType.MenuStart, Arena.Center );\r\n\t\tFadeToStage( _runContext.IsDaily\r\n\t\t\t? new DailyChallengeStage( Manager, _runContext.DailyId )\r\n\t\t\t: _runContext.Level?.IsWorkshop == true\r\n\t\t\t\t? new WorkshopStage( Manager )\r\n\t\t\t\t: new LevelSelectStage( Manager ) );\r\n\t}\r\n\r\n\t// --- footer shortcuts (leaderboard / this run\u0027s replay) -------------------------------\r\n\t/// \u003Csummary\u003ESnapshot of everything this tally was built from, so a screen opened from it can\r\n\t/// rebuild it (already counted out) when the player backs out.\u003C/summary\u003E\r\n\tpublic ScoreReturn Snapshot() =\u003E new ScoreReturn\r\n\t{\r\n\t\tBlocks = _blocks,\r\n\t\tGameTime = _gameTime,\r\n\t\tContext = _runContext,\r\n\t\tFirstTutorialVictory = _firstTutorialVictory,\r\n\t\tLevelWasBeatenOnEntry = _levelWasBeatenOnEntry,\r\n\t\tIsDebugRun = _isDebugRun,\r\n\t\tCoins = _coins,\r\n\t\tRunData = _runData,\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EWhether the footer\u0027s trophy button is offered: this run has a real board behind it\r\n\t/// (daily runs read their board on the daily page, debug runs never submit).\u003C/summary\u003E\r\n\tpublic bool CanViewLeaderboard =\u003E Finished \u0026\u0026 !_runContext.IsDaily \u0026\u0026 !_isDebugRun;\r\n\r\n\t/// \u003Csummary\u003EWhether the footer\u0027s replay button is offered \u2014 the run we just played was recorded.\u003C/summary\u003E\r\n\tpublic bool CanWatchReplay =\u003E Finished \u0026\u0026 _runData?.CanReplay == true;\r\n\r\n\t/// \u003Csummary\u003EThe Steam Workshop page of the level just played (footer thumbs-up link); null unless\r\n\t/// this was a workshop level and the tally has finished.\u003C/summary\u003E\r\n\tpublic string WorkshopItemUrl =\u003E Finished ? WorkshopLevels.ItemPageUrlFor( _runContext.Level?.Id ) : null;\r\n\r\n\t/// \u003Csummary\u003ETrophy button: open this level\u0027s full board. BACK there returns to this tally.\u003C/summary\u003E\r\n\tpublic void ViewLeaderboard()\r\n\t{\r\n\t\tif ( IsFadingOut || !CanViewLeaderboard )\r\n\t\t\treturn;\r\n\t\tif ( DeferForUnlock( ViewLeaderboard ) )\r\n\t\t\treturn;\r\n\r\n\t\tAudio.PlaySfx( SfxType.MenuStart, Arena.Center );\r\n\t\tFadeToStage( new HighscoreStage( Manager, levelId: _runContext.Level?.Id, returnScore: Snapshot() ) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EReplay button: watch the run that was just played (its recorded payload, attributed to\r\n\t/// us). Exiting the replay returns to this tally.\u003C/summary\u003E\r\n\tpublic void WatchReplay()\r\n\t{\r\n\t\tif ( IsFadingOut || !CanWatchReplay )\r\n\t\t\treturn;\r\n\t\tif ( DeferForUnlock( WatchReplay ) )\r\n\t\t\treturn;\r\n\r\n\t\tAudio.PlaySfx( SfxType.MenuStart, Arena.Center );\r\n\t\tlong steamId = (long)Game.SteamId;\r\n\t\tManager.StartReplay( _runData, new ReplaySubmitter( steamId, new Friend( steamId ).Name, \u0022\u0022, DateTimeOffset.UtcNow ),\r\n\t\t\treturnScore: Snapshot(), returnToScore: true );\r\n\t}\r\n\r\n\tprivate void AdvanceTally( float dt )\r\n\t{\r\n\t\tif ( _cursor \u003E= _steps.Count ) return;\r\n\r\n\t\t_stepTimer -= _fast ? 1000f : dt;\r\n\t\twhile ( _cursor \u003C _steps.Count \u0026\u0026 _stepTimer \u003C= 0f )\r\n\t\t{\r\n\t\t\t_steps[_cursor].Act?.Invoke();\r\n\t\t\t_cursor\u002B\u002B;\r\n\t\t\t_stepTimer \u002B= _cursor \u003C _steps.Count ? _steps[_cursor].Wait : 0f;\r\n\t\t}\r\n\r\n\t\tif ( _cursor \u003E= _steps.Count \u0026\u0026 !Finished )\r\n\t\t\tOnTallyComplete();\r\n\t}\r\n\r\n\tprivate void OnTallyComplete()\r\n\t{\r\n\t\tFinished = true;\r\n\t}\r\n\r\n\tprivate void AgeFloaters( float dt )\r\n\t{\r\n\t\tfor ( int i = _floaters.Count - 1; i \u003E= 0; i-- )\r\n\t\t{\r\n\t\t\tvar f = _floaters[i];\r\n\t\t\tf.Age \u002B= dt;\r\n\t\t\tf.World = new Vector2( f.World.x, f.World.y \u002B FLOATER_RISE * dt );\r\n\t\t\tif ( f.Age \u003E= FLOATER_LIFE )\r\n\t\t\t\t_floaters.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\t// --- one block\u0027s display sprites ----------------------------------------------------\r\n\tpublic sealed class TallyDisplay\r\n\t{\r\n\t\tprivate readonly GameObject _go;\r\n\t\tprivate readonly SpriteRenderer _face, _mouth, _eyes, _eyebrows;\r\n\t\tprivate readonly Dictionary\u003CDirection, SpriteRenderer\u003E _sides = new();\r\n\t\tprivate static readonly Vector2 SIZE = new Vector2( 40, 40 );\r\n\r\n\t\t// Quick scale \u0022punch\u0022 played on the victory reveal: snap up to BOUNCE_PEAK over BOUNCE_UP,\r\n\t\t// then ease back to 1x over BOUNCE_DOWN. _bounceTime \u003C 0 means no bounce is running.\r\n\t\tprivate const float BOUNCE_PEAK = 1.15f;\r\n\t\tprivate const float BOUNCE_UP = 0.05f;\r\n\t\tprivate const float BOUNCE_DOWN = 0.10f;\r\n\t\tprivate float _bounceTime = -1f;\r\n\r\n\t\tpublic TallyDisplay( ScoreStage stage, BlockResult br, Vector2 pos )\r\n\t\t{\r\n\t\t\tvar go = stage.CreateChild( $\u0022Tally_{br.Type}\u0022 );\r\n\t\t\t_go = go;\r\n\t\t\tgo.WorldPosition = new Vector3( MathF.Round( pos.x ), MathF.Round( pos.y ), Globals.DepthToZ( Globals.DEPTH_BLOCK ) );\r\n\r\n\t\t\t// Start in the faded \u0022asleep\u0022 look: flat, dim greyscale phase-0 shapes with closed eyes\r\n\t\t\t// (baked tally_* anims). The fade is baked into the grey art and fully opaque, so the\r\n\t\t\t// lit/coloured pressed buttons read against it without any background bleeding through.\r\n\t\t\t_face = SpriteLayer.Add( go, br.SpritePath, SIZE, \u0022tally_face\u0022, childOrder: 0 );\r\n\t\t\t_mouth = SpriteLayer.Add( go, br.SpritePath, SIZE, \u0022tally_mouth\u0022, childOrder: 2 );\r\n\t\t\t_eyes = SpriteLayer.Add( go, br.SpritePath, SIZE, \u0022tally_eyes\u0022, childOrder: 3 );\r\n\t\t\t_eyebrows = SpriteLayer.Add( go, br.SpritePath, SIZE, \u0022tally_eyebrows\u0022, childOrder: 4 );\r\n\r\n\t\t\tforeach ( var d in Globals.GetAllDirections() )\r\n\t\t\t\t_sides[d] = SpriteLayer.Add( go, \u0022sprites/sides.sprite\u0022, SIZE, $\u0022out0dark_{Globals.GetStringForDirection( d )}\u0022, childOrder: 1 );\r\n\r\n\t\t\t// Authored starting progress the run began with: open on the phase it was authored at with\r\n\t\t\t// its pre-pressed buttons already down, so the tally only counts out what the player earned.\r\n\t\t\t// A block authored as already complete gets no display at all (see ScoreStage._tallyBlocks),\r\n\t\t\t// so ScoreStartPhase here is always below max phase.\r\n\t\t\tif ( br.ScoreStartPhase \u003E 0 )\r\n\t\t\t{\r\n\t\t\t\tSetPhaseFace( br.ScoreStartPhase );\r\n\t\t\t\tSetSidesOut( br.ScoreStartPhase );\r\n\t\t\t}\r\n\t\t\tforeach ( var d in Globals.GetAllDirections() )\r\n\t\t\t\tif ( br.WasPressedAtScoreStart( d ) ) PressSide( d, br.ScoreStartPhase );\r\n\t\t}\r\n\r\n\t\t/// \u003Csummary\u003EPop every side out for the given phase (0/1) or max (2) \u2014 plays the pop-out\r\n\t\t/// animation so the replay mirrors the live block\u0027s buttons.\u003C/summary\u003E\r\n\t\tpublic void SetSidesOut( int phase )\r\n\t\t{\r\n\t\t\tstring add = phase \u003E= 2 ? \u0022maxadd\u0022 : phase == 1 ? \u0022out1darkadd\u0022 : \u0022out0darkadd\u0022;\r\n\t\t\tforeach ( var d in Globals.GetAllDirections() )\r\n\t\t\t\t_sides[d].PlayAnimation( $\u0022{add}_{Globals.GetStringForDirection( d )}\u0022 );\r\n\t\t}\r\n\r\n\t\tpublic void PressSide( Direction dir, int phase )\r\n\t\t{\r\n\t\t\tstring state = phase == 0 ? \u0022pressed0\u0022 : \u0022pressed1\u0022;\r\n\t\t\t_sides[dir].PlayAnimation( $\u0022{state}_{Globals.GetStringForDirection( dir )}\u0022 );\r\n\t\t}\r\n\r\n\t\t/// \u003Csummary\u003EReveal the block\u0027s real, full-colour face for a phase it has reached (replacing\r\n\t\t/// the faded grey tally art). The eyes use a fixed forward (\u0022down\u0022) gaze so the look matches\r\n\t\t/// the static row graphic (sprites/ui/blocks/{type}_p{phase}.png).\u003C/summary\u003E\r\n\t\tpublic void SetPhaseFace( int phase )\r\n\t\t{\r\n\t\t\t_face.PlayAnimation( $\u0022face_{phase}\u0022 );\r\n\t\t\t_mouth.PlayAnimation( $\u0022mouth_{phase}\u0022 );\r\n\t\t\t_eyes.PlayAnimation( $\u0022eyes_down_{phase}\u0022 );\r\n\t\t\t_eyebrows.PlayAnimation( $\u0022eyebrows_{phase}\u0022 );\r\n\t\t}\r\n\r\n\t\t/// \u003Csummary\u003ESwitch to the death face shown once a winning tally completes: the maxed\r\n\t\t/// (phase-2) face with the dead eyes/mouth/brows (mirrors \u003Csee cref=\u0022Block.Die\u0022/\u003E).\u003C/summary\u003E\r\n\t\tpublic void SetDeathFace()\r\n\t\t{\r\n\t\t\t_face.PlayAnimation( \u0022face_2\u0022 );\r\n\t\t\t_eyes.PlayAnimation( \u0022eyes_dead\u0022 );\r\n\t\t\t_mouth.PlayAnimation( \u0022mouth_dead\u0022 );\r\n\t\t\t_eyebrows.PlayAnimation( \u0022eyebrows_dead\u0022 );\r\n\t\t}\r\n\r\n\t\t/// \u003Csummary\u003EKick off the quick victory scale punch (see BOUNCE_* constants).\u003C/summary\u003E\r\n\t\tpublic void StartBounce() =\u003E _bounceTime = 0f;\r\n\r\n\t\t/// \u003Csummary\u003EAdvance the scale punch; a no-op once the bounce has finished (or never started).\u003C/summary\u003E\r\n\t\tpublic void TickBounce( float dt )\r\n\t\t{\r\n\t\t\tif ( _bounceTime \u003C 0f ) return;\r\n\r\n\t\t\t_bounceTime \u002B= dt;\r\n\r\n\t\t\tfloat scale;\r\n\t\t\tif ( _bounceTime \u003C BOUNCE_UP )\r\n\t\t\t\tscale = MathX.Lerp( 1f, BOUNCE_PEAK, _bounceTime / BOUNCE_UP );\r\n\t\t\telse if ( _bounceTime \u003C BOUNCE_UP \u002B BOUNCE_DOWN )\r\n\t\t\t\tscale = MathX.Lerp( BOUNCE_PEAK, 1f, (_bounceTime - BOUNCE_UP) / BOUNCE_DOWN );\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tscale = 1f;\r\n\t\t\t\t_bounceTime = -1f; // done\r\n\t\t\t}\r\n\r\n\t\t\t_go.LocalScale = scale;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// Everything a finished \u003Csee cref=\u0022ScoreStage\u0022/\u003E was built from. Carried by the screens the tally\u0027s\r\n/// footer buttons open (the full leaderboard, and this run\u0027s replay) so their BACK rebuilds the tally\r\n/// with its count-out already played out \u2014 the player lands back on RESTART / CONTINUE.\r\n/// \u003C/summary\u003E\r\npublic sealed class ScoreReturn\r\n{\r\n\tpublic List\u003CBlockResult\u003E Blocks;\r\n\tpublic float GameTime;\r\n\tpublic RunContext Context;\r\n\tpublic bool FirstTutorialVictory;\r\n\tpublic bool LevelWasBeatenOnEntry;\r\n\tpublic bool IsDebugRun;\r\n\tpublic int Coins;\r\n\tpublic RunData RunData;\r\n\r\n\tpublic ScoreStage Create( GameManager manager ) =\u003E new ScoreStage( manager, Blocks, GameTime, Context,\r\n\t\tFirstTutorialVictory, LevelWasBeatenOnEntry, IsDebugRun, Coins, RunData, skipAnimation: true );\r\n}\r\n"}]}