Stages/GameStage.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// Main gameplay stage. Port of the original <c>GameStage</c>: owns the player, the
/// blocks, projectiles and particles, drives them in a fixed per-step order, and tracks
/// the win condition (all blocks reach max phase).
///
/// Phase 3a: player + 5 blocks + screenshake + win/lose restart. Projectiles/particles
/// (phase 4), block spikes/blink/speed-lines (phase 3b), and subtype attacks (phase 5)
/// are not active yet.
/// </summary>
public sealed class GameStage : StageBase
{
public readonly List<Block> Blocks = new();
public readonly List<Fireball> Fireballs = new();
public readonly List<Bullet> Bullets = new();
public readonly List<SwapperProjectile> SwapperProjectiles = new();
public readonly List<BlockBulletProjectile> BlockBullets = new();
public readonly List<Teardrop> Teardrops = new();
public readonly List<Particle> Particles = new();
public readonly List<Portal> Portals = new();
public readonly List<ImpostorPortal> ImpostorPortals = new();
public readonly List<Coin> Coins = new();
public readonly List<CoinFloater> CoinFloaters = new();
public readonly List<Player> Players = new();
/// <summary>Coins picked up this run. Sim state (derived from deterministic overlap, no RNG):
/// read at EndGame for the tally, the submitted score, AND the replay-verify recompute, all from
/// this one field so they can't diverge.</summary>
public int CoinsCollected { get; private set; }
/// <summary>Secondary player bodies: hostile Summoner impostors and friendly character copies.
/// They share physics and hazard iteration while contact allegiance and chase targets remain explicit.</summary>
public readonly List<Player> Impostors = new();
// How much the impostor's box must overlap the player's box (on BOTH axes, in px) to count as a
// kill. The player box is 8x10, so this needs the clone well ON the player, not just edge-touching.
const float IMPOSTOR_KILL_OVERLAP = 4.0f;
/// <summary>Static solid interior wall regions for this level (empty for the classic square arena).
/// Does NOT include fences — those live in <see cref="_fences"/> so every player/hazard path that
/// iterates this list ignores them for free.</summary>
private readonly List<Obstacle> _obstacles = new();
/// <summary>FENCES: obstacles flagged solid ONLY to blocks (see <see cref="LevelDef.Fences"/>).
/// Kept out of <see cref="_obstacles"/> — only the block-movement collision paths
/// (<see cref="GetBlockSolidObstacles"/> / <see cref="GetFences"/>) ever see them.</summary>
private readonly List<Obstacle> _fences = new();
/// <summary>The NON-fence subset of <c>Level.Obstacles</c>, split once in <see cref="SpawnArena"/>.
/// All the wall-face building helpers (flush cutting, elbow/corner patches, band coverage) iterate
/// this instead of <c>Level.Obstacles</c>: a fence never cuts an arena side or another obstacle's
/// face, never merges with them, and never carries faces of its own.</summary>
private readonly List<RectF> _wallObstacleRects = new();
/// <summary>The FENCE subset of <c>Level.Obstacles</c>, split in the same <see cref="SpawnArena"/>
/// pass as <see cref="_wallObstacleRects"/>. The fence frame builder consults this — not the
/// still-filling <see cref="_fences"/> entity list — so the first fence built already sees every
/// flush fence neighbour.</summary>
private readonly List<RectF> _fenceRects = new();
/// <summary>The GLASS subset of <c>Level.Obstacles</c> (see <see cref="LevelDef.Glass"/>), split
/// in the same pass. Glass ENTITIES live inside <see cref="_obstacles"/> (flagged
/// <see cref="Obstacle.IsGlass"/>) so every player path treats them as walls; this rect list
/// exists for the visual builders (border seam cutting between flush glass panels).</summary>
private readonly List<RectF> _glassRects = new();
/// <summary>Every fence-frame / glass-border strip actually drawn (the seam-cut survivors),
/// recorded so the frame elbow pass can probe coverage once ALL panels exist — the 1px-outline
/// analogue of the <see cref="_faces"/> list (see <see cref="AddFrameElbowPatches"/>).</summary>
private struct FrameSeg { public bool Horizontal; public float Perp; public float Min, Max; }
private readonly List<FrameSeg> _fenceFrameSegs = new();
private readonly List<FrameSeg> _glassBorderSegs = new();
private readonly List<Vector2> _playerStepStarts = new();
private TwinsController _twinsController;
// The looping translucent movement demo, present only on levels with an authored ghost clip
// (Assets/ghosts/<level-id>.json). Cosmetic-only; see GhostPlayback.
private GhostPlayback _ghost;
public Player Player => _twinsController?.ActivePlayer ?? (Players.Count > 0 ? Players[0] : null);
public int ActivePlayerIndex => _twinsController?.ActiveIndex ?? 0;
/// <summary>The level this run plays, resolved from the run context on enter (classic when the
/// context carries no level — e.g. daily challenges).</summary>
public LevelDef Level { get; private set; }
// The level whose palette drives the per-stage colour hooks. Read from the run context (set before
// Stage.Enter, so it's available even though the Level field above is only assigned in OnEnter,
// which runs AFTER the base Enter() spawns the playfield / sets the camera colour).
private LevelDef PaletteLevel => Manager.CurrentRunContext.Level;
protected override Color EffectiveClearColor => PaletteLevel?.OutOfBoundsColor ?? base.EffectiveClearColor;
protected override string PlayfieldSprite => PaletteLevel?.CheckerboardColor is not null ? "sprites/background_neutral.sprite" : base.PlayfieldSprite;
protected override Color PlayfieldTint => PaletteLevel?.CheckerboardColor is Color c ? GammaToLinear( c ) : base.PlayfieldTint;
protected override PlayfieldPattern PlayfieldPatternKind => PaletteLevel?.PlayfieldPattern ?? PlayfieldPattern.Checker;
protected override int PlayfieldCellScale => PaletteLevel?.PlayfieldCellScale ?? 1;
protected override System.Collections.Generic.IReadOnlyList<string> PlayfieldPatternRows => PaletteLevel?.PlayfieldPatternRows;
protected override Color PlayfieldPatternColor => PaletteLevel?.CheckerboardColor ?? base.PlayfieldPatternColor;
protected override Color? PlayfieldPatternSecondColor => PaletteLevel?.CheckerboardSecondColor;
protected override bool AlternatePlayfieldEnabled => PaletteLevel?.AlternatePlayfieldEnabled ?? false;
protected override Color AlternatePlayfieldColor => PaletteLevel?.AlternateCheckerboardColor ?? PlayfieldPatternColor;
protected override Color? AlternatePlayfieldSecondColor => PaletteLevel?.AlternateCheckerboardSecondColor;
protected override PlayfieldPattern AlternatePlayfieldPatternKind => PaletteLevel?.AlternatePlayfieldPattern ?? PlayfieldPattern.Checker;
protected override int AlternatePlayfieldCellScale => PaletteLevel?.AlternatePlayfieldCellScale ?? 1;
protected override System.Collections.Generic.IReadOnlyList<string> AlternatePlayfieldPatternRows => PaletteLevel?.AlternatePlayfieldPatternRows;
protected override System.Collections.Generic.IReadOnlyList<RectF> AlternatePlayfieldRects => PaletteLevel?.AlternatePlayfieldRects;
protected override Color? BackgroundBlockBaseColor => PaletteLevel?.BackgroundBlockColor;
protected override System.Collections.Generic.IReadOnlyList<Color> BackgroundBlockColors => PaletteLevel?.BackgroundBlockColors;
protected override float BackgroundBlockScale => PaletteLevel?.BackgroundBlockScale ?? 1f;
protected override float BackgroundBlockDensity => PaletteLevel?.BackgroundBlockDensity ?? 1f;
protected override float BackgroundBlockOpacity => PaletteLevel?.BackgroundBlockOpacity ?? 1f;
protected override float BackgroundDriftSpeed => PaletteLevel?.BackgroundDriftSpeed ?? 1f;
protected override BackgroundDriftBias BackgroundBlockDriftBias => PaletteLevel?.BackgroundDriftBias ?? BackgroundDriftBias.None;
// --- background particle ambience (see StageBase.TickBackgroundParticles) -----------
protected override BackgroundParticleSettings BuildBackgroundParticleSettings()
=> BackgroundParticleSettings.FromLevel( PaletteLevel );
protected override void CollectBackgroundParticleStaticSolids( System.Collections.Generic.List<RectF> solids )
{
// Ordinary obstacles AND glass, never fences — the same rule as GetParticleSolidObstacles.
solids.AddRange( _wallObstacleRects );
solids.AddRange( _glassRects );
}
protected override void CollectBackgroundParticleDynamicSolids( System.Collections.Generic.List<RectF> solids )
{
foreach ( var block in Blocks )
if ( !block.IsDead && !block.PhasingIn )
solids.Add( block.GetRect() );
}
// WallColor is the direct displayed colour of the baked art's opaque 58/64/76 wall pixel. The
// SpriteRenderer still multiplies texture by tint internally, so derive the linear multiplier that
// maps that source pixel to the authored colour while preserving the art's alpha and shading.
private Color WallColorLinear => GammaToLinear( PaletteLevel?.WallColor ?? WALL_FILL_COLOR );
private Color WallSpriteTintLinear
{
get
{
Color source = GammaToLinear( WALL_FILL_COLOR );
Color target = WallColorLinear;
return new Color( target.r / source.r, target.g / source.g, target.b / source.b, target.a );
}
}
// TEMP DEBUG
private static readonly bool DEBUG_STARTING_BLOCKS = false;
// --- in-game options overlay --------------------------------------------------------
// Opening the gear menu freezes the simulation (see BlocksSimulation) and shows the shared
// volume options over the arena, without ducking the music (so the player hears an accurate
// level while adjusting). The game state is preserved — closing resumes exactly where it left.
public OptionsMenuController OptionsController { get; }
public bool MenuOpen { get; private set; }
/// <summary>True while the replay HUD's watch-a-code dialog is up (set by GameHud, which owns the
/// dialog). Folded into <see cref="WantsQuantize"/> so its text isn't mushed by the 240 grid.</summary>
public bool ReplayImportOpen { get; set; }
// --- leave-run confirm ------------------------------------------------------------------
// Back/Home on a live, limited-attempt daily run first ask "leave?" (see NeedsLeaveConfirm): the
// attempt was spent at launch, so bailing early throws it away. The intercepted action is held
// here until the prompt's LEAVE carries it out; the prompt freezes the sim like the options menu.
public enum LeaveAction { None, Back, Home }
private LeaveAction _pendingLeave;
public bool LeaveConfirmOpen => _pendingLeave != LeaveAction.None;
/// <summary>Focused prompt button: 0 = KEEP PLAYING (default), 1 = LEAVE.</summary>
public int LeaveConfirmSelection { get; private set; }
/// <summary>Bumped on every prompt open/close/focus change so the HUD repaints.</summary>
public int LeaveConfirmRevision { get; private set; }
// The only stage that quantizes to the 240 grid — and only while actually playing; the fade
// goes out while the in-game options menu covers the arena and snaps back in on resume.
public override bool WantsQuantize => !MenuOpen && !ShowCharacterHelp && !ReplayImportOpen && !LeaveConfirmOpen;
/// <summary>True when this stage is replaying a recorded run (driven by recorded input). The
/// must not tally/submit a new score — it just returns to the leaderboard when it ends.</summary>
public bool IsReplay { get; }
/// <summary>Sim version this stage's fixed-step sim runs at — the recorded version during a
/// replay, <see cref="Sim.VERSION"/> live. Version gates in the sim compare against this, never
/// against Sim.VERSION directly (see that constant's doc).</summary>
public int SimVersion => Manager.CurrentRunSimVersion;
/// <summary>True when this is a level-editor TEST run of an unsaved level: it never submits/tallies,
/// and pressing Back (or an unattended death) returns to the editor with its state intact.
/// Set once at construction (see <see cref="GameManager.StartLevelTest"/>).</summary>
public bool IsTest { get; set; }
/// <summary>True when this is a DEBUG daily run (daily_play ConCmd): plays a past/future day's
/// generated daily without submitting, marking progress, or counting an attempt. Unlike
/// <see cref="IsTest"/> the end-of-run flow is the normal daily one (score screen → daily hub),
/// not the level editor. Set once at construction (see <see cref="GameManager.StartDailyChallengeDebug"/>).</summary>
public bool IsDebugRun { get; set; }
private GameHud _hud;
public ReplayReactionTooltip ReactionTooltip { get; private set; }
private ReplayReactionSprites _reactionSprites;
// Game-over: in the original, death OR winning (all blocks max phase) ends the run and
// shows the score. We hold for a beat, then submit the score and open the highscore board.
private float _gameTime;
private bool _gameOver;
private float _gameOverTimer;
private bool _ended;
// Death (a player death) rather than a win triggered the game-over beat. Gates the in-game
// restart affordance, which is offered only after the player dies.
private bool _died;
// Any body died this run, even one whose partner carried on (a Twins run survives its first death).
// Feeds the flawless "beat X as Twins" pairing; _died alone can't see a death the run outlived.
private bool _anyPlayerDied;
// Captured when gameplay begins so a victory can distinguish first completion from a replay for score.
private bool _levelWasBeatenOnEntry;
// The run's score + replay are submitted exactly once, whether the game-over beat plays out into
// the score tally or the player bails early (restart/home) after the run is decided.
private bool _submitted;
// Death ends the run after a short beat; a win holds a touch longer so the death-throes
// (jitter/dust/explosions) get room to play before cutting to the score tally.
private const float GAME_OVER_DELAY = 2f;
private const float WIN_DELAY = 3f;
private const float VICTORY_EXPLOSION_SFX_INTERVAL_MIN = 0.12f;
private const float VICTORY_EXPLOSION_SFX_INTERVAL_MAX = 0.28f;
private float _victoryExplosionSfxTimer = -1f;
private const float CHARACTER_HELP_FADE_TIME = 1.1f;
private const float CHARACTER_HELP_ANIMATION_STEP_TIME = 0.1f;
// Every help-row animation derives its state from this counter (see GameHud's CharacterHelp*State
// helpers), so the wrap has to be a common multiple of BOTH the fast step's periods and the slow
// step's (fast/5) — otherwise a row's sequence jumps mid-cycle when the counter rolls over. Longest
// is the 8-way Blinker row: 64 states of 2 slow steps needs the slow range divisible by 128, and
// with the 3s, 5s and 9s the other rows want that lands on 5760 slow steps = 28800 raw.
private const int CHARACTER_HELP_ANIMATION_WRAP = 28800;
private const float TUTORIAL_SECOND_PROMPT_DELAY = 0.8f;
private const float TUTORIAL_PROMPT_HOLD_TIME = 0.3f; // hold any game input this long to dismiss
private const float TUTORIAL_PROMPT_DISMISS_TIME = 0.4f; // punch + fade; keep in sync with the .dismissing animations (GameHud.razor.scss)
private enum CharacterHelpPhase
{
Closed,
Visible,
AutoDismissFading,
}
private CharacterHelpPhase _characterHelpPhase;
private bool _characterHelpFadesOnDismiss;
private float _characterHelpFadeTimer;
private float _characterHelpAnimationTimer;
private int _characterHelpAnimationStep;
private int _characterHelpRevision;
private string _characterHelpCharacterId;
private bool _characterHelpIsMimicForm;
// The run's character is remembered by ID, not by reference: reload_characters rebuilds every
// CharacterDef instance (so a cached def would keep serving stale art/help while the dev iterates),
// and SubmitRunOnce clears Manager.CurrentRunContext while this stage is still on screen (so
// reading the context live would flip to the menu-selected character during the game-over beat).
private string _runCharacterId;
public CharacterDef RunCharacter => _runCharacterId is null ? null : Characters.Get( _runCharacterId );
/// <summary>The character the "?" button teaches right now. Normally the run's character, but a
/// Mimic run is a different character every time it transforms, so it follows the body the player is
/// actually driving (Mimic itself while untransformed). Twins is deliberately NOT tracked this way:
/// its Player is whichever twin holds control, while the run character is the paired def the help
/// panel wants.</summary>
public CharacterDef HelpCharacter
{
get
{
var run = RunCharacter;
if ( run is null || run.Id != Characters.Mimic.Id ) return run;
return Player?.Character ?? run;
}
}
/// <summary>A Mimic is currently wearing another character's shape, so the "?" teaches that shape's
/// <see cref="CharacterDef.MimicFormHelp"/> (a solo body) rather than its selectable help.</summary>
public bool IsMimicFormHelp => RunCharacter?.Id == Characters.Mimic.Id && HelpCharacter?.Id != Characters.Mimic.Id;
/// <summary>The help the "?" button opens right now; null disables the button.</summary>
public CharacterHelpDef HelpDef => HelpFor( HelpCharacter, IsMimicFormHelp );
public string HelpTitle => HelpDef?.Title ?? HelpCharacter?.Name;
private static CharacterHelpDef HelpFor( CharacterDef character, bool mimicForm )
=> mimicForm ? character?.MimicFormHelp ?? character?.Help : character?.Help;
/// <summary>The character the OPEN help panel is showing — snapshotted at open time so a form change
/// can never swap the panel out from under the player mid-read. Resolved through partners too: a
/// Mimic in Twin 2's shape has a character id that isn't in <see cref="Characters.All"/>.</summary>
public CharacterDef CharacterHelpCharacter =>
Characters.TryGetIncludingPartners( _characterHelpCharacterId, out var character ) ? character : null;
public CharacterHelpDef CharacterHelpDef => HelpFor( CharacterHelpCharacter, _characterHelpIsMimicForm );
public string CharacterHelpTitle => CharacterHelpDef?.Title ?? CharacterHelpCharacter?.Name;
/// <summary>The open panel's header shows the partner sprite too — never for a Mimic form, which is one body.</summary>
public bool CharacterHelpShowsPartner => !_characterHelpIsMimicForm;
public bool ShowCharacterHelp => _characterHelpPhase != CharacterHelpPhase.Closed;
/// <summary>The help panel is up and owns input — the HUD hides its "?" button for this, the way
/// the options overlay takes the gear with it. Goes false again for the auto-dismiss fade, which is
/// when the nudge pulse (if still owed) first becomes visible to show where the help lives.</summary>
public bool IsCharacterHelpVisible => _characterHelpPhase == CharacterHelpPhase.Visible;
public bool IsCharacterHelpFading => _characterHelpPhase == CharacterHelpPhase.AutoDismissFading;
/// <summary>The "?" button's teaching pulse: real runs only (replays, editor test runs and debug
/// runs are exempt), and only while the profile still owes the nudge (see
/// <see cref="CharacterProgress.ShouldNudgeHelpButton"/> for the arm/retire rules).</summary>
public bool ShouldPulseHelpButton => !IsReplay && !IsTest && !IsDebugRun
&& CharacterProgress.ShouldNudgeHelpButton;
public int CharacterHelpAnimationStep => _characterHelpAnimationStep / 5;
public int CharacterHelpFastAnimationStep => _characterHelpAnimationStep;
public int CharacterHelpRevision => _characterHelpRevision;
private enum TutorialPromptPhase
{
None,
Visible,
Dismissing,
}
private bool _tutorialPromptsEnabled;
private bool _tutorialSecondPromptShown;
private float _tutorialSecondPromptDelay = -1f;
private TutorialPromptPhase _tutorialPromptPhase;
private bool _tutorialPromptHoldArmed; // a fresh press AFTER the popup appeared started this hold
private bool _tutorialPromptPointerHeld; // mouse button held on the popup (GameHud mousedown/mouseup)
private float _tutorialPromptHoldTime;
private float _tutorialPromptDismissTimer;
private string _tutorialPromptText;
private int _tutorialPromptRevision;
public bool ShowTutorialPrompt => _tutorialPromptPhase != TutorialPromptPhase.None;
public bool IsTutorialPromptVisible => _tutorialPromptPhase == TutorialPromptPhase.Visible;
public bool IsTutorialPromptDismissing => _tutorialPromptPhase == TutorialPromptPhase.Dismissing;
public string TutorialPromptText => _tutorialPromptText;
public int TutorialPromptRevision => _tutorialPromptRevision;
/// <summary>Fill (0..1) of the popup's hold-to-dismiss bar; GameHud draws off this. Forced full on
/// dismissal (including click-dismiss) so the bar reads complete through the fade.</summary>
public float TutorialPromptHoldProgress => Math.Clamp( _tutorialPromptHoldTime / TUTORIAL_PROMPT_HOLD_TIME, 0f, 1f );
/// <summary>Elapsed scored run time in seconds. Stops when the run enters its game-over beat.</summary>
public float GameTime => _gameTime;
// Screenshake (camera offset).
private float _shakeH, _shakeV;
private bool _shakeHPos, _shakeVPos;
private const float CAM_SHAKE_RECOVERY = 0.85f;
private const float CAM_SHAKE_STRENGTH = 0.66f;
// Hit-stop / impact-freeze (diverges from original): strong impacts (a hard bounce, a wall dive)
// request a brief freeze of the whole gameplay sim for a few fixed steps, selling the impact. It is
// a deterministic step counter (sim state), driven by deterministic gameplay events, so a replay
// reproduces the freeze for free without recording it (see GameManager's frozen-step handling).
// Everything freezes during a hit-stop, including the camera shake (it just holds its last offset).
private int _hitStopFrames;
// Deadly-spike warning blink, shared across all blocks.
private bool _spikeBlink;
private float _spikeBlinkTimer;
private const float SPIKE_BLINK_TIME_ON = 0.10f;
private const float SPIKE_BLINK_TIME_OFF = 0.25f;
public GameStage( GameManager manager ) : base( manager )
{
OptionsController = new OptionsMenuController { OnBack = CloseMenu };
}
public GameStage( GameManager manager, bool isReplay ) : this( manager )
{
IsReplay = isReplay;
}
// --- accessors the Player/Block ports rely on ---------------------------------------
public List<Block> GetBlocks() => Blocks;
public List<Fireball> GetFireballs() => Fireballs;
public List<Bullet> GetBullets() => Bullets;
public List<BlockBulletProjectile> GetBlockBullets() => BlockBullets;
public List<Teardrop> GetTeardrops() => Teardrops;
public List<Obstacle> GetObstacles() => _obstacles;
public List<Obstacle> GetFences() => _fences;
/// <summary>The static solids from a HAZARD's point of view: interior walls + the Twin statue.
/// Skips GLASS (solid only to players) — lasers, bullets, fireballs, teardrops, fields and
/// player-launched projectiles all pass through it.</summary>
public SolidObstacleEnumerable GetSolidObstacles() => new( this, includeFences: false, includeGlass: false );
/// <summary>Static solids for particles: ordinary obstacles and glass, never fences.</summary>
public IEnumerable<Obstacle> GetParticleSolidObstacles() => _obstacles;
/// <summary>The static solids from a BLOCK's point of view: <see cref="GetSolidObstacles"/> plus
/// the fences (still no glass — blocks slide through panes). Block movement/placement paths (the
/// base mover, slip, wisp float, hunter planning, teleport/summon destinations) use this.</summary>
public SolidObstacleEnumerable GetBlockSolidObstacles() => new( this, includeFences: true, includeGlass: false );
/// <summary>The static solids from a PLAYER's point of view: interior walls, GLASS panels and the
/// Twin statue (no fences). Used by code that plans around the player's own collision (the AI
/// input source); Player.cs itself iterates <see cref="GetObstacles"/> directly with its
/// per-character wall gate.</summary>
public SolidObstacleEnumerable GetPlayerSolidObstacles() => new( this, includeFences: false, includeGlass: true );
/// <summary>Everything treated as a static solid: the authored interior obstacles plus any living
/// hardened player (the Twin statue) — plus, for the block-solid view only, the fences. An
/// allocation-free struct enumerable — the collision probes
/// call this several times per entity per tick, and the iterator method it replaces allocated its
/// state machine on every call. Still implements IEnumerable so a cold path can hold it as one
/// (BlockLaser.BeamBlockers), at the cost of boxing there.
///
/// This is a hand-written replacement for a `yield return` iterator — same sequence, but the
/// state machine is a stack struct instead of a per-call heap object. Same idiom as
/// List<T>.Enumerator: foreach duck-types onto the public struct GetEnumerator (no
/// allocation); the explicit interface methods exist only for interface-typed callers. The
/// "what is solid" rule itself lives entirely in Enumerator.MoveNext.</summary>
public readonly struct SolidObstacleEnumerable : IEnumerable<Entity2D>
{
private readonly GameStage _stage;
private readonly bool _includeFences;
private readonly bool _includeGlass;
internal SolidObstacleEnumerable( GameStage stage, bool includeFences, bool includeGlass )
{
_stage = stage;
_includeFences = includeFences;
_includeGlass = includeGlass;
}
public Enumerator GetEnumerator() => new( _stage, _includeFences, _includeGlass );
IEnumerator<Entity2D> IEnumerable<Entity2D>.GetEnumerator() => GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
// Shared empty tail for the non-fence views, so MoveNext stays a single unconditional walk.
private static readonly List<Obstacle> NoFences = new();
public struct Enumerator : IEnumerator<Entity2D>
{
private readonly List<Obstacle> _obstacles;
private readonly List<Obstacle> _fences; // empty unless the block-solid view was requested
private readonly List<Player> _players;
private readonly bool _includeGlass; // glass rides inside _obstacles, flagged per entry
private int _index; // walks the obstacles, then the fences, then the players (filtered to living hardened)
internal Enumerator( GameStage stage, bool includeFences, bool includeGlass )
{
_obstacles = stage._obstacles;
_fences = includeFences ? stage._fences : NoFences;
_players = stage.Players;
_includeGlass = includeGlass;
_index = 0;
Current = null;
}
public Entity2D Current { get; private set; }
object System.Collections.IEnumerator.Current => Current;
public bool MoveNext()
{
while ( _index < _obstacles.Count )
{
Obstacle ob = _obstacles[_index++];
if ( ob.IsGlass && !_includeGlass )
continue; // glass is solid only to players — hazard/block views skip it
Current = ob;
return true;
}
if ( _index - _obstacles.Count < _fences.Count )
{
Current = _fences[_index++ - _obstacles.Count];
return true;
}
int solids = _obstacles.Count + _fences.Count;
while ( _index - solids < _players.Count )
{
Player player = _players[_index++ - solids];
if ( player.IsHardened && !player.IsDead )
{
Current = player;
return true;
}
}
return false;
}
void System.Collections.IEnumerator.Reset() => _index = 0;
public void Dispose() { }
}
}
// Predicates for the filtered player views below. Named static methods (the compiler caches the
// method-group delegate, so a view never allocates), NOT lambdas in static fields: a lambda parked
// in a static field has no counterpart after a hotload (see DailyLevelGenerator.BuildAltLayout).
private static bool LivingPlayerFilter( Player player ) => !player.IsDead && !player.IsHardened;
private static bool FriendlyCloneFilter( Player clone ) => clone.IsSwarmClone && !clone.IsDead;
/// <summary>Run players that are alive and not hardened — the bodies field effects and block
/// attacks target. Several block types foreach this every tick, so it's an allocation-free
/// struct enumerable rather than an iterator (same idiom as <see cref="SolidObstacleEnumerable"/>).</summary>
public FilteredPlayerEnumerable LivingPlayers => new( Players, LivingPlayerFilter );
/// <summary>Living swarm copies (friendly player bodies among the Impostors).</summary>
public FilteredPlayerEnumerable LivingFriendlyClones => new( Impostors, FriendlyCloneFilter );
/// <summary>A filtered, allocation-free view over a player list. foreach duck-types onto the
/// struct GetEnumerator (see the SolidObstacleEnumerable note); deliberately NOT IEnumerable —
/// every consumer foreaches it directly, and keeping the interface off makes an accidental
/// LINQ/boxing use a compile error instead of a hidden allocation.</summary>
public readonly struct FilteredPlayerEnumerable
{
private readonly List<Player> _players;
private readonly System.Func<Player, bool> _filter;
internal FilteredPlayerEnumerable( List<Player> players, System.Func<Player, bool> filter )
{
_players = players;
_filter = filter;
}
public Enumerator GetEnumerator() => new( _players, _filter );
public struct Enumerator
{
private readonly List<Player> _players;
private readonly System.Func<Player, bool> _filter;
private int _index;
internal Enumerator( List<Player> players, System.Func<Player, bool> filter )
{
_players = players;
_filter = filter;
_index = 0;
Current = null;
}
public Player Current { get; private set; }
public bool MoveNext()
{
while ( _index < _players.Count )
{
Player player = _players[_index++];
if ( _filter( player ) )
{
Current = player;
return true;
}
}
return false;
}
}
}
/// <summary>Nearest free (un-hardened) living body. Swarm copies count: a copy standing closer
/// than the authoritative player draws hunters, lasers, teleport landings and hostile-clone AI —
/// the swarm works as a decoy pool.</summary>
public Player ClosestLivingPlayer( Vector2 origin )
{
Player closest = null;
float closestDistanceSquared = float.MaxValue;
foreach ( var player in Players )
Consider( player );
foreach ( var clone in Impostors )
if ( clone.IsSwarmClone ) Consider( clone );
return closest;
void Consider( Player body )
{
if ( body.IsDead || body.IsHardened ) return;
Vector2 delta = body.Pos - origin;
float distanceSquared = delta.LengthSquared;
if ( distanceSquared < closestDistanceSquared )
{
closest = body;
closestDistanceSquared = distanceSquared;
}
}
}
/// <summary>Aim-target selection for blocks that should keep pressuring a hardened statue: any
/// free (un-hardened) living player anywhere always wins, no matter how much closer the statue
/// is; the nearest hardened body is returned only when no free player remains.</summary>
public Player ClosestTargetablePlayer( Vector2 origin )
{
Player free = ClosestLivingPlayer( origin );
if ( free != null ) return free;
Player closest = null;
float closestDistanceSquared = float.MaxValue;
foreach ( var player in Players )
{
if ( player.IsDead || !player.IsHardened ) continue;
Vector2 delta = player.Pos - origin;
float distanceSquared = delta.LengthSquared;
if ( distanceSquared < closestDistanceSquared )
{
closest = player;
closestDistanceSquared = distanceSquared;
}
}
return closest;
}
// --- effect hooks -------------------------------------------------------------------
/// <summary>Spawn the Swapper's persistent portal (see <see cref="Portal"/>). Stationary,
/// non-colliding and never expires; the swap ability holds the returned reference and moves it.</summary>
public Portal AddPortal( Vector2 pos )
{
var go = CreateChild( "Portal" );
var p = go.Components.Create<Portal>();
p.Stage = this;
p.Pos = pos;
p.CreateVisuals();
Portals.Add( p );
return p;
}
public void RemovePortal( Portal portal )
{
if ( portal is null ) return;
Portals.Remove( portal );
portal.GameObject?.Destroy();
}
/// <summary>Begin the delayed, phase-coloured arrival of a Summoner impostor.</summary>
public ImpostorPortal AddImpostorPortal( Vector2 pos, int phase )
{
var go = CreateChild( "Impostor Portal" );
var portal = go.Components.Create<ImpostorPortal>();
portal.Stage = this;
portal.Pos = pos;
portal.Setup( phase );
portal.CreateVisuals();
ImpostorPortals.Add( portal );
return portal;
}
/// <summary>Spawn a collectible coin (see <see cref="Coin"/>). Stationary and non-colliding;
/// consumes no authoritative RNG, so level coin spawns can't shift any other draw.</summary>
public Coin AddCoin( Vector2 pos )
{
var go = CreateChild( "Coin" );
var c = go.Components.Create<Coin>();
c.Stage = this;
c.Pos = pos;
c.CreateVisuals();
Coins.Add( c );
return c;
}
/// <summary>Bank a coin pickup (called by <see cref="Coin.Tick"/> the tick it's collected), and
/// award the clean-sweep achievement if that was the level's LAST coin.</summary>
public void OnCoinCollected()
{
CoinsCollected++;
// The sweep achievement lands on the final coin, not at game over: the feat is the collecting,
// so whether the run then ends in a death or a win is irrelevant. Real runs only — a replay (or
// the hijacked practice run that shares its stage), an editor test-play and a debug daily run
// unlock nothing. AUTHORED levels are out entirely, workshop and local alike: either way the
// player controls the coin layout, so a level papered with the bare minimum would hand this over
// in one lap. Small levels are out too (see Achievements.AllCoinsMinimum). Level.Coins is the
// AUTHORED total, which a collected coin doesn't shrink; Unlock is idempotent, so the condition
// staying true afterwards is harmless.
int levelCoins = Level?.Coins?.Count ?? 0;
if ( !IsReplay && !IsTest && !IsDebugRun && Level?.IsWorkshop != true && Level?.IsLocal != true
&& levelCoins >= Achievements.AllCoinsMinimum && CoinsCollected >= levelCoins )
Achievements.AwardAllCoinsCollected();
}
/// <summary>Spawn the "+N" popup a collected coin leaves behind (see <see cref="CoinFloater"/>).</summary>
public CoinFloater AddCoinFloater( Vector2 coinPos )
{
var go = CreateChild( "CoinFloater" );
var f = go.Components.Create<CoinFloater>();
f.CreateVisuals( coinPos );
CoinFloaters.Add( f );
return f;
}
public SpriteRenderer CreateMimicChoiceSprite( CharacterDef character, out MimicChoicePulse pulse )
{
var go = CreateChild( "MimicChoice" );
var sprite = SpriteLayer.Add( go, character.SpritePath, character.ArtSize, "idle" );
sprite.GameObject.WorldPosition = new Vector3( 0f, 0f, Globals.DepthToZ( Globals.DEPTH_MIMIC_CHOICE ) );
pulse = sprite.GameObject.Components.Create<MimicChoicePulse>();
pulse.Setup( sprite, character.ArtSize );
return sprite;
}
public Particle AddParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size )
{
// Depth is a purely COSMETIC layer pick (which particle plane it draws on) with zero gameplay
// effect, so it MUST come from the cosmetic stream. AddParticle is shared by both player-
// triggered emitters (blood/dust, which use the cosmetic stream for everything they control)
// and block-triggered ones; rolling this on the authoritative stream would let an incidental
// visual advance the sim's RNG and shift every subsequent block decision — defeating the two-
// stream split documented in Rng.cs.
int depth = (Rng.CosmeticInt( 0, 3 ) == 0) ? Globals.DEPTH_PARTICLE_1 : Globals.DEPTH_PARTICLE_0;
var go = CreateChild( "Particle" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( vel, decel, gravity, kind, lifetime, size );
p.CreateVisuals();
Particles.Add( p );
return p;
}
/// <summary>Colliding and bouncing cosmetic fragment with an exact caller-provided tint.</summary>
public Particle AddPhysicsParticle( Vector2 pos, Vector2 vel, float decel, float gravity, Color color, float lifetime, int size )
{
int depth = (Rng.CosmeticInt( 0, 3 ) == 0) ? Globals.DEPTH_PARTICLE_1 : Globals.DEPTH_PARTICLE_0;
var go = CreateChild( "PhysicsParticle" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( vel, decel, gravity, color, lifetime, size );
// Death fragments pinned in a sliver gap (block vs block/obstacle/wall) may phase out of the
// pinning block instead of vibrating invisibly until they expire — see Particle.EnablePinEscape.
p.EnablePinEscape();
p.CreateVisuals();
Particles.Add( p );
return p;
}
/// <summary>Non-colliding cosmetic fragment with an exact caller-provided tint and depth.</summary>
public Particle AddColoredParticle( Vector2 pos, Vector2 vel, float decel, Color color, float lifetime, int size, int depth, bool translucent = false )
{
var go = CreateChild( "ColoredParticle" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( vel, decel, 0f, color, lifetime, size, collides: false, bounces: false );
p.CreateVisuals( translucent );
Particles.Add( p );
return p;
}
/// <summary>A particle that steers toward a target (Spikey's spike-send burst). When
/// <paramref name="anchor"/> is non-null, <paramref name="targetOffset"/> is relative to it
/// and tracks it; otherwise it's an absolute world point.</summary>
public TargetedParticle AddTargetedParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size, Vector2 targetOffset, float targetAccel, Entity2D anchor )
{
// Cosmetic layer pick — see AddParticle: never draw this from the authoritative stream.
int depth = (Rng.CosmeticInt( 0, 3 ) == 0) ? Globals.DEPTH_PARTICLE_1 : Globals.DEPTH_PARTICLE_0;
var go = CreateChild( "TargetedParticle" );
var p = go.Components.Create<TargetedParticle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( vel, decel, gravity, kind, lifetime, size );
p.SetupTarget( targetOffset, targetAccel, anchor );
p.CreateVisuals();
Particles.Add( p );
return p;
}
/// <summary>Non-colliding targeted particle with an exact caller-provided tint and depth.</summary>
public TargetedParticle AddColoredTargetedParticle( Vector2 pos, Vector2 vel, float decel, Color color,
float lifetime, int size, int depth, Vector2 targetOffset, float targetAccel, Entity2D anchor,
bool translucent = false )
{
var go = CreateChild( "ColoredTargetedParticle" );
var p = go.Components.Create<TargetedParticle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( vel, decel, 0f, color, lifetime, size, collides: false, bounces: false );
p.SetupTarget( targetOffset, targetAccel, anchor );
p.CreateVisuals( translucent );
Particles.Add( p );
return p;
}
/// <summary>Wrap-portal flash (the wrap character's edge/obstacle wrap): a stationary square that
/// rapidly shrinks away, spawned at both ends of a wrap (the caller layers a dark square and a
/// smaller light highlight per portal, on separate depth layers). Unlike <see cref="AddParticle"/>
/// it draws NO Rng at all (the wrap is sim code — a random depth pick would advance the
/// authoritative stream and desync replays) and sits on a fixed caller-chosen layer over everything
/// but text, so it reads even where the wrap point is buried in a wall or out of bounds (which is
/// also why it neither collides nor bounces — the walls must not eject it).</summary>
public Particle AddPortalParticle( Vector2 pos, float lifetime, int size, ParticleKind kind, int depth )
{
var go = CreateChild( "PortalParticle" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( Vector2.Zero, 1f, 0f, kind, lifetime, size, collides: false, bounces: false );
p.CreateVisuals();
Particles.Add( p );
return p;
}
/// <summary>Cosmetic wind-lane particle: like <see cref="AddParticle"/> but (a) picks its depth
/// from the COSMETIC Rng stream so emitting one every tick never advances the authoritative sim
/// Rng (which would desync replays), and (b) is non-colliding AND non-bouncing so it flies dead
/// straight through the tunnel. The caller (<see cref="BlockWind"/>) spawns it anywhere along the
/// lane and sets a short lifetime that expires by the time it reaches the column's blocker.</summary>
public Particle AddWindParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size )
{
// Fixed at the low particle layer so wind dust reads ON TOP of the lane's translucent overlay
// (which sits just below this layer) but still behind the blocks/player/entities.
int depth = Globals.DEPTH_PARTICLE_0;
var go = CreateChild( "WindParticle" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = depth;
p.Pos = pos;
p.Setup( vel, decel, gravity, kind, lifetime, size, collides: false, bounces: false );
p.CreateVisuals();
Particles.Add( p );
return p;
}
/// <summary>A translucent, non-colliding "field mote" — used for the reverse-gravity field's ambient
/// upward drift and its enter/leave puff. Like <see cref="AddWindParticle"/> but renders with a smooth
/// alpha blend (AlphaCutoff 0) so a mostly-transparent colour actually shows. Cosmetic depth pick, so
/// emitting one every tick never advances the authoritative Rng.</summary>
public Particle AddFieldParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size )
{
var go = CreateChild( "FieldParticle" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = Globals.DEPTH_PARTICLE_0;
p.Pos = pos;
p.Setup( vel, decel, gravity, kind, lifetime, size, collides: false, bounces: false );
p.CreateVisuals( translucent: true );
Particles.Add( p );
return p;
}
/// <summary>Create a stage-level overlay quad (a tintable pixel sprite) for effects like the wind
/// lane tint. Parented to the stage root — NOT a moving entity — so the caller drives its world
/// position / size / colour / enabled directly each tick (mirrors how the playfield/backdrop rects
/// are placed). Starts disabled.</summary>
public SpriteRenderer CreateOverlaySprite()
{
var go = CreateChild( "WindOverlay" );
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", new Vector2( 1, 1 ), "idle" );
// SpriteRenderer.AlphaCutoff defaults to 0.5 and DISCARDS any pixel whose alpha is below it — that
// was the hard "invisible under 0.5" cutoff. Zero it so the overlay can be any alpha (true smooth
// translucency), and keep Opaque off so it alpha-blends rather than dithers.
sr.Opaque = false;
sr.AlphaCutoff = 0f;
sr.Enabled = false;
return sr;
}
/// <summary>Destroy an overlay sprite made by <see cref="CreateOverlaySprite"/>. The renderer sits on
/// a layer child INSIDE the "WindOverlay" wrapper (see SpriteLayer.Add), so the wrapper is what must
/// be destroyed — destroying only the renderer's own GameObject would leak an empty wrapper each time.</summary>
public static void DestroyOverlaySprite( SpriteRenderer sr )
=> sr?.GameObject?.Parent?.Destroy();
public Fireball AddFireball( Vector2 pos, Vector2 vel, bool bouncing )
{
var go = CreateChild( "Fireball" );
var f = go.Components.Create<Fireball>();
f.Stage = this;
f.Depth = Globals.DEPTH_FIREBALL;
f.Pos = pos;
f.Setup( vel, bouncing );
f.CreateVisuals();
Fireballs.Add( f );
return f;
}
/// <summary>Spawn a Gunner <see cref="Bullet"/> travelling in <paramref name="direction"/> from
/// <paramref name="pos"/>; its first collision step sweeps from <paramref name="sweepOrigin"/> (the
/// shooter's centre) so nothing between shooter and muzzle is skipped. Lives in its own list (never
/// touched by the wind/magnet lanes) and is reaped when it dies. See <see cref="Bullet"/> for its
/// impact behaviour.</summary>
public Bullet AddBullet( Vector2 pos, Vector2 direction, Vector2 sweepOrigin )
{
var go = CreateChild( "Bullet" );
var b = go.Components.Create<Bullet>();
b.Stage = this;
b.Depth = Globals.DEPTH_FIREBALL;
b.Pos = pos;
b.Setup( direction, sweepOrigin );
b.CreateVisuals();
Bullets.Add( b );
return b;
}
/// <summary>Spawn a Swapper bolt at <paramref name="pos"/>; its first collision step sweeps from
/// <paramref name="sweepOrigin"/> (the caster's centre). See <see cref="AddBullet"/>.</summary>
public SwapperProjectile AddSwapperProjectile( Vector2 pos, Vector2 direction, Vector2 sweepOrigin, Portal portal, float speed, int impactHitStopFrames, float portalSurfacePadding )
{
var go = CreateChild( "SwapperProjectile" );
var projectile = go.Components.Create<SwapperProjectile>();
projectile.Stage = this;
projectile.Depth = Globals.DEPTH_FIREBALL;
projectile.Pos = pos;
projectile.Setup( direction, sweepOrigin, portal, speed, impactHitStopFrames, portalSurfacePadding );
projectile.CreateVisuals();
SwapperProjectiles.Add( projectile );
return projectile;
}
public void RemoveSwapperProjectiles( Portal portal )
{
foreach ( SwapperProjectile projectile in SwapperProjectiles )
if ( ReferenceEquals( projectile.TargetPortal, portal ) ) projectile.Dead = true;
}
public BlockBulletProjectile AddBlockBullet( Vector2 pos, Vector2 direction, BlockBulletVariant variant )
{
var go = CreateChild( "BlockBullet" );
var bullet = go.Components.Create<BlockBulletProjectile>();
bullet.Stage = this;
bullet.Depth = BlockBulletProjectile.IsBouncing( variant ) ? Globals.DEPTH_FIREBALL : Globals.DEPTH_WALL;
bullet.Pos = pos;
bullet.Setup( direction, variant );
bullet.CreateVisuals();
BlockBullets.Add( bullet );
return bullet;
}
public Teardrop AddTeardrop( Vector2 pos, Vector2 vel, Block creatingBlock, TeardropVariant variant = TeardropVariant.Standard )
{
var go = CreateChild( "Teardrop" );
var t = go.Components.Create<Teardrop>();
t.Stage = this;
t.Depth = variant == TeardropVariant.Piercing
? Globals.DEPTH_PIERCING_TEARDROP
: Globals.DEPTH_TEARDROP;
t.Pos = pos;
t.Setup( vel, creatingBlock, variant );
t.CreateVisuals();
Teardrops.Add( t );
return t;
}
public void AddHorizontalScreenshake( float amount ) { _shakeH += amount; }
public void AddVerticalScreenshake( float amount ) { _shakeV += amount; }
// --- hit-stop (impact freeze) -------------------------------------------------------
/// <summary>True while the sim is frozen for hit-stop; <see cref="GameManager"/> consumes frozen
/// steps (advancing the counter) instead of ticking the sim.</summary>
public bool HitStopActive => _hitStopFrames > 0;
/// <summary>Request a hit-stop of <paramref name="frames"/> fixed steps. Takes the longer of any
/// overlapping requests so multiple impacts in one step don't stack into an over-long freeze.</summary>
public void RequestHitStop( int frames )
{
if ( frames > _hitStopFrames ) _hitStopFrames = frames;
}
/// <summary>Consume one frozen step. Called by <see cref="GameManager"/> in place of a sim tick
/// while <see cref="HitStopActive"/>; nothing else advances (deterministic, no Rng consumed).</summary>
public void TickHitStop()
{
if ( _hitStopFrames > 0 ) _hitStopFrames--;
}
/// <summary>Cancel any pending hit-stop freeze immediately. Used by the replay single-step transport
/// so a frame-step tap always advances a real sim frame instead of being swallowed by the freeze.
/// Safe for determinism: hit-stop only gates HOW MANY frozen display steps play, never the sim state
/// (positions are a pure function of the fed recorded frames, keyed on the recorded-frame index).</summary>
public void ClearHitStop() => _hitStopFrames = 0;
// --- wall-face spikes (phase-2 hazard, sent by Spikey) ------------------------------
// A "wall face" is a spikeable edge: the arena boundary PLUS each interior obstacle's outward
// sides. A boundary SIDE can be several faces: an obstacle sitting flush against an arena edge
// SPLITS that edge into independent segments (e.g. the C-shape's floor becomes a left-channel and
// a right-channel face), so each spikes on its own. Arena faces carry ArenaSide; the player /
// wrap ability query them position-aware (WallDeadlyAt) so they hit the right segment.
private readonly List<WallFace> _faces = new(); // all faces (arena boundary + obstacle)
private const float ARENA_SPIKE_ADD_TIME = 0.44f;
private const float ARENA_SPIKE_RETRACT_TIME = 0.5f;
/// <summary>Does <paramref name="pos"/> fall within a face's extent along its wall (X-extent for a
/// horizontal wall, Y-extent for a vertical one)? A full-width boundary face covers everything.</summary>
private static bool CoversAlong( WallFace f, Direction side, Vector2 pos )
{
if ( side == Direction.Left || side == Direction.Right )
return pos.y >= f.Segment.YMin && pos.y <= f.Segment.YMax;
return pos.x >= f.Segment.XMin && pos.x <= f.Segment.XMax;
}
/// <summary>The boundary face on <paramref name="side"/> covering <paramref name="pos"/> (the segment
/// under the player / block), or null. For an unsplit side there's one full-width face.</summary>
public WallFace FindArenaFace( Direction side, Vector2 pos )
{
foreach ( var f in _faces )
if ( f.Owner is null && f.ArenaSide == side && CoversAlong( f, side, pos ) )
return f;
return null;
}
// Boundary spike query, position-aware so a split side hits the right segment. A SINGLE "deadly
// now" predicate: spikes present AND settled (not mid grow-in / retract). Symmetric with
// ObstacleFaceDeadlyAt and Block.SideDeadly so no caller has to remember to AND a "has spikes"
// flag with a "not switching" flag — the asymmetry that let bug #6 slip the wrap gate through a
// transitioning face.
public bool WallDeadlyAt( Direction side, Vector2 pos )
{
var f = FindArenaFace( side, pos );
return f is not null && f.SpikesPresent && !f.Switching;
}
/// <summary>The obstacle's face with the given outward normal covering <paramref name="pos"/>
/// along the side, or null. Position-aware like the boundary queries: a side that another
/// obstacle sits flush against is SPLIT into segments (and the shared seam has no face at all),
/// so the same (owner, normal) pair can name several independent faces. CoversAlong keys the
/// extent axis off Left/Right-vs-Up/Down identically for a side and a normal, so passing the
/// normal is correct here (a Left-normal face is vertical → Y extent).</summary>
public WallFace FindObstacleFace( Obstacle ob, Direction normal, Vector2 pos )
{
foreach ( var f in _faces )
if ( f.Owner == ob && f.Normal == normal && CoversAlong( f, normal, pos ) )
return f;
return null;
}
/// <summary>True if the obstacle's face with this normal at this position has live (deadly,
/// non-switching) spikes — a single "deadly now" predicate, symmetric with <see cref="WallDeadlyAt"/>
/// and <c>Block.SideDeadly</c>. Used for the player's kill-on-contact and the wrap character's
/// crossing gate.</summary>
public bool ObstacleFaceDeadlyAt( Obstacle ob, Direction normal, Vector2 pos )
{
var f = FindObstacleFace( ob, normal, pos );
return f is not null && f.SpikesPresent && !f.Switching;
}
/// <summary>Contact-span variant of <see cref="ObstacleFaceDeadlyAt"/> for a body resolved FLUSH
/// with a face plane: true if any live-spiked obstacle face with this normal lies on the rect's
/// contacted side and overlaps the rect's extent along the face's tangent axis. The centre-point
/// query under-killed at face ENDS — a body standing on a spiked top's last pixels with its
/// centre hung past the corner was "covered" by no segment and survived. Keyed on the contact
/// LINE rather than a resolved owner so a seam between flush obstacles is airtight too: a
/// partially-spiked surface is authored as flush rects with one spiked side, and an owner-keyed
/// query would test whichever rect the unpenetrate loop happened to resolve first — a body
/// straddling the seam could stand with its toes on the neighbour's teeth. Same plane + tangent
/// overlap means resting on that face; both are strict, so a body exactly flush with a segment's
/// END (zero tangent overlap) is beside the face, not on it.</summary>
public bool ObstacleFaceDeadlyForRect( Direction normal, RectF rect )
{
const float PLANE_EPS = 0.01f; // flush placement is float-exact; distinct face planes sit whole pixels apart
bool verticalFace = normal == Direction.Left || normal == Direction.Right;
float min = verticalFace ? rect.Bottom : rect.Left;
float max = verticalFace ? rect.Top : rect.Right;
// The rect side the face touches: resolved out LEFT of a Left-normal face → our Right, etc.
float contact = normal switch
{
Direction.Left => rect.Right,
Direction.Right => rect.Left,
Direction.Down => rect.Top,
_ => rect.Bottom,
};
foreach ( var f in _faces )
{
if ( f.Owner is null || f.Normal != normal || !f.SpikesPresent || f.Switching ) continue;
float plane = verticalFace ? f.Segment.XMin : f.Segment.YMin; // face lines are axis-aligned on edgeCoord
if ( plane < contact - PLANE_EPS || plane > contact + PLANE_EPS ) continue;
bool overlaps = verticalFace
? max > f.Segment.YMin && min < f.Segment.YMax
: max > f.Segment.XMin && min < f.Segment.XMax;
if ( overlaps ) return true;
}
return false;
}
/// <summary>Graft deadly spikes onto any face for <paramref name="time"/> seconds. Plays the grow-in
/// animation; the face is "switching" (harmless) until it lands.</summary>
public void AddSpikesToFace( WallFace f, float time )
{
if ( f is null || f.SpikesPresent ) return;
f.SpikesPresent = true;
f.Timer = time;
f.Trans = WallTrans.Adding;
f.TransTimer = ARENA_SPIKE_ADD_TIME;
f.Switching = true;
f.Play( "add" );
Audio.PlaySfx( SfxType.SpikesAdd );
}
private void RetractFace( WallFace f )
{
f.Trans = WallTrans.Retracting;
f.TransTimer = ARENA_SPIKE_RETRACT_TIME;
f.Switching = true;
f.Play( "retract" );
Audio.PlaySfx( SfxType.SpikesRetract );
}
private void TickArenaSpikes( float dt )
{
foreach ( var f in _faces )
{
if ( f.Permanent ) continue; // level-authored spikes never transition, retract, or time out
if ( f.Trans != WallTrans.None )
{
f.TransTimer -= dt;
if ( f.TransTimer <= 0f )
{
if ( f.Trans == WallTrans.Adding )
{
f.Switching = false; // spikes are now live/deadly
f.Play( "spiked" );
}
else // Retracting
{
f.SpikesPresent = false;
f.Switching = false;
f.Play( "plain" );
}
f.Trans = WallTrans.None;
}
continue; // don't count down the live-spike timer while switching
}
if ( f.SpikesPresent )
{
f.Timer -= dt;
if ( f.Timer <= 0f )
RetractFace( f );
}
}
}
/// <summary>Deadly-spike warning flash, in lock-step with the block spikes.</summary>
private void BlinkWallSpikes( bool blinkOn )
{
foreach ( var f in _faces )
{
// Permanent (level-authored) spikes are a static hazard — they hold steady, no warning flash.
if ( !f.SpikesPresent || f.Switching || f.Permanent ) continue;
f.Play( blinkOn ? "spiked_alt" : "spiked" );
}
}
private static readonly Dictionary<BlockType, string> BlockSprites = new()
{
[BlockType.Dragon] = "sprites/blocks/dragon.sprite",
[BlockType.Squid] = "sprites/blocks/squid.sprite",
[BlockType.SquidPierce] = "sprites/blocks/squidpierce.sprite",
[BlockType.Smile] = "sprites/blocks/smile.sprite",
[BlockType.Spikey] = "sprites/blocks/spikey.sprite",
[BlockType.Sad] = "sprites/blocks/sad.sprite",
[BlockType.SadPierce] = "sprites/blocks/sadpierce.sprite",
[BlockType.Wind] = "sprites/blocks/wind.sprite", // custom airy gust art baked by tools/bake.py (bake_wind)
[BlockType.Magnet] = "sprites/blocks/magnet.sprite", // custom purple attractor art baked by tools/bake.py (bake_magnet)
[BlockType.Sticky] = "sprites/blocks/sticky.sprite", // pink recolour baked by tools/bake.py
[BlockType.Shade] = "sprites/blocks/shade.sprite", // custom spectral art baked by tools/bake.py (bake_shade)
[BlockType.Stasis] = "sprites/blocks/stasis.sprite", // custom icy time-freeze art baked by tools/bake.py (bake_stasis)
[BlockType.Shockwave] = "sprites/blocks/shockwave.sprite", // custom teal pulse-ring emitter art baked by tools/bake.py (bake_shockwave)
[BlockType.Slicer] = "sprites/blocks/slicer.sprite", // custom gunmetal laser-emitter art baked by tools/bake.py (bake_slicer)
[BlockType.Mimic] = "sprites/blocks/mimic.sprite", // custom shape-shifter art baked by tools/bake.py (bake_mimic)
[BlockType.Teleport] = "sprites/blocks/teleport.sprite", // custom warp/blink art baked by tools/bake.py (bake_teleport)
[BlockType.Summoner] = "sprites/blocks/summoner.sprite", // custom sinister crimson clone-summoner art baked by tools/bake.py (bake_summoner)
[BlockType.Hunter] = "sprites/blocks/hunter.sprite", // custom predatory rust-red stalker art baked by tools/bake.py (bake_hunter)
[BlockType.Siren] = "sprites/blocks/siren.sprite", // custom rose/gold singer art baked by tools/bake.py (bake_siren)
[BlockType.Wisp] = "sprites/blocks/wisp.sprite", // custom spectral mint floater art baked by tools/bake.py (bake_wisp)
[BlockType.Venom] = "sprites/blocks/venom.sprite", // custom toxic acid-trail art baked by tools/bake.py (bake_venom)
[BlockType.Reverse] = "sprites/blocks/reverse.sprite", // custom violet anti-gravity art baked by tools/bake.py (bake_reverse)
[BlockType.Laser] = "sprites/blocks/laser.sprite", // cool-blue Squid-derived laser-emitter art baked by tools/bake.py
[BlockType.Bullet] = "sprites/blocks/bullet.sprite", // cyan/gold Dragon-derived marksman art baked by tools/bake.py
[BlockType.DragonBounce] = "sprites/blocks/dragonbounce.sprite", // green/violet Dragon-derived ricochet art baked by tools/bake.py
[BlockType.LaserPierce] = "sprites/blocks/laserpierce.sprite", // coral/gold Squid-derived piercing-laser art baked by tools/bake.py
[BlockType.StraightLaser] = "sprites/blocks/straightlaser.sprite", // emerald/lime cardinal laser-emitter art baked by tools/bake.py
};
protected override void OnEnter()
{
// The run context (set in GameManager.ApplyPendingStage, for live runs and replays alike)
// names the level; everything below spawns from its definition.
var context = Manager.CurrentRunContext;
Level = context.Level;
Audio.PlayLevelMusic( Level );
_runCharacterId = context.Character.Id; // resolved id (never null/unknown), so RunCharacter always finds it again
if ( !IsReplay && !IsTest )
Leaderboard.PrimePersonalBests( context );
_levelWasBeatenOnEntry = !IsReplay && !IsTest && !context.IsDaily
&& LevelProgress.IsBeaten( Level?.Id );
_tutorialPromptsEnabled = !IsReplay && !IsTest && !context.IsDaily
&& Level?.Id == Levels.TutorialId && !LevelProgress.IsBeaten( Levels.TutorialId );
if ( _tutorialPromptsEnabled )
ShowTutorialInstruction( "TOUCH BLOCK SIDES" );
TryAutoOpenCharacterHelp();
// The song's run-progression pitch (starts at normal, rises as blocks phase up — see
// Block.NextPhase) is reset centrally on every stage entry in GameManager.ApplyPendingStage, so a
// run always begins at the base pitch here; ScaleMusicProgressionToLevel (below, once the blocks
// exist) reports this level's phase-up count so its eased climb ends where every other level's does.
SpawnBackgroundBlocks();
SpawnArena();
CreateHud();
// TEMP DEBUG: single block bottom-left heading right, player riding on top, to make the
// moving-platform edge/handoff cases easy to reproduce. Flip DEBUG_SINGLE_BLOCK to restore
// the normal 5-block top row. The repro test levels are exempt: their whole point is their
// exact authored layout, so their ConCmds must work even with this override on.
if ( DEBUG_STARTING_BLOCKS && !TestLevels.IsTestLevel( Level?.Id ) )
{
// ---------- Two blocks, bottom-left and bottom-right, heading horizontally toward each other for collision/handoff tests.
const float BLOCK_HALF = 20f;
var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF );
SpawnBlock( blockPos, BlockType.Dragon );
Blocks[0].DebugSetMoveDirection( Direction.Right );
//// Second block bottom-right, heading left (so the two can meet for collision/handoff tests).
//var blockPos2 = new Vector2( Arena.WIDTH - Arena.WALL_SIZE - BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF );
//SpawnBlock( blockPos2, BlockType.Sticky );
//Blocks[1].DebugSetMoveDirection( Direction.Left );
var blockPos2 = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 60 );
SpawnBlock( blockPos2, BlockType.Sticky );
Blocks[1].DebugSetMoveDirection( Direction.Right );
// ---------- Two blocks, bottom-left and top-right, heading vertically toward each other for collision/handoff tests.
//const float BLOCK_HALF = 20f;
//var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 30 );
//SpawnBlock( blockPos, BlockType.Dragon );
//Blocks[0].DebugSetMoveDirection( Direction.Up );
//// Second block bottom-right, heading left (so the two can meet for collision/handoff tests).
//var blockPos2 = new Vector2( Arena.WALL_SIZE + BLOCK_HALF + 30, Arena.HEIGHT - BLOCK_HALF );
//SpawnBlock( blockPos2, BlockType.Squid );
//Blocks[1].DebugSetMoveDirection( Direction.Down );
// ----------
//const float BLOCK_HALF = 20f;
//var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 39f );
//SpawnBlock( blockPos, BlockType.Dragon );
//Blocks[0].DebugSetMoveDirection( Direction.Right );
//var blockPos1b = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 79f );
//SpawnBlock( blockPos1b, BlockType.Sad );
//Blocks[1].DebugSetMoveDirection( Direction.Right );
// Second block bottom-right, heading left (so the two can meet for collision/handoff tests).
//var blockPos2 = new Vector2( Arena.WIDTH - Arena.WALL_SIZE - BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF );
//SpawnBlock( blockPos2, BlockType.Squid );
//Blocks[1].DebugSetMoveDirection( Direction.Left );
//const float BLOCK_HALF = 20f;
//var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF + 40, Arena.WALL_SIZE + BLOCK_HALF );
//SpawnBlock( blockPos, BlockType.Sticky );
//Blocks[0].DebugSetMoveDirection( Direction.Left);
//var blockPos2 = new Vector2( Arena.WIDTH - Arena.WALL_SIZE - BLOCK_HALF - 30, Arena.WALL_SIZE + BLOCK_HALF + 30f + 10f );
//SpawnBlock( blockPos2, BlockType.Sticky );
//Blocks[1].DebugSetMoveDirection( Direction.Right );
// Sit the player on the first block's top (block top = blockPos.y + half).
var onBlock = new Vector2( blockPos.x, blockPos.y + BLOCK_HALF + Player.COLLISION_SIZE.y / 2f );
SpawnRunPlayers( onBlock );
ScaleMusicProgressionToLevel();
return;
}
// Spawn the level's blocks. ResolveSpawns draws from the freshly-seeded authoritative Rng
// (pool picks / position subset / type shuffle), so a replay — which reseeds and re-enters
// with the same level id — reproduces the identical layout.
Level.ResolveSpawns( out var types, out var positions, out var configs, out var abilityTimers, out var directions, out var turnModes );
for ( int i = 0; i < types.Count && i < positions.Count; i++ )
SpawnBlock( positions[i], types[i], configs[i], abilityTimers[i], directions[i], turnModes[i] );
if ( positions.Count < types.Count )
Log.Warning( $"[BlockParty] level '{Level.Id}': {types.Count} blocks but only {positions.Count} spawn positions — {types.Count - positions.Count} not spawned." );
// Blocks exist (with their authored starting phases applied), so the music's per-phase-up pitch
// step can be sized to this level.
ScaleMusicProgressionToLevel();
// Multi-spawn levels draw the player's start from the run Rng, AFTER ResolveSpawns (the draw
// order is replay-sensitive). Exactly two Twins spawns are ordered; with three or more they
// draw distinct random starts. Single-spawn levels consume NO extra draw.
var playerSpawns = Level.PlayerSpawns;
bool useOrderedTwinSpawns = RunCharacter.Partner is not null && playerSpawns is { Count: 2 };
int playerSpawnIndex = useOrderedTwinSpawns ? 0 : playerSpawns is { Count: > 1 } ? Rng.Int( 0, playerSpawns.Count ) : 0;
var playerSpawn = playerSpawns is { Count: > 0 } ? playerSpawns[playerSpawnIndex] : new Vector2( 16, 40 );
Vector2? partnerSpawn = null;
if ( RunCharacter.Partner is not null && playerSpawns is { Count: > 1 } )
{
if ( useOrderedTwinSpawns )
{
partnerSpawn = playerSpawns[1];
}
else
{
int partnerOffset = Rng.Int( 1, playerSpawns.Count );
partnerSpawn = playerSpawns[(playerSpawnIndex + partnerOffset) % playerSpawns.Count];
}
}
SpawnRunPlayers( playerSpawn, partnerSpawn );
// Level-authored coins. Spawning draws NO authoritative Rng, so this can't shift the
// spawn/shuffle draws above (old replays keep reproducing).
if ( Level?.Coins is not null )
foreach ( var coinPos in Level.Coins )
AddCoin( coinPos );
// Repro test levels do their scripted setup (forced block directions, state resets) here;
// a no-op for every real level.
TestLevels.OnStageEnter( this );
SpawnVisionOccluder();
// A level with an authored tutorial ghost loops it as a faded movement demo. Created after
// every Rng-consuming spawn above and never touching the sim, so replays are unaffected.
var ghostClip = Ghosts.Get( Level?.Id );
if ( ghostClip?.DecodePoses() is { Count: > 0 } ghostPoses )
_ghost = new GhostPlayback( this, ghostClip, ghostPoses );
SpawnBackgroundInfo();
// Authored starts can leave EVERY block already maxed. SetInitialPhase fires no phase-up, so
// nothing would ever trigger the win, and the only exit (dying) would tally as a victory since
// the outcome is read from final block phases. Decide such a run as won the moment it begins.
if ( TryBeginVictory() )
Log.Warning( $"[BlockParty] level '{Level?.Id}': every block starts at max phase — run won at start." );
}
// ---- persistent background info -------------------------------------------------------------
// Some levels paint a persistent cosmetic hint onto the arena background: world sprites in
// front of the playfield and the drifting cosmetic blocks, behind every gameplay layer. Purely
// decorative — no Rng, no sim state — so replays are unaffected. Keyed by level id; extend the
// switch as more levels want one.
private const float BACKGROUND_INFO_ALPHA = 0.4f;
/// <summary>Key art is 14px (a 1px transparent rim around a 12px cap); 16px pitch leaves a 2px
/// visual gap between neighbouring caps, matching the replay HUD's spaced key boxes.</summary>
private const float BACKGROUND_INFO_KEY_PITCH = 16f;
private void SpawnBackgroundInfo()
{
switch ( Level?.Id )
{
case "tutorial":
// "MOVE" top middle, the arrow keys under it in the replay-HUD input-display
// formation (Up on top, Left/Down/Right in a row below).
float cx = Arena.WIDTH / 2f;
AddBackgroundInfoSprite( "sprites/ui/word_move.sprite", new Vector2( cx, 228 ), new Vector2( 31, 7 ) );
var keySize = new Vector2( 14, 14 );
AddBackgroundInfoSprite( "sprites/ui/arrow_key_up.sprite", new Vector2( cx, 212 ), keySize );
AddBackgroundInfoSprite( "sprites/ui/arrow_key_left.sprite", new Vector2( cx - BACKGROUND_INFO_KEY_PITCH, 196 ), keySize );
AddBackgroundInfoSprite( "sprites/ui/arrow_key_down.sprite", new Vector2( cx, 196 ), keySize );
AddBackgroundInfoSprite( "sprites/ui/arrow_key_right.sprite", new Vector2( cx + BACKGROUND_INFO_KEY_PITCH, 196 ), keySize );
break;
}
}
/// <summary><paramref name="artSize"/> is the art's real pixel dimensions — used both to land
/// the sprite's edges on the logical pixel grid and to build the square quad size the engine's
/// aspect-fit rule needs for non-square art (see SpriteLayer notes).</summary>
private void AddBackgroundInfoSprite( string spritePath, Vector2 center, Vector2 artSize )
{
var go = CreateChild( "BackgroundInfo" );
Vector2 aligned = SpriteLayer.PixelAlignedCenter( center, artSize );
go.WorldPosition = new Vector3( aligned.x, aligned.y, Globals.DepthToZ( Globals.DEPTH_BACKGROUND_INFO ) );
float longEdge = MathF.Max( artSize.x, artSize.y );
var sr = SpriteLayer.Add( go, spritePath, new Vector2( longEdge, longEdge ), "idle" );
sr.Opaque = false; // translucent rendering, like the tutorial ghost
sr.AlphaCutoff = 0f;
sr.Color = Color.White.WithAlpha( BACKGROUND_INFO_ALPHA );
}
// ---- Mimic block transformation -----------------------------------------------------------
// Disguise pool: EVERY block type except Mimic (a mimic never disguises as itself). Derived from the
// enum so any block type added later is AUTOMATICALLY a possible disguise -- no hand-maintained list
// to keep in sync. Enum.GetValues returns declaration order (stable), so the pick stays deterministic
// for replays (reordering the enum would change picks -> bump Sim.VERSION if that ever happens).
private static readonly BlockType[] MimicDisguisePool = BuildMimicDisguisePool();
// Never a PHASE-2 disguise (Sim.MIMIC_P2_EXCLUSIONS). Still fine at phase 1.
// Changing this set changes the pick's Rng range -> bump Sim.VERSION + gate.
private static readonly HashSet<BlockType> MimicPhase2Excluded = new()
{
BlockType.StraightLaser, // permanent four-way beam cross
BlockType.SquidPierce, // 10s rotating beam through everything
};
private static BlockType[] BuildMimicDisguisePool()
{
var all = (BlockType[])System.Enum.GetValues( typeof( BlockType ) );
var pool = new List<BlockType>( all.Length );
foreach ( var t in all )
if ( t != BlockType.Mimic )
pool.Add( t );
return pool.ToArray();
}
private HashSet<BlockType> _levelBlockTypes; // types this level specifies (mimic exclusion set)
private readonly List<BlockType> _mimicCandidates = new();
/// <summary>The block types this level specifies (its exact Blocks list and/or random Pool). A mimic
/// only ever disguises as a type NOT in this set, so it always introduces a type "not from this level".</summary>
private HashSet<BlockType> LevelBlockTypes()
{
if ( _levelBlockTypes != null ) return _levelBlockTypes;
_levelBlockTypes = new HashSet<BlockType>();
if ( Level?.Blocks != null ) foreach ( var t in Level.Blocks ) _levelBlockTypes.Add( t );
if ( Level?.Pool != null ) foreach ( var t in Level.Pool ) _levelBlockTypes.Add( t );
return _levelBlockTypes;
}
/// <summary>Create the concrete Block component for a type (shared by the initial spawn and the mimic
/// transform swap).</summary>
private static Block CreateBlockComponent( GameObject go, BlockType type ) => type switch
{
BlockType.Dragon => go.Components.Create<BlockDragon>(),
BlockType.Squid => go.Components.Create<BlockSquid>(),
BlockType.SquidPierce => go.Components.Create<BlockSquidPierce>(),
BlockType.Spikey => go.Components.Create<BlockSpikey>(),
BlockType.Smile => go.Components.Create<BlockSmile>(),
BlockType.Sad => go.Components.Create<BlockSad>(),
BlockType.SadPierce => go.Components.Create<BlockSadPierce>(),
BlockType.Wind => go.Components.Create<BlockWind>(),
BlockType.Magnet => go.Components.Create<BlockMagnet>(),
BlockType.Sticky => go.Components.Create<BlockSticky>(),
BlockType.Shade => go.Components.Create<BlockShade>(),
BlockType.Stasis => go.Components.Create<BlockStasis>(),
BlockType.Shockwave => go.Components.Create<BlockShockwave>(),
BlockType.Slicer => go.Components.Create<BlockSlicer>(),
BlockType.Mimic => go.Components.Create<BlockMimic>(),
BlockType.Teleport => go.Components.Create<BlockTeleport>(),
BlockType.Summoner => go.Components.Create<BlockSummoner>(),
BlockType.Hunter => go.Components.Create<BlockHunter>(),
BlockType.Siren => go.Components.Create<BlockSiren>(),
BlockType.Wisp => go.Components.Create<BlockWisp>(),
BlockType.Venom => go.Components.Create<BlockVenom>(),
BlockType.Reverse => go.Components.Create<BlockReverse>(),
BlockType.Laser => go.Components.Create<BlockLaser>(),
BlockType.Bullet => go.Components.Create<BlockBullet>(),
BlockType.DragonBounce => go.Components.Create<BlockDragonBounce>(),
BlockType.LaserPierce => go.Components.Create<BlockLaserPierce>(),
BlockType.StraightLaser => go.Components.Create<BlockStraightLaser>(),
_ => go.Components.Create<Block>(),
};
/// <summary>Cloud puff for the Mimic block's transform + aura, drawn ABOVE the blocks/player (its own
/// particle plane) so the "poof" reads on top. Cosmetic: drifts, fades, and neither collides nor bounces.</summary>
public Particle AddMimicCloud( Vector2 pos, Vector2 vel, ParticleKind kind, float lifetime, int size, Color? color = null )
{
var go = CreateChild( "MimicCloud" );
var p = go.Components.Create<Particle>();
p.Stage = this;
p.Depth = Globals.DEPTH_PARTICLE_1; // above blocks (2) and player (3)
p.Pos = pos;
if ( color.HasValue )
p.Setup( vel, 0.92f, 0f, color.Value, lifetime, size, collides: false, bounces: true );
else
p.Setup( vel, 0.92f, 0f, kind, lifetime, size, collides: false, bounces: true );
p.CreateVisuals();
Particles.Add( p );
return p;
}
/// <summary>A burst of cloud puffs above a transforming mimic, plus a soft warp cue. Cosmetic.</summary>
public void SpawnMimicCloudBurst( Vector2 pos, bool playSound = true, bool compact = false, Color? color = null )
{
if ( playSound ) Audio.PlaySfx( SfxType.TurnInvisible, pos, 0.6f, 1.1f );
int count = compact ? 10 : 22;
for ( int i = 0; i < count; i++ )
{
var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
float ang = Rng.CosmeticFloat( 0f, MathF.PI * 2f );
float speed = compact ? Rng.CosmeticFloat( 8f, 20f ) : Rng.CosmeticFloat( 18f, 55f );
float upwardBias = compact ? 6f : 22f;
var vel = new Vector2( MathF.Cos( ang ) * speed, MathF.Sin( ang ) * speed + upwardBias );
float jitterRange = compact ? 2f : 20f;
var jitter = new Vector2( Rng.CosmeticFloat( -jitterRange, jitterRange ), Rng.CosmeticFloat( -jitterRange, jitterRange ) );
float lifetime = compact ? Rng.CosmeticFloat( 0.35f, 0.55f ) : Rng.CosmeticFloat( 0.6f, 1.0f );
int size = compact ? Rng.CosmeticInt( 4, 7 ) : Rng.CosmeticInt( 9, 15 );
AddMimicCloud( pos + jitter, vel, kind, lifetime, size, color );
}
}
public void SpawnMimicChoiceSparkles( Vector2 pos )
{
const int count = 8;
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * i / count;
var radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
ParticleKind kind = (i & 1) == 0 ? ParticleKind.MimicWow0 : ParticleKind.MimicWow1;
AddParticle( pos + radial * Rng.CosmeticFloat( 4f, 7f ), radial * Rng.CosmeticFloat( 14f, 28f ),
0.92f, 0f, kind, Rng.CosmeticFloat( 0.3f, 0.5f ), Rng.CosmeticInt( 2, 5 ) );
}
}
/// <summary>Swap any mimic block that has reached a new phase for a real disguise type, spawned at
/// that phase. Deferred to here (after the block tick loop) so the swap never destroys a block mid-
/// tick. Skipped once the run is over (a phase-2 mimic that just triggered the win is left as-is).</summary>
private void ProcessMimicTransforms()
{
if ( _gameOver ) return;
List<Block> pending = null;
foreach ( var b in Blocks )
{
if ( b.Dead || b.Mimic == null ) continue;
if ( b.Phase > b.Mimic.TransformedThroughPhase )
(pending ??= new List<Block>()).Add( b );
}
if ( pending == null ) return;
foreach ( var b in pending ) TransformMimic( b );
}
private void TransformMimic( Block old )
{
var mimic = old.Mimic;
int phase = old.Phase; // the phase it just reached (1 or 2)
BlockType disguise = PickMimicType( mimic, phase );
if ( phase == 1 ) mimic.Phase1Type = disguise;
mimic.TransformedThroughPhase = phase;
var pos = old.Pos;
var dir = old.MoveDirection;
int stageIndex = old.StageIndex;
int listIndex = Blocks.IndexOf( old );
float abilityInterval = old.AbilityInterval;
float abilityRandomness = old.AbilityRandomness;
float abilityClosedTime = old.AbilityClosedTime;
// Cloud "poof" above the block, then destroy the old instance and spawn the real disguise at the
// same spot/phase, carrying the mimic memory + heading forward. Stage-parented overlays (lane
// tints, trails, rings, lines) do NOT die with the block's GameObject — release them explicitly
// or they stay frozen on screen (with their gameplay effect gone) for the rest of the run.
SpawnMimicCloudBurst( pos );
old.DestroyStageVisuals();
old.GameObject?.Destroy();
var go = CreateChild( $"Block_{disguise}" );
Block b = CreateBlockComponent( go, disguise );
b.Stage = this;
b.StageIndex = stageIndex;
b.Mimic = mimic;
b.Setup( disguise, BlockSprites[disguise] );
b.Pos = pos;
b.CreateVisuals();
b.ConfigureAbilityTimer( abilityInterval, abilityRandomness, closedTime: abilityClosedTime );
b.ConfigureTurnMode( old.TurnMode );
b.SetInitialPhase( phase );
b.CopySpikesFrom( old ); // a spiked side no longer blocks phase-up, so live grafts must survive the swap
b.CopyStickyFrom( old ); // transferred goo likewise outlives the swap (keeps stuck players stuck)
b.CopyScoreStartFrom( old );
b.SetMoveDirection( dir );
if ( listIndex >= 0 ) Blocks[listIndex] = b;
else Blocks.Add( b );
// Deterministic handoff: engine IsValid() on the destroyed instance only flips when the queued
// delete flushes at the NEXT FRAME boundary — which never comes inside a one-frame re-sim
// (timeline scrub, hijack rebuild, replay_verify), so sim code polling it desynced those paths
// (glued to the old instance's frozen "phantom" rect). Mark the swap in sim terms and re-point
// every live reference to the disguise on this same tick instead.
old.Replaced = true;
foreach ( var p in Players ) p.OnBlockReplaced( old, b );
foreach ( var imp in Impostors ) imp.OnBlockReplaced( old, b );
foreach ( var blk in Blocks ) if ( blk != b ) blk.OnBlockReplaced( old, b );
foreach ( var t in Teardrops ) t.OnBlockReplaced( old, b );
}
/// <summary>Pick a disguise type: a curated block type NOT used by this level, never Mimic, and (for
/// the phase-2 transform) never the same type the phase-1 disguise used nor one of
/// <see cref="MimicPhase2Excluded"/>. Uses the AUTHORITATIVE Rng stream -- the chosen type changes
/// gameplay (each type behaves differently), so it is part of the deterministic sim.</summary>
private BlockType PickMimicType( MimicState mimic, int phase )
{
var levelTypes = LevelBlockTypes();
bool p2Exclusions = phase >= 2 && SimVersion >= Sim.MIMIC_P2_EXCLUSIONS;
_mimicCandidates.Clear();
foreach ( var t in MimicDisguisePool )
{
if ( levelTypes.Contains( t ) ) continue;
if ( phase >= 2 && t == mimic.Phase1Type ) continue;
if ( p2Exclusions && MimicPhase2Excluded.Contains( t ) ) continue;
_mimicCandidates.Add( t );
}
// Degenerate fallback (a level somehow reserved every disguise): relax the level exclusion, keep
// the hard rules (not Mimic / not the phase-1 type / not a phase-2 exclusion) so a valid type is
// always available.
if ( _mimicCandidates.Count == 0 )
foreach ( var t in MimicDisguisePool )
if ( (phase < 2 || t != mimic.Phase1Type) && !(p2Exclusions && MimicPhase2Excluded.Contains( t )) )
_mimicCandidates.Add( t );
return _mimicCandidates[Rng.Int( 0, _mimicCandidates.Count )];
}
/// <summary>Tell the music how many phase-ups this level has left to give, so the song's pitch climb
/// ends in the same place whether the level is one nearly-maxed block or eight fresh ones. Counts the
/// spawned blocks' remaining phases; mimic transforms carry their phase over, so the total holds for
/// the whole run.</summary>
private void ScaleMusicProgressionToLevel()
{
int phaseUps = 0;
foreach ( var b in Blocks )
phaseUps += Math.Max( 0, Block.NUM_PHASES - 1 - b.Phase );
Audio.ScaleMusicProgression( phaseUps );
}
private void SpawnBlock( Vector2 pos, BlockType type, BlockStart start = null, BlockAbilityTimer abilityTimer = null,
Direction startDirection = Direction.None, TurnMode turnMode = TurnMode.Free )
{
var go = CreateChild( $"Block_{type}" );
Block b = CreateBlockComponent( go, type );
b.Stage = this;
b.StageIndex = Blocks.Count; // spawn-order index; drives per-block lane-overlay Z (see Block.StageIndex)
if ( type == BlockType.Mimic ) b.Mimic = new MimicState();
b.Setup( type, BlockSprites[type] );
if ( startDirection != Direction.None ) b.SetMoveDirection( startDirection );
b.ConfigureTurnMode( turnMode );
b.ConfigureAbilityTimer( abilityTimer?.Interval ?? Block.DEFAULT_ABILITY_INTERVAL,
abilityTimer?.Randomness ?? 0f, abilityTimer?.StartOffset ?? 0f,
abilityTimer?.ClosedTime ?? Block.DEFAULT_ABILITY_CLOSED_TIME );
b.Pos = pos;
b.CreateVisuals();
// Optional authored starting phase / pre-pressed sides (see LevelDef.BlockStarts / PoolStartPhase).
// Must run after CreateVisuals (it drives the face / side-button sprites).
if ( start is not null && (start.Phase != 0 || (start.PressedSides is { Count: > 0 })) )
b.SetInitialPhase( start.Phase, start.PressedSides );
b.SetScoreStart( start?.Phase ?? 0, start?.PressedSides );
Blocks.Add( b );
}
/// <summary>Spawn the line-of-sight cover if this level opts in (obstacles flagged to block vision
/// and/or a vision-blocking block). The cover uses the level's resolved out-of-bounds colour so the
/// hidden area reads as "outside the arena". See <see cref="VisionOccluder"/>.</summary>
private void SpawnVisionOccluder()
{
bool hasExplicit = Level.VisionBlockers is { Count: > 0 };
bool blockBlocksVision = Blocks.Exists( b => b.BlocksVision );
// A Mimic block can transform into a vision-blocking Shade mid-run, so spawn the occluder up front
// whenever a mimic is present (it produces no cover until a real blocker actually exists).
bool mimicPresent = Blocks.Exists( b => b.Mimic != null );
if ( !hasExplicit && !blockBlocksVision && !mimicPresent )
return;
var go = CreateChild( "VisionOccluder" );
var vo = go.Components.Create<VisionOccluder>();
vo.PlayerSource = () => Player;
// Fill the hidden area with the level's direct wall colour so the cover reads as a wall
// occluding the view.
vo.Cover = WallColorLinear;
vo.BlockerSource = EnumerateVisionBlockers;
// The stage can render before this newly created component receives its first OnUpdate.
vo.RebuildGeometry();
}
/// <summary>The current vision blockers: the vision-flagged OBSTACLE rects (as
/// <see cref="VisionBlockMode.Umbra"/>), plus every vision-blocking BLOCK at its current
/// phase-dependent mode. Evaluated every frame by the occluder, so a moving block's rect and a
/// block phasing up are picked up live.</summary>
private void EnumerateVisionBlockers( List<VisionBlocker> into )
{
if ( Level.VisionBlockers is not null )
{
// Vision blockers are solid OBSTACLE rects. An obstacle face draws its band as an exact quad
// filling the WALL_SIZE strip just inside the collision edge, and its (spiked) teeth grow
// outward from that edge (see BuildTiledFace) — so casting the umbra from the exact edge
// would leave the whole band and the teeth uncovered, plus a faint colour seam on unspiked
// far faces. Inset the caster so the umbra base lands half a pixel INSIDE the band: cover and
// band are the same wall colour, so the overlap is seamless, and the margin keeps the base
// off the dark fill while the umbra swallows the obstacle's own far walls/spikes.
const float inset = Arena.WALL_SIZE - 0.5f;
foreach ( var r in Level.VisionBlockers )
into.Add( new VisionBlocker( new RectF( r.Left + inset, r.Bottom + inset, r.Right - inset, r.Top - inset ), VisionBlockMode.Umbra ) );
}
// Vision-blocking blocks contribute their current (phase-dependent) cover; a moving block's rect
// is read live each frame. Inset it ~1px so the cover sits just inside the block art — otherwise a
// 1px seam shows as the cover trails the moving block and at the block's rounded-corner pixels.
const float blockInset = 1f;
foreach ( var b in Blocks )
{
// A mimic disguise that reached a new phase but hasn't been swapped out yet (ProcessMimicTransforms
// runs later in the tick) would briefly expose its NEW phase's cover -- e.g. a phase-1 Shade hitting
// phase 2 flashing its spotlight for a frame before it becomes a different type. Skip a mimic that's
// mid-transform so that transient cover never renders.
if ( b.Mimic != null && b.Phase > b.Mimic.TransformedThroughPhase )
continue;
if ( b.VisionMode is VisionBlockMode mode )
{
RectF br = b.GetRect();
into.Add( new VisionBlocker( new RectF( br.Left + blockInset, br.Bottom + blockInset, br.Right - blockInset, br.Top - blockInset ), mode ) );
}
}
}
private Player SpawnPlayer( Vector2 pos, CharacterDef character )
{
var go = CreateChild( "Player" );
var p = go.Components.Create<Player>();
p.Stage = this;
p.Pos = pos;
// The run context names the character (art + movement feel); resolve it before building
// visuals so the correct sprite sheet and movement tuning are used.
p.Character = character;
p.CreateVisuals();
return p;
}
private void SpawnRunPlayers( Vector2 pos, Vector2? authoredPartnerPos = null )
{
CharacterDef character = RunCharacter;
var first = SpawnPlayer( pos, character );
Players.Add( first );
if ( character.Partner is not CharacterDef partner )
return;
var second = SpawnPlayer( authoredPartnerPos ?? FindPartnerSpawn( pos, first ), partner );
Players.Add( second );
_twinsController = new TwinsController( this, Players );
}
private Vector2 FindPartnerSpawn( Vector2 origin, Player first )
{
float dx = Player.COLLISION_SIZE.x + 2f;
float dy = Player.COLLISION_SIZE.y + 2f;
int maxRing = (int)MathF.Ceiling( MathF.Max( Arena.WIDTH / dx, Arena.HEIGHT / dy ) );
for ( int ring = 1; ring <= maxRing; ring++ )
{
Vector2[] preferredOffsets =
{
new( ring * dx, 0f ), new( -ring * dx, 0f ), new( 0f, ring * dy ), new( 0f, -ring * dy ),
new( ring * dx, ring * dy ), new( -ring * dx, ring * dy ),
new( ring * dx, -ring * dy ), new( -ring * dx, -ring * dy ),
};
foreach ( var offset in preferredOffsets )
{
Vector2 candidate = origin + offset;
if ( IsPlayerSpawnClear( candidate, first ) ) return candidate;
}
for ( int step = 1; step < ring; step++ )
{
Vector2[] edgeOffsets =
{
new( ring * dx, step * dy ), new( -ring * dx, step * dy ),
new( ring * dx, -step * dy ), new( -ring * dx, -step * dy ),
new( step * dx, ring * dy ), new( -step * dx, ring * dy ),
new( step * dx, -ring * dy ), new( -step * dx, -ring * dy ),
};
foreach ( var offset in edgeOffsets )
{
Vector2 candidate = origin + offset;
if ( IsPlayerSpawnClear( candidate, first ) ) return candidate;
}
}
}
Log.Warning( "[BlockParty] no clear partner spawn exists; spawning both twins at the authored player position." );
return origin;
}
private bool IsPlayerSpawnClear( Vector2 center, Player first )
{
float halfWidth = Player.COLLISION_SIZE.x / 2f;
float halfHeight = Player.COLLISION_SIZE.y / 2f;
var rect = new RectF( center.x - halfWidth, center.y - halfHeight, center.x + halfWidth, center.y + halfHeight );
if ( rect.Left < Arena.WALL_SIZE || rect.Right > Arena.WIDTH - Arena.WALL_SIZE
|| rect.Bottom < Arena.WALL_SIZE || rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
return false;
if ( rect.Intersects( first.GetRect() ) ) return false;
foreach ( var block in Blocks )
if ( !block.Dead && rect.Intersects( block.GetRect() ) ) return false;
foreach ( var obstacle in _obstacles )
if ( rect.Intersects( obstacle.GetRect() ) ) return false;
return !FallLandingDeadly( rect );
}
/// <summary>True if a body spawned at <paramref name="rect"/> would fall onto level-authored spikes.
/// The partner spawns in the air with no control, so the STATIC surface its fall column ends on must
/// be harmless: a spiked floor stretch or spiked obstacle top under any part of the rect rejects the
/// candidate. Blocks don't count as support — harmless at spawn, they move away and drop the twin
/// onto whatever waits beneath. Landing is the only spike kill in play here: spikes kill on relative
/// motion INTO them, and a spawn never touches a side/ceiling face (candidates keep a 1px wall gap,
/// and an uncontrolled fall only moves down).</summary>
private bool FallLandingDeadly( RectF rect )
{
// Sample the landing across the rect's span (corners nudged inward so a seam-exact edge doesn't
// probe the neighbouring face segment): a ledge landing can deflect either way, so EVERY column's
// own support must be safe, not just the highest one.
for ( int i = 0; i < 3; i++ )
{
float x = i switch
{
0 => rect.Left + 0.5f,
1 => (rect.Left + rect.Right) * 0.5f,
_ => rect.Right - 0.5f,
};
Obstacle support = null;
float supportTop = Arena.WALL_SIZE; // the arena floor backstops every column
foreach ( var obstacle in _obstacles )
{
RectF r = obstacle.GetRect();
if ( x < r.Left || x > r.Right ) continue;
if ( r.Top > rect.Bottom || r.Top < supportTop ) continue;
support = obstacle;
supportTop = r.Top;
}
var landing = new Vector2( x, supportTop );
bool deadly = support is null
? WallDeadlyAt( Direction.Down, landing )
: ObstacleFaceDeadlyAt( support, Direction.Up, landing );
if ( deadly ) return true;
}
return false;
}
/// <summary>Spawn a Summoner impostor (a hostile AI clone of the player) at <paramref name="pos"/>. It
/// uses Original's movement, its own baked phase sprite, an AI input brain, and joins the Impostors list so
/// it ticks and can be pushed / kill like the player.</summary>
public Player SpawnImpostor( Vector2 pos, int phase )
{
var go = CreateChild( "Impostor" );
var p = go.Components.Create<Player>();
p.Stage = this;
p.Pos = pos;
p.MakeImpostor( new AiInputSource() );
p.Character = BuildImpostorCharacter( phase );
p.CreateVisuals();
Impostors.Add( p );
return p;
}
public Player SpawnSwarmClone( Player owner, out Player anchor )
{
if ( !TryFindSwarmSpawn( owner, out Vector2 pos, out anchor ) ) return null;
var go = CreateChild( "Swarm Copy" );
var clone = go.Components.Create<Player>();
clone.Stage = this;
clone.Pos = pos;
clone.MakeSwarmClone( owner );
clone.SwarmTraceId = ++_swarmTraceNextId;
clone.Character = BuildSwarmCloneCharacter();
clone.CreateVisuals();
Player.RewindState state = owner.CaptureRewindState() with { Position = pos };
clone.RestoreRewindState( state );
if ( Rng.Float( 0f, 1f ) < SWARM_SPAWN_EXTRA_MOVE_CHANCE )
{
Vector2 outward = Utils.Normalized( pos - anchor.Pos );
Vector2 direction = Utils.RotateVector( outward, Rng.Float( -SWARM_SPAWN_EXTRA_MOVE_ANGLE, SWARM_SPAWN_EXTRA_MOVE_ANGLE ) );
clone.ApplySwarmRepel( direction, Rng.Float( SWARM_SPAWN_EXTRA_MOVE_MIN, SWARM_SPAWN_EXTRA_MOVE_MAX ) );
}
if ( owner.ReverseGravityFieldActive ) clone.ApplyReverseGravity();
clone.RefreshContactsAfterTeleport();
Impostors.Add( clone );
return clone;
}
// A new copy buds off a RANDOM existing swarm body (the player or any live clone), deliberately
// OVERLAPPING it: offset far enough to read as two bodies, close enough that the penetration stays
// at/above SWARM_LAUNCH_MIN_OVERLAP on both axes, so the same-frame ResolveSwarmRepel pass fires the
// launch and the newborn pops out in a random direction — multiplication feels unpredictable.
// Bounds are matched to the 8×10 body: |dx| ≤ 6 keeps overlapX ≥ 2, |dy| ≤ 8 keeps overlapY ≥ 2.
private const float SWARM_SPAWN_OFFSET_X_MIN = 3.5f;
private const float SWARM_SPAWN_OFFSET_X_MAX = 6.0f;
private const float SWARM_SPAWN_OFFSET_Y_MIN = 4.5f;
private const float SWARM_SPAWN_OFFSET_Y_MAX = 8.0f;
private const float SWARM_SPAWN_EXTRA_MOVE_CHANCE = 0.45f;
private const float SWARM_SPAWN_EXTRA_MOVE_ANGLE = 35f;
private const float SWARM_SPAWN_EXTRA_MOVE_MIN = 20f;
private const float SWARM_SPAWN_EXTRA_MOVE_MAX = 55f;
// Trace-only: stamps each bud with a stable label for `swarm_trace` (never read by the sim).
private int _swarmTraceNextId;
private bool TryFindSwarmSpawn( Player owner, out Vector2 position, out Player anchor )
{
var bodies = new List<Player>( 6 ) { owner };
foreach ( Player clone in Impostors )
if ( !clone.IsDead && clone.IsSwarmClone && ReferenceEquals( clone.SwarmOwner, owner ) )
bodies.Add( clone );
const int attempts = 24;
for ( int i = 0; i < attempts; i++ )
{
anchor = bodies[Rng.Int( 0, bodies.Count )];
float angle = Rng.Float( 0f, MathF.PI * 2f );
Vector2 offset = new(
MathF.Cos( angle ) * Rng.Float( SWARM_SPAWN_OFFSET_X_MIN, SWARM_SPAWN_OFFSET_X_MAX ),
MathF.Sin( angle ) * Rng.Float( SWARM_SPAWN_OFFSET_Y_MIN, SWARM_SPAWN_OFFSET_Y_MAX ) );
Vector2 candidate = anchor.Pos + offset;
if ( IsSwarmSpawnClear( owner, candidate ) )
{
position = candidate;
return true;
}
}
position = default;
anchor = null;
return false;
}
private bool IsSwarmSpawnClear( Player owner, Vector2 center )
{
float halfWidth = Player.COLLISION_SIZE.x / 2f;
float halfHeight = Player.COLLISION_SIZE.y / 2f;
var rect = new RectF( center.x - halfWidth, center.y - halfHeight, center.x + halfWidth, center.y + halfHeight );
if ( rect.Left <= Arena.WALL_SIZE || rect.Right >= Arena.WIDTH - Arena.WALL_SIZE
|| rect.Bottom <= Arena.WALL_SIZE || rect.Top >= Arena.HEIGHT - Arena.WALL_SIZE )
return false;
foreach ( var block in Blocks )
if ( !block.Dead && !block.PhasingIn && rect.Intersects( block.GetRect() ) ) return false;
foreach ( var obstacle in _obstacles )
if ( rect.Intersects( obstacle.GetRect() ) ) return false;
// A newborn deliberately overlaps its own swarm (that's what launches it) — only OTHER run
// players and hostile impostors block the spot.
foreach ( var player in Players )
if ( !player.IsDead && !ReferenceEquals( player, owner ) && rect.Intersects( player.GetRect() ) ) return false;
foreach ( var impostor in Impostors )
{
if ( impostor.IsDead ) continue;
if ( impostor.IsSwarmClone && ReferenceEquals( impostor.SwarmOwner, owner ) ) continue;
if ( rect.Intersects( impostor.GetRect() ) ) return false;
}
return true;
}
private static CharacterDef BuildSwarmCloneCharacter() => new()
{
Id = "swarm-copy",
Name = "SWARM COPY",
SpritePath = Characters.Swarm.SpritePath,
ArtSize = Characters.Swarm.ArtSize,
Abilities = Characters.Swarm.Abilities with { HasSwarm = false },
Movement = Characters.Swarm.Movement,
};
/// <summary>The impostor's character: Original's art + abilities, but a deliberately
/// FLOATIER movement feel — slower top speed, weaker jump, less ground friction and lighter gravity —
/// so the clone reads as a drifting menace. Built fresh each spawn so a live tuning edit (hotload)
/// takes effect on the next summon. The softer, faster-recovering wall-jump also lets it climb a wall
/// by repeatedly kicking off it (see AiInputSource wall-climb).</summary>
private static CharacterDef BuildImpostorCharacter( int phase ) => new()
{
Id = "impostor",
Name = "IMPOSTOR",
// Phase-1 slams summon the red clone; phase-2 slams the deeper crimson-magenta one. Each is a
// dedicated sprite (Assets/sprites/player_impostor_p{1,2}_sheet.png) so the art can be hand-edited.
SpritePath = phase >= 2 ? "sprites/player_impostor_p2.sprite" : "sprites/player_impostor_p1.sprite",
ArtSize = phase >= 2 ? new Vector2( 14f, 14f ) : Player.ART_SIZE,
Abilities = Characters.Original.Abilities,
Movement = phase >= 2 ? ImpostorMovementP2() : ImpostorMovementP1(),
Audio = new CharacterAudio
{
Jump = phase >= 2 ? SfxType.ImpostorJumpP2 : SfxType.ImpostorJumpP1,
// Own footstep events (not the shared player_walk) so a crowd's steps can be rate-limited
// in Audio without gating a fast real character's cadence.
Footstep = phase >= 2 ? SfxType.ImpostorWalkP2 : SfxType.ImpostorWalkP1,
PlayLandSfx = false,
},
};
/// <summary>Phase-2 impostor movement — the base "drifting menace": slow top speed, a weak jump,
/// light gravity, low ground friction, and a soft, fast-recovering wall-jump so it can climb a wall
/// by repeatedly kicking off it (see AiInputSource wall-climb).</summary>
private static CharacterMovement ImpostorMovementP2() => new()
{
MaxXSpeed = 42.0f, // slower than the player's 86
JumpPower = 62.0f, // weaker than 112
MaxRiseSpeed = 110.0f, // cap upward speed so chained wall-jumps can't stack into a rocket
HorizontalDeceleration = 210.0f, // less than 330 — slides / floats more
Gravity = 155.0f, // lighter than 300 — floatier fall
ActiveUpGravityFactor = 0.40f, // extra float while holding Up (0.5 default)
WallJumpVerticalPower = 96.0f, // matches the softer jump
WallJumpHorizontalPower = 48.0f, // smaller kick off the wall (70 default) so it stays near it
WallJumpTime = 0.45f, // friction recovers faster (1.0 default) so it returns to the wall to climb
};
/// <summary>Phase-1 impostor movement — an EVEN slower, floatier version of the phase-2 feel (a
/// gentler early menace): lower top speed and lighter gravity so it drifts toward the player rather
/// than rushing. Phase 2 keeps the punchier feel above.</summary>
private static CharacterMovement ImpostorMovementP1() => new()
{
MaxXSpeed = 20.0f, // MUCH slower than phase 2's 42 — a crawling drift
JumpPower = 50.0f, // weaker still
MaxRiseSpeed = 88.0f, // tight rise cap — no runaway wall-jump stacking
MaxFallSpeed = 120.0f, // capped descent so it drifts DOWN slowly (very floaty)
HorizontalDeceleration = 140.0f, // very low friction — long slippery glides
Gravity = 82.0f, // far lighter than phase 2's 155 — hangs in the air
ActiveUpGravityFactor = 0.24f, // lots of float while holding Up
WallJumpVerticalPower = 80.0f,
WallJumpHorizontalPower = 42.0f,
WallJumpTime = 0.45f,
};
/// <summary>Advance every secondary body, expire AI clones at the end of their configured lifetime,
/// and resolve hostile contact plus soft Swarm separation.</summary>
private void TickImpostors( float dt )
{
// Once the run is decided the impostors stop chasing/killing. Hostile clones celebrate a player
// death and pop instantly on a win. Swarm copies retire silently with their dead owner, but on a
// win they stay alive and keep mirroring input alongside the winner — hazards can still kill them
// normally during the beat.
if ( _gameOver )
{
foreach ( var imp in Impostors )
{
if ( imp.IsDead ) continue;
if ( imp.IsSwarmClone )
{
if ( _died ) imp.RetireSwarmCloneSilently();
else imp.Tick( dt );
}
else if ( _died )
{
imp.Ai.Celebrate( imp, dt );
imp.Tick( dt );
}
else
{
imp.ExpireImpostor(); // player won — pop instantly
}
}
// Keep the surviving swarm readable while it celebrates (a no-op when the owner died).
ResolveSwarmRepel();
return;
}
foreach ( var imp in Impostors )
{
if ( imp.IsDead ) continue;
if ( imp.IsSwarmClone )
{
imp.Tick( dt );
continue;
}
Player targetPlayer = ClosestTargetablePlayer( imp.Pos );
Vector2 target = targetPlayer is { IsDead: false }
? targetPlayer.Pos
: new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT / 2f );
imp.Ai.Think( imp, target, dt );
if ( imp.Ai.Expired ) { imp.ExpireImpostor(); continue; }
imp.Tick( dt );
}
// A live impostor kills the player only on a SUBSTANTIAL overlap (not a bare edge touch): the
// clone has to be well on top of them, with at least IMPOSTOR_KILL_OVERLAP px of overlap on BOTH axes.
foreach ( var player in Players )
{
if ( player.IsDead || player.IsHardened ) continue;
RectF pr = player.GetRect();
foreach ( var imp in Impostors )
{
if ( imp.IsDead || imp.IsSwarmClone ) continue;
RectF ir = imp.GetRect();
float ox = MathF.Min( pr.Right, ir.Right ) - MathF.Max( pr.Left, ir.Left );
float oy = MathF.Min( pr.Top, ir.Top ) - MathF.Max( pr.Bottom, ir.Bottom );
if ( ox >= IMPOSTOR_KILL_OVERLAP && oy >= IMPOSTOR_KILL_OVERLAP )
{
player.KilledByImpostor( imp.Pos );
break;
}
}
}
foreach ( var clone in Impostors )
{
if ( clone.IsDead || !clone.IsSwarmClone ) continue;
RectF cloneRect = clone.GetRect();
foreach ( var imp in Impostors )
{
if ( imp.IsDead || imp.IsSwarmClone ) continue;
RectF impRect = imp.GetRect();
if ( cloneRect.Intersects( impRect ) )
{
clone.KilledByImpostor( imp.Pos );
break;
}
}
}
ResolveSwarmRepel();
}
// Minimum penetration depth (along the separation axis) before an overlap counts as a real press
// and fires the swarm launch; shallower contact only feeds the soft depenetration channel.
private const float SWARM_LAUNCH_MIN_OVERLAP = 2.0f;
// Reused by ResolveSwarmRepel (it runs every tick; only rebuilt while a swarm actually exists).
private readonly List<Player> _swarmBodies = new();
private void ResolveSwarmRepel()
{
// No live swarm copies -> every bodies list below would be a single owner and the pair loop
// a no-op. Skip the whole pass (this runs every tick, for every character).
bool anySwarm = false;
foreach ( Player clone in Impostors )
if ( !clone.IsDead && clone.IsSwarmClone ) { anySwarm = true; break; }
if ( !anySwarm ) return;
foreach ( Player owner in Players )
{
if ( owner.IsDead ) continue;
List<Player> bodies = _swarmBodies;
bodies.Clear();
bodies.Add( owner );
foreach ( Player clone in Impostors )
if ( !clone.IsDead && clone.IsSwarmClone && ReferenceEquals( clone.SwarmOwner, owner ) )
bodies.Add( clone );
for ( int i = 0; i < bodies.Count; i++ )
{
for ( int j = i + 1; j < bodies.Count; j++ )
{
Player first = bodies[i];
Player second = bodies[j];
RectF firstRect = first.GetRect();
RectF secondRect = second.GetRect();
float overlapX = MathF.Min( firstRect.Right, secondRect.Right ) - MathF.Max( firstRect.Left, secondRect.Left );
float overlapY = MathF.Min( firstRect.Top, secondRect.Top ) - MathF.Max( firstRect.Bottom, secondRect.Bottom );
if ( overlapX <= 0f || overlapY <= 0f ) continue;
Vector2 delta = second.Pos - first.Pos;
Vector2 direction = overlapX <= overlapY
? new Vector2( delta.x >= 0f ? 1f : -1f, 0f )
: new Vector2( 0f, delta.y >= 0f ? 1f : -1f );
float strength = Math.Clamp( 12f + MathF.Min( overlapX, overlapY ) * 5f, 12f, 40f );
first.ApplySwarmRepel( -direction, strength );
second.ApplySwarmRepel( direction, strength );
// A substantial press (not a 1px graze) additionally fires the strong one-shot
// bounce — the pair splits apart hard, and landing on a copy works as a jump. Each
// body launches on its OWN cooldown: a ready body can still spring off a partner
// that just launched (chain-hopping across the swarm), the pop just lands one-sided.
if ( MathF.Min( overlapX, overlapY ) >= SWARM_LAUNCH_MIN_OVERLAP )
{
if ( first.SwarmLaunchReady ) first.ApplySwarmLaunch( -direction, second );
if ( second.SwarmLaunchReady ) second.ApplySwarmLaunch( direction, first );
}
}
}
}
}
// --- in-game HUD / options overlay --------------------------------------------------
/// <summary>Spawn the screen-space HUD (the gear button + the options overlay it toggles).</summary>
private void CreateHud()
{
var ui = CreateChild( "UI" );
var screen = ui.Components.Create<ScreenPanel>();
screen.ZIndex = 100;
_hud = ui.Components.Create<GameHud>();
_hud.Stage = this;
}
public void EnsureReactionSprites()
{
if ( !IsReplay || _reactionSprites.IsValid() ) return;
_reactionSprites = CreateChild( "ReactionSprites" ).Components.Create<ReplayReactionSprites>();
_reactionSprites.Stage = this;
}
public void ShowReactionTooltip( string name )
{
if ( !IsReplay ) return;
// Lazy creation also supports hotloading this feature into an already-open replay.
if ( !ReactionTooltip.IsValid() )
{
ReactionTooltip = CreateChild( "ReactionTooltip" ).Components.Create<ReplayReactionTooltip>();
ReactionTooltip.Stage = this;
}
ReactionTooltip.Show( name );
}
/// <summary>Open the in-game options overlay (gear button). Freezes the sim; shows the cursor.</summary>
public void OpenMenu()
{
if ( MenuOpen || ShowCharacterHelp || LeaveConfirmOpen ) return;
MenuOpen = true;
Audio.PlaySfx( SfxType.MenuStart );
}
/// <summary>Close the overlay and resume gameplay where it left off.</summary>
public void CloseMenu()
{
if ( !MenuOpen ) return;
MenuOpen = false;
}
/// <summary>Whether leaving should be confirmed first: a live, undecided, non-debug daily run on a
/// day with an attempt limit. The attempt was consumed at launch (RegisterAttemptStart), so bailing
/// early spends it with nothing to show. A decided run (game-over beat begun) banks its score on
/// the way out, so it leaves freely; unlimited days lose nothing by leaving.</summary>
public bool NeedsLeaveConfirm => !IsReplay && !IsTest && !IsDebugRun && !_gameOver && !_ended
&& Manager.CurrentRunContext.IsDaily
&& DailyLevels.Get( Manager.CurrentRunContext.DailyId )?.MaxAttempts is not null;
/// <summary>Intercept a Back/Home leave. Opens the confirm prompt (holding <paramref name="action"/>
/// for its LEAVE button) and returns true when <see cref="NeedsLeaveConfirm"/>; returns false so
/// the caller leaves immediately otherwise. A prompt already up swallows the request.</summary>
public bool TryOpenLeaveConfirm( LeaveAction action )
{
if ( LeaveConfirmOpen ) return true;
if ( action == LeaveAction.None || !NeedsLeaveConfirm ) return false;
_pendingLeave = action;
LeaveConfirmSelection = 0;
LeaveConfirmRevision++;
return true;
}
/// <summary>The prompt's LEAVE: carry out the intercepted Back/Home. Closes the prompt first so the
/// manager's leave path sees a plain live stage.</summary>
public void ConfirmLeave()
{
var action = _pendingLeave;
if ( action == LeaveAction.None ) return;
CloseLeaveConfirm();
Audio.PlaySfx( SfxType.MenuStart );
Haptics.MenuConfirm();
if ( action == LeaveAction.Home ) Manager.QuitToMenu();
else Manager.BackToMenu();
}
/// <summary>The prompt's KEEP PLAYING (also Back, and a click on the dim): resume where the run froze.</summary>
public void CancelLeave()
{
if ( !LeaveConfirmOpen ) return;
CloseLeaveConfirm();
Audio.PlaySfx( SfxType.MenuBlip );
}
/// <summary>Move the prompt's focus (mouse hover or a direction press). Blips only on a keyed change.</summary>
public void SetLeaveConfirmSelection( int selection, bool blip = false )
{
selection = selection == 0 ? 0 : 1;
if ( !LeaveConfirmOpen || selection == LeaveConfirmSelection ) return;
LeaveConfirmSelection = selection;
LeaveConfirmRevision++;
if ( blip ) Audio.PlaySfx( SfxType.MenuBlip );
}
private void CloseLeaveConfirm()
{
_pendingLeave = LeaveAction.None;
LeaveConfirmRevision++;
}
/// <summary>Attempts line for the leave prompt: what the day has left once this run is spent
/// (the merged counter already includes this run). Null when the day has no limit.</summary>
public string LeaveConfirmAttemptsText
{
get
{
var ctx = Manager.CurrentRunContext;
if ( !ctx.IsDaily || DailyLevels.Get( ctx.DailyId )?.MaxAttempts is not int max )
return null;
int left = System.Math.Max( 0, max - DailyChallengeProgress.DisplayedAttemptsUsed( ctx.DailyId ) );
return left == 0 ? "NO ATTEMPTS LEFT AFTER THIS" : $"{left}/{max} ATTEMPTS LEFT AFTER THIS";
}
}
public void OpenCharacterHelp()
{
if ( HelpDef is null || ShowCharacterHelp || MenuOpen || ShowTutorialPrompt || LeaveConfirmOpen )
return;
// One manual open anywhere (replays and test runs included — it teaches the same lesson)
// retires the button's nudge pulse for good.
CharacterProgress.MarkHelpButtonUsed();
ShowCharacterHelpOverlay( fadesOnDismiss: false );
Audio.PlaySfx( SfxType.MenuStart );
}
public void DismissCharacterHelp()
{
if ( _characterHelpPhase != CharacterHelpPhase.Visible )
return;
Audio.PlaySfx( SfxType.MenuBlip, volume: 0.7f, pitch: 1.25f );
if ( !_characterHelpFadesOnDismiss )
{
HideCharacterHelp();
return;
}
_characterHelpPhase = CharacterHelpPhase.AutoDismissFading;
_characterHelpFadeTimer = CHARACTER_HELP_FADE_TIME;
_characterHelpRevision++;
}
private void TryAutoOpenCharacterHelp()
{
if ( IsReplay || IsTest || IsDebugRun || RunCharacter?.Help is not { AutoShowOnFirstPlay: true }
|| CharacterProgress.HasSeenHelp( RunCharacter.Id ) )
return;
CharacterProgress.MarkHelpSeen( RunCharacter.Id );
ShowCharacterHelpOverlay( fadesOnDismiss: true );
}
private void ShowCharacterHelpOverlay( bool fadesOnDismiss )
{
_characterHelpCharacterId = HelpCharacter?.Id;
_characterHelpIsMimicForm = IsMimicFormHelp;
_characterHelpFadesOnDismiss = fadesOnDismiss;
_characterHelpFadeTimer = 0f;
_characterHelpAnimationTimer = 0f;
_characterHelpAnimationStep = 0;
_characterHelpPhase = CharacterHelpPhase.Visible;
_characterHelpRevision++;
}
private void HideCharacterHelp()
{
_characterHelpPhase = CharacterHelpPhase.Closed;
_characterHelpFadesOnDismiss = false;
_characterHelpFadeTimer = 0f;
_characterHelpRevision++;
}
/// <summary>While an in-game overlay owns input, simulation is frozen. The tutorial popup only
/// freezes it while VISIBLE: the moment its hold-to-dismiss bar fills the sim resumes, so the key
/// the player is already holding drives play through the popup's fade with no dead frame.</summary>
public override bool BlocksSimulation => MenuOpen || ShowCharacterHelp || IsTutorialPromptVisible || LeaveConfirmOpen;
/// <summary>Pump whichever overlay currently owns the frozen simulation.</summary>
public override void TickMenu( float dt )
{
if ( LeaveConfirmOpen )
{
// Two buttons side by side: Confirm activates the focused one, Back backs out of the prompt
// (a guard against leaving must never itself leave), and any direction edge flips the focus.
// Confirm/Back are resolved BEFORE the flip: Confirm shares Space/A with Jump, and a Jump
// press also raises the directional edge for its resolved direction (InputState.Sample), so
// with a sideways jump direction (wall cling/hug, Shifter on a wall, a wall-charge wind-up)
// one press carries ConfirmJust AND LeftJust/RightJust. Flipping first would move the focus
// from KEEP PLAYING onto LEAVE and then confirm it.
if ( InputState.ConfirmJust )
{
if ( LeaveConfirmSelection == 1 ) ConfirmLeave();
else CancelLeave();
return;
}
if ( InputState.BackJust )
{
CancelLeave();
return;
}
if ( InputState.LeftJust || InputState.RightJust || InputState.NavUpJust || InputState.NavDownJust )
SetLeaveConfirmSelection( 1 - LeaveConfirmSelection, blip: true );
return;
}
if ( ShowCharacterHelp )
{
_characterHelpAnimationTimer += dt;
while ( _characterHelpAnimationTimer >= CHARACTER_HELP_ANIMATION_STEP_TIME )
{
_characterHelpAnimationTimer -= CHARACTER_HELP_ANIMATION_STEP_TIME;
_characterHelpAnimationStep = (_characterHelpAnimationStep + 1) % CHARACTER_HELP_ANIMATION_WRAP;
}
if ( _characterHelpPhase == CharacterHelpPhase.Visible && InputState.ConfirmJust )
DismissCharacterHelp();
else if ( _characterHelpPhase == CharacterHelpPhase.AutoDismissFading )
{
_characterHelpFadeTimer -= dt;
if ( _characterHelpFadeTimer <= 0f )
HideCharacterHelp();
}
return;
}
if ( IsTutorialPromptVisible )
{
// Hold-to-dismiss: holding any game input (Space/WASD/arrows/controller — see
// PopupDismissHeld) fills the bar; releasing snaps it empty. Arms only on a FRESH press
// after the popup appeared (like hold-to-restart) — a key still held from play when the
// popup pops must not bleed into a dismissal the player never chose. A held mouse button
// on the popup counts too (its mousedown can only happen after the popup appeared, so
// it's a fresh press by construction). The Dismissing fade is NOT pumped here: it ticks
// in Tick(), because the sim is already live again by then (see BlocksSimulation).
if ( InputState.PopupDismissJust )
_tutorialPromptHoldArmed = true;
bool holding = (_tutorialPromptHoldArmed && InputState.PopupDismissHeld) || _tutorialPromptPointerHeld;
_tutorialPromptHoldTime = holding ? _tutorialPromptHoldTime + dt : 0f;
if ( _tutorialPromptHoldTime >= TUTORIAL_PROMPT_HOLD_TIME )
DismissTutorialInstruction();
return;
}
OptionsController.Tick( dt );
}
/// <summary>The mouse button went down/up on the popup (GameHud's mousedown/mouseup) — held, it
/// fills the dismiss bar exactly like a held key.</summary>
public void SetTutorialPromptPointerHeld( bool held ) => _tutorialPromptPointerHeld = held;
private void DismissTutorialInstruction()
{
if ( _tutorialPromptPhase != TutorialPromptPhase.Visible )
return;
_tutorialPromptPhase = TutorialPromptPhase.Dismissing;
_tutorialPromptHoldTime = TUTORIAL_PROMPT_HOLD_TIME; // bar reads full through the fade
_tutorialPromptDismissTimer = TUTORIAL_PROMPT_DISMISS_TIME;
_tutorialPromptPointerHeld = false;
_tutorialPromptRevision++;
Audio.PlaySfx( SfxType.MenuBlip, volume: 0.7f, pitch: 1.25f );
}
private void ShowTutorialInstruction( string text )
{
_tutorialPromptText = text;
_tutorialPromptHoldArmed = false;
_tutorialPromptPointerHeld = false;
_tutorialPromptHoldTime = 0f;
_tutorialPromptPhase = TutorialPromptPhase.Visible;
_tutorialPromptRevision++;
}
private void HideTutorialInstruction()
{
_tutorialPromptPhase = TutorialPromptPhase.None;
_tutorialPromptRevision++;
}
/// <summary>Called by the player when it dies. Death ends the run (after a short beat).</summary>
public void PlayerHasDied( Player player )
{
_twinsController?.PlayerDied( player );
// A death AFTER the run is decided (a still-flying block bullet or live wall spike during the
// victory beat) must not touch the outcome: it would deny the flawless Twins pairing, count a
// death against a won run, and re-arm the restart affordance over a win. _gameOver from a death
// means every player is already dead, so this only ever trips post-victory. The Twins hand-off
// above still runs so the surviving body keeps control through the beat.
if ( _gameOver ) return;
_anyPlayerDied = true;
foreach ( var candidate in Players )
if ( !candidate.IsDead ) return;
_died = true;
foreach ( var b in Blocks ) b.PlayerHasDied();
BeginGameOver( GAME_OVER_DELAY );
}
public void PlayerHardened( Player player )
{
_twinsController?.PlayerHardened( player );
}
/// <summary>Called when a block hits max phase; win once every block is max phase.</summary>
public void BlockReachedMaxPhase( Block block ) => TryBeginVictory();
/// <summary>Start the win beat if every block is at max phase and the run isn't already decided.
/// Returns true if the win began here.</summary>
private bool TryBeginVictory()
{
if ( _gameOver || Blocks.Count == 0 ) return false;
foreach ( var b in Blocks )
if ( b.Phase < Block.NUM_PHASES - 1 ) return false;
foreach ( var b in Blocks ) b.Die();
_victoryExplosionSfxTimer = 0f;
BeginGameOver( WIN_DELAY );
return true;
}
/// <summary>Arm the second Tutorial instruction after its block first reaches phase one.</summary>
public void BlockReachedPhase( Block block )
{
if ( !_tutorialPromptsEnabled || _tutorialSecondPromptShown || block?.Phase != 1 )
return;
_tutorialSecondPromptShown = true;
_tutorialSecondPromptDelay = TUTORIAL_SECOND_PROMPT_DELAY;
}
private void BeginGameOver( float delay )
{
if ( _gameOver ) return;
_gameOver = true;
_gameOverTimer = delay;
}
/// <summary>True once the run has entered its game-over beat (death/win) but before <see cref="EndGame"/>
/// has actually fired. The score is already locked in here (the beat is just a cosmetic countdown).</summary>
public bool IsGameOver => _gameOver;
/// <summary>Leave an editor TEST run and return to the editor. Driven by the Back key + the HUD exit
/// button; unattended deaths also return through <see cref="EndGame"/>. A no-op if this isn't a test
/// run or the run already concluded. Marks <c>_ended</c> so the game-over timer can't also fire a
/// restart after the user has chosen to leave.</summary>
public void ExitTest()
{
// Only exit once, and only when the manager can actually perform the return. If a stage wipe is
// still running (e.g. the start-test transition, right as the level appears), TransitionToStage
// silently ignores overlapping wipes — so setting _ended here without the return happening would
// permanently dead-lock the button: _ended stays true and every later click is rejected by the
// !_ended guard. Ignoring the click while transitioning lets a later click (once the wipe ends)
// exit normally.
if ( IsTest && !_ended && !Manager.IsTransitioning )
{
_ended = true;
Manager.ReturnFromTestPlay();
}
}
/// <summary>Whether a live run can restart now. Active runs may restart immediately with R; after
/// game-over, only a death remains restartable so a victory still proceeds to its score tally.
/// Replays and editor tests use their own restart paths. A daily run allows restarting
/// only while the day is still TODAY (a run held across UTC midnight can finish but not relaunch
/// on the closed board) and has attempts remaining (the restart consumes one — see
/// <see cref="GameManager.RestartRun"/>); a DEBUG daily restarts freely.</summary>
public bool CanRestart => (!_gameOver || _died) && !IsReplay && !IsTest
&& (!Manager.CurrentRunContext.IsDaily
|| IsDebugRun
|| (Manager.CurrentRunContext.DailyId == DailyChallenge.TodayId
&& DailyChallengeProgress.HasAttemptsRemaining( Manager.CurrentRunContext.DailyId,
DailyLevels.Get( Manager.CurrentRunContext.DailyId )?.MaxAttempts )) );
/// <summary>The on-screen restart affordance remains death-only; active-run restarts are an R shortcut.</summary>
public bool ShowRestartButton => _died && CanRestart;
/// <summary>Restart affordance for a hijacked practice run: R retries from the takeover point at any
/// time during a hijack, but the button appears only once this attempt has died — a visual reminder,
/// in the same letterbox spot as the live-run restart. An armed hijack point implies this stage is a
/// hijack practice run (arming is cleared whenever normal playback resumes).</summary>
public bool ShowHijackRestartButton => _died && Manager.HijackArmed;
/// <summary>A replay's recorded input ran out while the game-over beat was still counting down. The
/// original ended the beat either by the timer expiring or by the player skipping it with Confirm —
/// and a skip isn't reproducible (that input edge isn't recorded), so the timer-driven replay can't
/// reach <see cref="EndGame"/> on its own. Finish it now with the score that was already locked in at
/// game-over, so the determinism check verifies a real score instead of a spurious "input exhausted".</summary>
public void FinishGameOverForReplay()
{
// _gameOver: the death/win beat has begun (so the score is locked in). !_ended: EndGame hasn't
// fired yet. Both true == we're mid game-over and can conclude it now; otherwise do nothing
// (the run hadn't reached game-over, so the caller treats it as a real input-exhausted desync).
// The only other way EndGame can be called during a replay is when the post-gameover countdown expires.
if ( IsReplay && _gameOver && !_ended )
EndGame();
}
/// <summary>Snapshot the blocks and hand off to the animated score-tally stage.</summary>
private void EndGame()
{
if ( _ended ) return;
_ended = true;
// Transition "whoosh" as we leave the play field (original played this on the
// game -> score handoff, for both a win and a player death).
Audio.PlaySfx( SfxType.LeaveGame );
// Editor TEST run: an unattended death returns to the editor instead of repeatedly restarting and
// archiving empty-input replays. Once the player has interacted, death/win keeps the iteration loop.
if ( IsTest )
{
if ( _died && !RunRecorder.HasInput )
{
RunRecorder.Stop();
Manager.ReturnFromTestPlay();
return;
}
SaveTestReplayOnce();
Manager.RestartTestPlay();
return;
}
var results = BuildBlockResults();
// A replay just watches a recorded run — don't tally or submit a new score. Recompute the
// final score from the reproduced block states with the same deterministic function the
// original submit used (so any determinism drift is flagged), then freeze on this final
// frame instead of leaving: the viewer chooses to restart or go back from the replay HUD.
// This branch also covers a HIJACKED practice run (it runs in an IsReplay stage): it must
// never submit/tally either. NOTE: when achievements are added, any unlock hook must gate on
// !IsReplay so neither a plain replay nor a hijacked practice run can trigger them.
if ( IsReplay )
{
Manager.FinishReplay( ScoreCalc.Compute( results, _gameTime, CoinsCollected ), results );
return;
}
// Snapshot the context before submitting (SubmitRunOnce clears it) so the tally knows where to
// return afterwards (daily page vs high-score board).
var context = Manager.CurrentRunContext;
bool victory = ScoreCalc.AllMaxPhase( results );
bool firstTutorialVictory = !context.IsDaily && victory && context.Level.Id == Levels.TutorialId
&& !LevelProgress.IsBeaten( Levels.TutorialId );
// Bank the score + replay now — normally the first and only submit for this run. If a dead
// player already bailed early via restart/home, SubmitRunOnce fired then and this is a guarded
// no-op (and we wouldn't reach here anyway, having already switched stages).
SubmitRunOnce();
// Pass the precise (un-rounded) elapsed time so the win time bonus keeps sub-second
// resolution for the leaderboard tiebreaker (ScoreStage floors it for the visible tally).
Manager.TransitionToStage( new ScoreStage( Manager, results, _gameTime, context, firstTutorialVictory,
levelWasBeatenOnEntry: _levelWasBeatenOnEntry, isDebugRun: IsDebugRun, coinsCollected: CoinsCollected,
runData: _submittedRun ) );
}
// Snapshot every block's final state into the serialisable BlockResult list the score tally and
// leaderboard payload are built from (the live Blocks are destroyed when the stage exits).
private List<BlockResult> BuildBlockResults()
{
var results = new List<BlockResult>();
foreach ( var b in Blocks )
{
results.Add( new BlockResult
{
Type = b.BlockType,
WasMimic = b.Mimic is not null,
SpritePath = BlockSprites[b.BlockType],
Phase = b.Phase,
MoveDirection = b.MoveDirection,
Left = b.IsSidePressedOrSuspended( Direction.Left ),
Right = b.IsSidePressedOrSuspended( Direction.Right ),
Up = b.IsSidePressedOrSuspended( Direction.Up ),
Down = b.IsSidePressedOrSuspended( Direction.Down ),
ScoreStartPhase = b.ScoreStartPhase,
ScoreStartLeft = b.WasSidePressedAtScoreStart( Direction.Left ),
ScoreStartRight = b.WasSidePressedAtScoreStart( Direction.Right ),
ScoreStartUp = b.WasSidePressedAtScoreStart( Direction.Up ),
ScoreStartDown = b.WasSidePressedAtScoreStart( Direction.Down ),
} );
}
return results;
}
/// <summary>Submit this run's score to the leaderboard and save its replay locally — exactly once.
/// Called when the game-over beat hands off to the score tally, AND when a player bails early
/// (restart / home) after the run is decided (a death or a win) but before the tally opens, so the
/// run is never lost. No-op for replays, if the run hasn't reached game-over, or if already done.
/// The recorded input the replay needs is read from RunRecorder inside Leaderboard.Submit.</summary>
/// <summary>This run's recorded payload, kept from <see cref="SubmitRunOnce"/> so the score tally can
/// offer "watch this run" without waiting on the board to serve it back. Null until submitted.</summary>
private RunData _submittedRun;
public void SubmitRunOnce()
{
if ( _submitted || IsReplay || IsTest || !_gameOver )
return;
_submitted = true;
// The run's outcome is locked in at game-over; stop recording so the encoded replay ends there
// (later frames freeze on the game-over beat and carry nothing the replay needs).
RunRecorder.Stop();
var results = BuildBlockResults();
bool victory = ScoreCalc.AllMaxPhase( results );
int finalScore = ScoreCalc.Compute( results, _gameTime, CoinsCollected );
// Sub-integer time tiebreaker rides along so equal-score runs order by speed (see ScoreStage).
double submitValue = finalScore + ScoreCalc.TimeTiebreaker( _gameTime );
var context = Manager.CurrentRunContext;
// DEBUG daily runs never submit, touch progress or unlock anything — but bank a LOCAL replay
// (My Replays) so a debug-played day can be reviewed.
if ( IsDebugRun )
{
_submittedRun = Leaderboard.BuildRunData( _gameTime, victory, finalScore, results, context );
LocalReplays.Add( _submittedRun );
return;
}
var run = Leaderboard.Submit( submitValue, _gameTime, victory, finalScore, results, context );
_submittedRun = run;
bool isWorkshop = context.Level?.IsWorkshop == true;
// Read from the run's own death flag, not from !victory: a run can end without a win for other
// reasons, and only an actual death should count.
if ( _died )
Achievements.SubmitDeath();
// Bank this run's coins toward the lifetime tally the coin achievement watches. Campaign and
// daily runs count (win or lose); WORKSHOP runs don't — a downloaded level can be papered with
// coins, which would turn a long-haul achievement into one trivially farmable lap.
if ( !isWorkshop )
{
Achievements.SubmitRunCoins( CoinsCollected );
// Block types maxed this run, win or lose — a death after maxing three of four blocks still
// banked those three. Workshop runs stay out (like the coins): an authored all-types sampler
// would hand out the collection in one lap. Submit right away when something new landed
// (deaths and daily wins never reach the victory path's CheckProgress below; for the runs
// that do, the session cache makes the second call a no-op).
if ( Achievements.RecordMaxPhasedTypes( results ) )
Achievements.CheckProgress();
}
if ( context.IsDaily )
DailyChallengeProgress.MarkCompleted( context.DailyId, run );
// Workshop wins don't unlock characters: a trivial downloaded level forcing an un-owned
// character would bypass its real forced-level unlock.
if ( victory && !isWorkshop )
CharacterProgress.Unlock( context.Character.Id );
// Daily runs play their generated-for-the-day level and never touch the beaten flags. Workshop
// wins DO mark beaten locally — it drives the tally's first-completion affordance and it's what
// makes a repeat win read as "returning for score" — but stay out of the cloud map-gate mirror
// (ws ids would be noise there; MergeAsync only ever pulls backend→local, so a local ws flag
// can't leak up either).
if ( !context.IsDaily && victory )
{
// Read before marking: the beaten flag is what tells a first win from a return visit, and
// it's what keeps the workshop achievement counting distinct LEVELS rather than wins.
bool firstWin = !LevelProgress.IsBeaten( context.Level.Id );
LevelProgress.MarkBeaten( context.Level.Id );
if ( isWorkshop )
{
if ( firstWin )
Achievements.AwardWorkshopBeaten();
}
else
{
CloudProgress.SubmitBeaten( context.Level.Id ); // cross-machine mirror of the beaten flag
// Records only; CheckProgress below is what submits the percent, so this must come first.
Achievements.RecordCapstoneWin( context.Level.Id, context.Character.Id );
// Deliberately outside the firstWin branch: a level already beaten as someone else must
// still count the first time it's beaten as the pairing's character.
Achievements.AwardCharacterLevelChallenge( context.Level.Id, context.Character.Id, _anyPlayerDied );
Achievements.CheckProgress(); // this may have been the last map level / last character
}
}
Manager.ClearRunContext();
}
// Guards SaveTestReplayOnce so an editor test run archives at most one local replay per run.
private bool _testReplaySaved;
/// <summary>Save an editor TEST run's replay into the LOCAL archive (My Replays) — exactly once.
/// Test runs never submit to the leaderboard, but the recorded input plus the exact (possibly
/// unsaved / mid-edit) level are embedded in the payload so the run can be rewatched later. No-op
/// unless this is a test run that has reached game-over.</summary>
private void SaveTestReplayOnce()
{
if ( _testReplaySaved || !IsTest || !_gameOver )
return;
_testReplaySaved = true;
// Outcome is locked in at game-over; stop recording so the encoded replay ends there (later
// frames just freeze on the game-over beat and carry nothing the replay needs).
RunRecorder.Stop();
var results = BuildBlockResults();
bool victory = ScoreCalc.AllMaxPhase( results );
int finalScore = ScoreCalc.Compute( results, _gameTime, CoinsCollected );
var context = Manager.CurrentRunContext;
var run = Leaderboard.BuildRunData( _gameTime, victory, finalScore, results, context );
// The editor always test-plays a transient snapshot of the in-memory model (even a saved level),
// which won't reliably resolve by id once the test ends, so embed its exact definition — the
// replay re-registers it to re-simulate the layout.
run.LevelData = context.Level;
LocalReplays.Add( run );
}
private struct Span { public float Min; public float Max; }
private const float SPIKE_TILE = 5f; // baked tooth tile width (the arena strip repeats every 5px)
// --- arena background + walls -------------------------------------------------------
private void SpawnArena()
{
// The play background is the teal camera clear colour plus the moving cosmetic
// background blocks (SpawnBackgroundBlocks) — matching the original, which had no
// separate fill rect here.
//
// A boundary side is normally one full-width baked strip (240x4 / 4x240). IMPORTANT:
// SpriteRenderer.Size is a SQUARE bounding box (engine aspect-correction), so we pass a square
// Size = the long dimension (240) and the strip's own aspect is fit; the literal (240,4) would
// render 1/60th as tall (invisible). When an obstacle sits flush against a side, that side is
// instead SPLIT into per-segment tiled faces (see BuildArenaSide) so each segment spikes alone.
// Split the level's obstacles into real walls, FENCES and GLASS up front — every face-building
// helper below (flush cutting, corner/elbow patches, band coverage) must see only the walls:
// fences and glass never merge with, cut, or share faces with them (see LevelDef.Fences /
// LevelDef.Glass).
_wallObstacleRects.Clear();
_fenceRects.Clear();
_glassRects.Clear();
_fenceFrameSegs.Clear();
_glassBorderSegs.Clear();
foreach ( var rect in Level.Obstacles )
(IsFenceRect( rect ) ? _fenceRects : IsGlassRect( rect ) ? _glassRects : _wallObstacleRects).Add( rect );
const float D = 4f; // wall art depth (px)
var span = new Vector2( Arena.WIDTH, Arena.WIDTH );
BuildArenaSide( Direction.Down, new Vector2( Arena.WIDTH / 2f, D / 2f ), span );
BuildArenaSide( Direction.Up, new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT - D / 2f ), span );
BuildArenaSide( Direction.Left, new Vector2( D / 2f, Arena.HEIGHT / 2f ), span );
BuildArenaSide( Direction.Right, new Vector2( Arena.WIDTH - D / 2f, Arena.HEIGHT / 2f ), span );
AddArenaCornerPatches();
// Interior solid regions (empty for the classic square arena). Collision rides the existing
// block-collision paths (see Obstacle); each side facing playable space gets its own faces.
// Fences spawn as their own entity list (solid only to blocks, no faces, own look); GLASS
// spawns into the normal obstacle list flagged IsGlass (solid only to players, own look).
foreach ( var rect in Level.Obstacles )
{
if ( IsFenceRect( rect ) )
AddFence( rect );
else if ( IsGlassRect( rect ) )
AddGlass( rect );
else
AddObstacle( rect );
}
// Obstacle elbow patches need EVERY face to exist first (a notch can sit in a rect that is
// not either meeting face's owner — see AddObstacleElbowPatches), so they go in one pass here.
AddObstacleElbowPatches();
// Fence frames and glass borders need the same pass: their 1px strips are seam-cut around
// flush same-kind neighbours, so a composite's inner corner leaves a one-pixel notch where
// two perpendicular strips only corner-touch (see AddFrameElbowPatches).
Color fenceFrame = GammaToLinear( Level.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor ).WithAlpha( FENCE_BORDER_ALPHA );
AddFrameElbowPatches( _fenceFrameSegs, _fenceRects, FENCE_FRAME,
c => AddFenceQuad( c, new Vector2( FENCE_FRAME, FENCE_FRAME ), fenceFrame, WallFace.ORDER_TEETH ) );
Color glassBorder = GammaToLinear( Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor );
AddFrameElbowPatches( _glassBorderSegs, _glassRects, GLASS_BORDER,
c => AddGlassQuad( c, new Vector2( GLASS_BORDER, GLASS_BORDER ), glassBorder, GLASS_ORDER_BORDER, opaque: true ) );
}
// Build one boundary side. With no obstacle flush against it, it's a single full-width baked strip
// (the classic look). Otherwise the flush obstacles CUT the side into surviving segments, each a
// tiled face that spikes independently (so e.g. the C-shape's two channel floors are separate).
private void BuildArenaSide( Direction side, Vector2 fullCenter, Vector2 fullSpan )
{
// Level-authored permanent spikes: every face on this side (all segments if the side is split
// by a flush obstacle) starts deadly and never retracts.
bool permanent = Contains( Level.SpikedWalls, side );
var cuts = FlushObstacleSpans( side );
if ( cuts.Count == 0 )
{
AddWall( side, fullCenter, fullSpan, permanent );
return;
}
bool horizontal = side == Direction.Down || side == Direction.Up;
// Perpendicular walls own the outer corner bands. Including them here creates a sub-tile
// face whose full-size art overhangs an obstacle that reaches both arena walls.
float min = Arena.WALL_SIZE;
float max = (horizontal ? Arena.WIDTH : Arena.HEIGHT) - Arena.WALL_SIZE;
float edgeCoord = side switch
{
Direction.Down => Arena.WALL_SIZE,
Direction.Up => Arena.HEIGHT - Arena.WALL_SIZE,
Direction.Left => Arena.WALL_SIZE,
_ => Arena.WIDTH - Arena.WALL_SIZE,
};
Direction normal = Globals.GetOppositeDirection( side ); // arena spikes grow inward
foreach ( var seg in SubtractSpans( min, max, cuts ) )
{
BuildTiledFace( normal, null, side, edgeCoord, seg.Min, seg.Max, ArenaSegmentLine( side, seg.Min, seg.Max ), permanent );
// Elbow patch: at a segment end that abuts a flush obstacle, the wall band's grey and the
// obstacle's perpendicular grey border form an L whose corner square is covered by NEITHER —
// the arena tiles stop at the cut and the obstacle's border tiles stop at its face — so the
// obstacle FILL (outside-teal) showed through. A small wall-colour quad plugs exactly that
// square (extending wall art segments outward instead would collide with the spike teeth).
if ( seg.Min > min ) AddWallElbowPatch( side, seg.Min - Arena.WALL_SIZE / 2f );
if ( seg.Max < max ) AddWallElbowPatch( side, seg.Max + Arena.WALL_SIZE / 2f );
}
// A vision blocker CUTS this side like any other flush obstacle (so the segments either side
// stay independently-spiking faces and no teeth are drawn inside its body), but the wall BAND
// is then painted straight back through its footprint: a vision pillar standing on the boundary
// must read as a separate object on an unbroken wall, not as a hole in it. Its fill stops at the
// wall instead of extending past the arena edge to cover the band (ObstacleFillVisualRect).
foreach ( var span in FlushObstacleSpans( side, visionOnly: true ) )
AddWallBandPatch( side, span.Min, span.Max, min, max );
}
// The plain arena strip's solid band colour, sampled from the baked art (arena/down_0.png).
private static readonly Color WALL_FILL_COLOR = new Color( 58 / 255f, 64 / 255f, 76 / 255f );
// A WALL_SIZE-square wall-colour quad centred <paramref name="alongCenter"/> along the given
// boundary side, sitting in the wall band (between the arena boundary and the collision edge).
private void AddWallElbowPatch( Direction side, float alongCenter )
{
bool horizontal = side == Direction.Down || side == Direction.Up;
AddElbowPatch( horizontal, alongCenter, WallBandCenter( side ), arenaBoundary: true );
}
// The band strip's centre line on a boundary side (half a WALL_SIZE in from the arena boundary).
private static float WallBandCenter( Direction side ) => side switch
{
Direction.Down => Arena.WALL_SIZE / 2f,
Direction.Up => Arena.HEIGHT - Arena.WALL_SIZE / 2f,
Direction.Left => Arena.WALL_SIZE / 2f,
_ => Arena.WIDTH - Arena.WALL_SIZE / 2f,
};
// The wall band painted through [alongMin,alongMax] of a boundary side as a plain quad — no face,
// so it can never spike (see BuildArenaSide's vision-blocker pass). Clamped to the side's own
// [min,max] range: the perpendicular walls own the corner squares, exactly as the segments do.
private void AddWallBandPatch( Direction side, float alongMin, float alongMax, float min, float max )
{
float lo = alongMin < min ? min : alongMin;
float hi = alongMax > max ? max : alongMax;
if ( hi - lo <= FLUSH_EPS )
return; // nothing (or a sub-EPS sliver) of this blocker actually lies along the side
bool horizontal = side == Direction.Down || side == Direction.Up;
float alongCenter = (lo + hi) / 2f, perpCenter = WallBandCenter( side );
var center = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
var size = horizontal ? new Vector2( hi - lo, Arena.WALL_SIZE ) : new Vector2( Arena.WALL_SIZE, hi - lo );
AddWallQuad( "WallBand", center, size, arenaBoundary: true );
}
// Corner squares: a split side's segments stop WALL_SIZE short of the corners — the perpendicular
// wall's full strip owns them (see BuildArenaSide). When BOTH walls meeting at a corner are split,
// neither side's art reaches it, so the WALL_SIZE corner square would show the fill colour — plug
// it. EXCEPT a corner an obstacle sits flush into (flush against both walls): there the band frame
// opens to the outside and the square must stay fill-coloured like everything around it.
private void AddArenaCornerPatches()
{
const float W = Arena.WALL_SIZE;
var corners = new (Direction sideA, Direction sideB, float x, float y)[]
{
(Direction.Down, Direction.Left, W / 2f, W / 2f),
(Direction.Down, Direction.Right, Arena.WIDTH - W / 2f, W / 2f),
(Direction.Up, Direction.Left, W / 2f, Arena.HEIGHT - W / 2f),
(Direction.Up, Direction.Right, Arena.WIDTH - W / 2f, Arena.HEIGHT - W / 2f),
};
foreach ( var c in corners )
{
if ( FlushObstacleSpans( c.sideA ).Count == 0 || FlushObstacleSpans( c.sideB ).Count == 0 )
continue; // an unsplit side's full-width strip covers the corner
bool occupied = false;
foreach ( var r in _wallObstacleRects )
if ( !ObstacleBlocksVision( r ) && FlushWithArenaWall( r, c.sideA ) && FlushWithArenaWall( r, c.sideB ) ) { occupied = true; break; }
if ( !occupied )
AddElbowPatch( true, c.x, c.y, arenaBoundary: true );
}
}
// The shared elbow quad: a WALL_SIZE square of wall colour plugging the corner square that two
// perpendicular grey border bands leave uncovered (arena-wall/obstacle AND obstacle/obstacle).
private void AddElbowPatch( bool horizontal, float alongCenter, float perpCenter, bool arenaBoundary = false )
{
Vector2 c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
AddWallQuad( "WallElbow", c, new Vector2( Arena.WALL_SIZE, Arena.WALL_SIZE ), arenaBoundary );
}
// A flat wall-colour quad in the band sub-layer (elbow patches and vision-blocker band patches).
private void AddWallQuad( string name, Vector2 center, Vector2 size, bool arenaBoundary )
{
var go = CreateChild( name );
// The band sub-layer, like the tiles it sits between: in front of the obstacle fill quad it
// must paint over (a full 0.1 step — a 0.01 depth sub-offset z-fights the fill under the
// ortho camera, see SpriteLayer / DEPTH_PORTAL_HIGHLIGHT).
float rootZ = arenaBoundary ? Globals.DEPTH_ARENA_WALL : Globals.DepthToZ( Globals.DEPTH_WALL );
go.WorldPosition = new Vector3( center.x, center.y, rootZ );
// pixel.sprite is a 1x1 texture, so a non-square Size maps straight to the quad (unlike the
// baked wall strips, whose own art aspect is fitted into a square Size — see SpawnArena).
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle", WallFace.ORDER_BAND );
sr.Opaque = true;
// The pixel sprite is white, so its linear tint is the direct wall colour.
sr.Color = WallColorLinear;
}
// Tolerance for "sitting flush" edge comparisons (arena walls and obstacle-obstacle seams).
private const float FLUSH_EPS = 0.5f;
// THE definition of an obstacle side sitting flush against its arena wall. Shared by
// FlushObstacleSpans (cut the arena side around it) and AddObstacle (skip building the
// obstacle's own face there) — if these ever disagreed, a fractionally-offset rect would
// both cut the wall AND build a buried-but-lethal face inside the wall band.
private static bool FlushWithArenaWall( RectF r, Direction side ) => side switch
{
Direction.Down => r.Bottom <= Arena.WALL_SIZE + FLUSH_EPS,
Direction.Up => r.Top >= Arena.HEIGHT - Arena.WALL_SIZE - FLUSH_EPS,
Direction.Left => r.Left <= Arena.WALL_SIZE + FLUSH_EPS,
_ => r.Right >= Arena.WIDTH - Arena.WALL_SIZE - FLUSH_EPS,
};
// The obstacle's FILL quad. A flush side's fill runs out past the arena edge: the boundary side is
// cut there, so nothing else paints that band strip and the interior must read as continuous with
// the outside. A VISION BLOCKER is the exception — the band survives through its footprint
// (BuildArenaSide), so its fill stops at its own rect and it reads as a pillar on an intact wall.
private RectF ObstacleFillVisualRect( RectF r )
{
if ( ObstacleBlocksVision( r ) )
return r;
float left = FlushWithArenaWall( r, Direction.Left ) ? 0f : r.Left;
float bottom = FlushWithArenaWall( r, Direction.Down ) ? 0f : r.Bottom;
float right = FlushWithArenaWall( r, Direction.Right ) ? Arena.WIDTH : r.Right;
float top = FlushWithArenaWall( r, Direction.Up ) ? Arena.HEIGHT : r.Top;
return new RectF( left, bottom, right, top );
}
// The along-spans of obstacles sitting flush against a boundary side (they cut that side's face).
private List<Span> FlushObstacleSpans( Direction side ) => FlushObstacleSpans( side, visionOnly: false );
// <paramref name="visionOnly"/> narrows the result to the VISION BLOCKERS among them: they cut the
// side's face like any other flush obstacle, but the band is painted back through their footprint
// (AddWallBandPatch) so they read as separate pillars rather than merging into the boundary.
private List<Span> FlushObstacleSpans( Direction side, bool visionOnly )
{
bool horizontal = side == Direction.Down || side == Direction.Up;
var spans = new List<Span>();
foreach ( var r in _wallObstacleRects )
{
if ( !FlushWithArenaWall( r, side ) || (visionOnly && !ObstacleBlocksVision( r )) )
continue;
spans.Add( horizontal
? new Span { Min = r.Left, Max = r.Right }
: new Span { Min = r.Bottom, Max = r.Top } );
}
return spans;
}
// Is this obstacle rect one the level flagged as a vision blocker? Matched by value, float-exact,
// like ObstacleSideSpiked — the editor and the daily generator both copy the entry straight out of
// Level.Obstacles, so a flagged rect is always bit-identical to its obstacle.
private bool ObstacleBlocksVision( RectF rect )
{
if ( Level.VisionBlockers is null )
return false;
foreach ( var blocker in Level.VisionBlockers )
{
if ( blocker.Left == rect.Left && blocker.Bottom == rect.Bottom
&& blocker.Right == rect.Right && blocker.Top == rect.Top )
return true;
}
return false;
}
// Is this obstacle rect one the level flagged as a FENCE (solid only to blocks)? Matched by
// value, float-exact, exactly like ObstacleBlocksVision.
private bool IsFenceRect( RectF rect )
{
if ( Level.Fences is null )
return false;
foreach ( var fence in Level.Fences )
{
if ( fence.Left == rect.Left && fence.Bottom == rect.Bottom
&& fence.Right == rect.Right && fence.Top == rect.Top )
return true;
}
return false;
}
// Is this obstacle rect one the level flagged as GLASS (solid only to players)? Matched by
// value, float-exact, exactly like ObstacleBlocksVision.
private bool IsGlassRect( RectF rect )
{
if ( Level.Glass is null )
return false;
foreach ( var glass in Level.Glass )
{
if ( glass.Left == rect.Left && glass.Bottom == rect.Bottom
&& glass.Right == rect.Right && glass.Top == rect.Top )
return true;
}
return false;
}
// [min,max] minus the cut intervals -> the surviving segments (gaps). Segments no longer than
// FLUSH_EPS are DROPPED, not emitted: flushness is EPS-tolerant, so a fractionally-offset flush
// rect can leave a sub-EPS sliver (against a range end, or between two nearly-abutting cuts)
// whose face would draw a full-size tile overhanging the obstacle it abuts — the same artifact
// exact-flush clamping already prevents. Nothing legitimate is that short: WALL_SIZE is 2.
private static List<Span> SubtractSpans( float min, float max, List<Span> cuts )
{
cuts.Sort( ( p, q ) => p.Min.CompareTo( q.Min ) );
var result = new List<Span>();
float cursor = min;
foreach ( var c in cuts )
{
// Clamp BOTH ends into [min,max]: a flush neighbour that only corner-touches the side
// contributes a cut lying entirely outside it, which unclamped would emit a bogus
// segment extending past the side's far end.
float s = c.Min < min ? min : (c.Min > max ? max : c.Min);
float e = c.Max < min ? min : (c.Max > max ? max : c.Max);
if ( e <= cursor ) continue;
if ( s > cursor + FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = s } );
cursor = e > cursor ? e : cursor;
}
if ( cursor < max - FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = max } );
return result;
}
private void AddObstacle( RectF rect )
{
var center = new Vector2( (rect.Left + rect.Right) / 2f, (rect.Bottom + rect.Top) / 2f );
var size = new Vector2( rect.Width, rect.Height );
var go = CreateChild( "Obstacle" );
var ob = go.Components.Create<Obstacle>();
ob.Setup( center, size );
// Interior fill reads as non-playable space — the SAME colour as outside the arena (the edge
// blockers / camera clear). The wall-grey border tiles frame it. Tint is consumed as LINEAR.
var fill = ObstacleFillVisualRect( rect );
var fillCenter = new Vector2( (fill.Left + fill.Right) / 2f, (fill.Bottom + fill.Top) / 2f );
var fillSize = new Vector2( fill.Width, fill.Height );
ob.CreateVisuals( GammaToLinear( EffectiveClearColor ), fillCenter, fillSize );
_obstacles.Add( ob );
// Spikeable faces on each side that borders playable space (skip a side flush with an arena
// wall — nothing can stand there, and buried spikes would be invisible). Flushness MUST use
// the same predicate that cuts the arena side (FlushWithArenaWall). A side that another
// obstacle sits flush against is SPLIT around the shared seam (BuildObstacleSide), so two
// flush rects read and behave as ONE composite solid (an L, T, …).
foreach ( var dir in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
{
if ( !FlushWithArenaWall( rect, dir ) )
BuildObstacleSide( ob, rect, dir, ObstacleSideSpiked( rect, dir ) );
}
}
// ── fences ───────────────────────────────────────────────────────────────────────────────────
// A level authors ONE fence colour (LevelDef.FenceColor, default warm amber): the frame/border
// draws it directly and the bars derive a brighter version (LevelCosmeticsRandomizer.FenceBarColor)
// — deliberately NOT the level's wall/OOB colours, so a fence reads as its own object class:
// open bars only blocks collide with, never more wall.
// Tints are consumed as LINEAR, like every pixel-quad tint (see WALL_FILL_COLOR usage).
private const float FENCE_FRAME = 1f; // frame thickness (px), lighter than the 2px wall band
private const float FENCE_BAR = 1f; // vertical bar width (px)
private const float FENCE_BAR_SPACING = 5f; // bar rhythm (px), world-grid aligned (see AddFence)
// Both layers are translucent so the playfield shows through (a fence must read "not solid to
// you") — the bars strongly, the frame only slightly. A translucent frame can't fully hide the
// bar ends it caps: each bar tip shows through it as a faint blend on the 5px rhythm.
private const float FENCE_BAR_ALPHA = 0.5f;
private const float FENCE_BORDER_ALPHA = 0.85f;
/// <summary>Spawn one FENCE: an <see cref="Obstacle"/> body that only the block-solid collision
/// view sees (<see cref="GetBlockSolidObstacles"/>), drawn as an open frame with vertical bars —
/// the playfield stays visible through it, telling the player it isn't solid to them. No
/// <see cref="WallFace"/>s are built (fences can't be spiked), no arena side is cut, and no fill
/// hides the space behind it. Two flush fences merge visually: the shared seam gets no frame
/// (BuildFenceFrameSide), and the bars sit on a WORLD-aligned 5px grid so they run continuously
/// across the seam of a composite fence.</summary>
private void AddFence( RectF rect )
{
var center = new Vector2( (rect.Left + rect.Right) / 2f, (rect.Bottom + rect.Top) / 2f );
var size = new Vector2( rect.Width, rect.Height );
var go = CreateChild( "Fence" );
var ob = go.Components.Create<Obstacle>();
ob.Setup( center, size );
// Fences render UNDER blocks/player/hazards (unlike real obstacles at DEPTH_WALL): everything
// that ignores a fence visibly passes in front of its bars.
ob.Depth = Globals.DEPTH_FENCE;
ob.SyncTransform();
_fences.Add( ob );
Color border = Level.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor;
Color frame = GammaToLinear( border ).WithAlpha( FENCE_BORDER_ALPHA );
Color bar = GammaToLinear( LevelCosmeticsRandomizer.FenceBarColor( border ) );
// Bars first (ORDER_BAND), edge-to-edge on both axes so they run straight through a
// fence-fence seam without breaking the 5px rhythm; the frame paints over them on a nearer
// sub-layer (ORDER_TEETH), so where a frame edge exists it caps/hides the bars cleanly and
// where it's cut away (a seam) the bars continue.
for ( float x = MathF.Ceiling( rect.Left / FENCE_BAR_SPACING ) * FENCE_BAR_SPACING; x <= rect.Right - FENCE_BAR; x += FENCE_BAR_SPACING )
AddFenceQuad( new Vector2( x + FENCE_BAR / 2f, center.y ), new Vector2( FENCE_BAR, rect.Height ), bar.WithAlpha( FENCE_BAR_ALPHA ), WallFace.ORDER_BAND );
foreach ( var dir in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
BuildFenceFrameSide( rect, dir, frame );
}
// One side of a fence's 1px frame, drawn just inside the collision edge. A NEIGHBOURING FENCE
// sitting flush against this side cuts the frame around the shared span (the same SubtractSpans
// used for wall faces), so two flush fences read as one composite pen. Normal obstacles and
// arena walls deliberately do NOT cut it — a fence never merges with them.
private void BuildFenceFrameSide( RectF rect, Direction normal, Color frame )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float alongStart = horizontal ? rect.Left : rect.Bottom;
float alongEnd = horizontal ? rect.Right : rect.Top;
// The frame strip's centre line, half a frame inside the edge.
float perpCenter = normal switch
{
Direction.Left => rect.Left + FENCE_FRAME / 2f,
Direction.Right => rect.Right - FENCE_FRAME / 2f,
Direction.Down => rect.Bottom + FENCE_FRAME / 2f,
_ => rect.Top - FENCE_FRAME / 2f,
};
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinNeighbourSpans( _fenceRects, rect, normal ) ) )
{
var c = horizontal
? new Vector2( (seg.Min + seg.Max) / 2f, perpCenter )
: new Vector2( perpCenter, (seg.Min + seg.Max) / 2f );
var s = horizontal
? new Vector2( seg.Max - seg.Min, FENCE_FRAME )
: new Vector2( FENCE_FRAME, seg.Max - seg.Min );
AddFenceQuad( c, s, frame, WallFace.ORDER_TEETH );
_fenceFrameSegs.Add( new FrameSeg { Horizontal = horizontal, Perp = perpCenter, Min = seg.Min, Max = seg.Max } );
}
}
// The along-spans of OTHER rects of the same KIND (<paramref name="kin"/> = the fence or glass
// rect split) sitting flush against this side of <paramref name="rect"/> — the same-kind-only
// analogue of FlushNeighbourSpans (fences/glass only combine with their own kind). Reads the
// build-time rect splits, not the entity lists: those are still filling while panels spawn.
private static List<Span> FlushKinNeighbourSpans( List<RectF> kin, RectF rect, Direction normal )
{
const float EPS = FLUSH_EPS;
var spans = new List<Span>();
foreach ( var q in kin )
{
if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
continue; // self
bool flush;
float a, b;
switch ( normal )
{
case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
}
if ( flush )
spans.Add( new Span { Min = a, Max = b } );
}
return spans;
}
// Frame elbow patches, in ONE pass after every fence/glass panel is built — the 1px-outline
// analogue of AddObstacleElbowPatches. A flush same-kind neighbour cuts a frame/border side, so
// at an inner corner of a composite (an L — or the staircase, where the notch square belongs to
// a THIRD rect with no surviving strips of its own there) the two perpendicular strips only
// corner-touch, leaving a frame-sized notch in the outline. At each strip end, probe the square
// just past it: patch iff it lies inside some same-kind rect (open space past a true outer
// corner isn't) and no strip covers it (a collinear continuation across a seam does).
private static void AddFrameElbowPatches( List<FrameSeg> strips, List<RectF> kin, float w, Action<Vector2> addPatch )
{
var done = new HashSet<Vector2>(); // the two strips meeting at an L both probe their shared notch
foreach ( var f in strips )
{
TryFrameElbowPatch( strips, kin, f.Horizontal, f.Min - w / 2f, f.Perp, w, done, addPatch );
TryFrameElbowPatch( strips, kin, f.Horizontal, f.Max + w / 2f, f.Perp, w, done, addPatch );
}
}
private static void TryFrameElbowPatch( List<FrameSeg> strips, List<RectF> kin, bool horizontal, float alongCenter, float perpCenter, float w, HashSet<Vector2> done, Action<Vector2> addPatch )
{
var c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
if ( !InsideAnyRect( kin, c ) || CoveredByFrameStrip( strips, c, w ) )
return;
if ( done.Add( c ) )
addPatch( c );
}
private static bool InsideAnyRect( List<RectF> rects, Vector2 pos )
{
foreach ( var r in rects )
if ( pos.x > r.Left && pos.x < r.Right && pos.y > r.Bottom && pos.y < r.Top )
return true;
return false;
}
// Does any drawn strip of the same kind cover this point? (Inclusive at the edges, like
// CoveredByFaceBand — a collinear neighbour's strip abutting the probe point counts.)
private static bool CoveredByFrameStrip( List<FrameSeg> strips, Vector2 pos, float w )
{
foreach ( var f in strips )
{
float along = f.Horizontal ? pos.x : pos.y;
float perp = f.Horizontal ? pos.y : pos.x;
if ( along >= f.Min && along <= f.Max && perp >= f.Perp - w / 2f && perp <= f.Perp + w / 2f )
return true;
}
return false;
}
// A flat fence-colour quad on the fence layer — under blocks/player/hazards, over the playfield
// (see DEPTH_FENCE) — with the fence's own sub-layer split: bars on ORDER_BAND, frame on
// ORDER_TEETH so it caps the bar ends. Always translucent (see FENCE_BAR_ALPHA/FENCE_BORDER_ALPHA).
private void AddFenceQuad( Vector2 center, Vector2 size, Color color, int order )
{
var go = CreateChild( "FenceQuad" );
go.WorldPosition = new Vector3( center.x, center.y, Globals.DepthToZ( Globals.DEPTH_FENCE ) );
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle", order );
sr.Opaque = false;
sr.AlphaCutoff = 0f; // match the glass/editor translucent path exactly
sr.Color = color;
}
// ── glass ────────────────────────────────────────────────────────────────────────────────────
// A level authors ONE glass colour (LevelDef.GlassColor, default lavender): the translucent pane
// tint, the reflection streaks, the solid border and the spike-teeth tint all derive from it.
private const float GLASS_BORDER = 1f; // solid border thickness (px)
private const float GLASS_FILL_ALPHA = 0.30f; // pane tint opacity
private const float GLASS_STREAK_ALPHA = 0.50f;// reflection streaks: a bit more opaque than the pane
private const float GLASS_STREAK_WIDTH = 3f; // streak thickness (px), perpendicular to the diagonal
private const float GLASS_STREAK_PERIOD = 24f; // diagonal rhythm (px), world-aligned (see AddGlassStreaks)
private const int GLASS_ORDER_FILL = 1; // pane, then streaks, then border, then spike teeth
private const int GLASS_ORDER_STREAK = 2;
private const int GLASS_ORDER_BORDER = 3;
public const int GLASS_ORDER_TEETH = 4;
/// <summary>Spawn one GLASS panel: an <see cref="Obstacle"/> flagged <see cref="Obstacle.IsGlass"/>
/// in the NORMAL obstacle list — so every player collision/crush/spike/landing path treats it as
/// an interior wall (including the wrap character, which collides instead of wrapping — see
/// Player.ObstacleSolidToUs) — while the block/hazard views skip it. Drawn as a translucent
/// tinted pane with sparse diagonal reflection streaks and a solid 1px border, at
/// <see cref="Globals.DEPTH_GLASS"/>: blocks and shots visibly slide OVER it. Authored spikes
/// build player-lethal faces on its sides (tinted teeth, no wall band). Two flush glass panels
/// merge visually: the shared border seam is dropped and the streak rhythm is world-aligned so
/// streaks run continuously across the seam.</summary>
private void AddGlass( RectF rect )
{
var center = new Vector2( (rect.Left + rect.Right) / 2f, (rect.Bottom + rect.Top) / 2f );
var size = new Vector2( rect.Width, rect.Height );
var go = CreateChild( "Glass" );
var ob = go.Components.Create<Obstacle>();
ob.Setup( center, size );
ob.IsGlass = true;
ob.SyncTransform();
_obstacles.Add( ob );
Color glass = GammaToLinear( Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor );
AddGlassQuad( center, size, glass.WithAlpha( GLASS_FILL_ALPHA ), GLASS_ORDER_FILL, opaque: false );
AddGlassStreaks( rect, glass.WithAlpha( GLASS_STREAK_ALPHA ) );
foreach ( var dir in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
{
BuildGlassBorderSide( rect, dir, glass );
// Authored spikes only — a block can never ram a pane, so Spikey grafts can't happen and
// every glass face is a permanent level spike. A side flush with an arena wall is skipped
// exactly like an obstacle's (a buried face would be invisible but lethal).
if ( !FlushWithArenaWall( rect, dir ) && ObstacleSideSpiked( rect, dir ) )
BuildGlassSide( ob, rect, dir );
}
}
// One side of a glass panel's 1px solid border, seam-cut around flush GLASS neighbours (glass
// only combines with glass) — the pane analogue of BuildFenceFrameSide.
private void BuildGlassBorderSide( RectF rect, Direction normal, Color border )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float alongStart = horizontal ? rect.Left : rect.Bottom;
float alongEnd = horizontal ? rect.Right : rect.Top;
float perpCenter = normal switch
{
Direction.Left => rect.Left + GLASS_BORDER / 2f,
Direction.Right => rect.Right - GLASS_BORDER / 2f,
Direction.Down => rect.Bottom + GLASS_BORDER / 2f,
_ => rect.Top - GLASS_BORDER / 2f,
};
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinNeighbourSpans( _glassRects, rect, normal ) ) )
{
var c = horizontal
? new Vector2( (seg.Min + seg.Max) / 2f, perpCenter )
: new Vector2( perpCenter, (seg.Min + seg.Max) / 2f );
var s = horizontal
? new Vector2( seg.Max - seg.Min, GLASS_BORDER )
: new Vector2( GLASS_BORDER, seg.Max - seg.Min );
AddGlassQuad( c, s, border, GLASS_ORDER_BORDER, opaque: true );
_glassBorderSegs.Add( new FrameSeg { Horizontal = horizontal, Perp = perpCenter, Min = seg.Min, Max = seg.Max } );
}
}
// One authored-spiked side of a glass panel: tiled spike faces split around flush GLASS
// neighbours (composite panes spike per-segment like composite obstacles), always permanent.
private void BuildGlassSide( Obstacle ob, RectF rect, Direction normal )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float edgeCoord = normal switch
{
Direction.Left => rect.Left,
Direction.Right => rect.Right,
Direction.Down => rect.Bottom,
_ => rect.Top,
};
float alongStart = horizontal ? rect.Left : rect.Bottom;
float alongEnd = horizontal ? rect.Right : rect.Top;
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinNeighbourSpans( _glassRects, rect, normal ) ) )
{
Line line = horizontal
? new Line( new Vector2( seg.Min, edgeCoord ), new Vector2( seg.Max, edgeCoord ) )
: new Line( new Vector2( edgeCoord, seg.Min ), new Vector2( edgeCoord, seg.Max ) );
BuildTiledFace( normal, ob, Direction.None, edgeCoord, seg.Min, seg.Max, line, permanent: true );
}
}
// Sparse diagonal "reflection" streaks on a WORLD-aligned rhythm (the 45° bands
// |x - y - c| <= h, one per GLASS_STREAK_PERIOD px). Each streak is rasterized as abutting
// 1px-wide columns of the exact band∩pane region, so it runs fully edge-to-edge (no missing
// end wedges — a rotated quad's diagonal end cut couldn't fill the corner against an
// axis-aligned edge without spilling), never leaves the pane, and continues seamlessly across
// the seams of a composite pane: adjacent panels rasterize the same world-space band and
// their columns abut exactly at the shared edge, with no flush-detection needed. In-game the
// CRT quantizes to the 240px grid anyway, so the 1px staircase renders identically to a
// smooth rotated band — and the editor schematic now shows the same pixel-true shape.
private void AddGlassStreaks( RectF rect, Color color )
{
const float P = GLASS_STREAK_PERIOD;
// Perpendicular half-thickness w/2 → the band is |x - y - c| <= (w/2)·√2.
float h = GLASS_STREAK_WIDTH * MathF.Sqrt( 2f ) / 2f;
float cMin = rect.Left - rect.Top - h, cMax = rect.Right - rect.Bottom + h;
for ( float c = MathF.Ceiling( cMin / P ) * P; c <= cMax; c += P )
{
// Columns the band can reach; starting on an integer keeps columns aligned across the
// seams of pixel-snapped composite panes.
float xLo = MathF.Max( rect.Left, MathF.Floor( rect.Bottom + c - h ) );
float xHi = MathF.Min( rect.Right, rect.Top + c + h );
for ( float x0 = xLo; x0 < xHi; x0 += 1f )
{
float x1 = MathF.Min( x0 + 1f, rect.Right );
float xc = (x0 + x1) / 2f;
float yLo = MathF.Max( rect.Bottom, xc - c - h );
float yHi = MathF.Min( rect.Top, xc - c + h );
if ( yHi <= yLo ) continue;
AddGlassQuad( new Vector2( xc, (yLo + yHi) / 2f ), new Vector2( x1 - x0, yHi - yLo ),
color, GLASS_ORDER_STREAK, opaque: false );
}
}
}
// A glass quad at the glass depth — above fences, still under blocks/player/hazards.
private SpriteRenderer AddGlassQuad( Vector2 center, Vector2 size, Color color, int order, bool opaque )
{
var go = CreateChild( "GlassQuad" );
go.WorldPosition = new Vector3( center.x, center.y, Globals.DepthToZ( Globals.DEPTH_GLASS ) );
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle", order );
sr.Opaque = opaque;
if ( !opaque )
sr.AlphaCutoff = 0f; // match the editor schematic's translucent path exactly
sr.Color = color;
return sr;
}
// True if the level authored permanent spikes on this obstacle rect's given outward side. The rect
// is matched by value against SpikedObstacleSides entries (float-exact, like FlushNeighbourSpans'
// self-skip) — authors share the same RectF value between Obstacles and the spec.
private bool ObstacleSideSpiked( RectF rect, Direction normal )
{
foreach ( var spec in Level.SpikedObstacleSides )
{
if ( spec.Rect.Left != rect.Left || spec.Rect.Bottom != rect.Bottom
|| spec.Rect.Right != rect.Right || spec.Rect.Top != rect.Top )
continue;
if ( Contains( spec.Sides, normal ) )
return true;
}
return false;
}
// Minimal membership test (GameStage doesn't pull in System.Linq).
private static bool Contains( IReadOnlyList<Direction> list, Direction d )
{
for ( int i = 0; i < list.Count; i++ )
if ( list[i] == d ) return true;
return false;
}
// Build one outward side of an obstacle. A neighbouring obstacle sitting flush (edge-to-edge)
// against this side CUTS it, exactly like a flush obstacle cuts an arena boundary side: no face
// exists along the shared seam — the two interiors are one contiguous non-playable region — and
// each surviving segment becomes its own independently-spiking tiled face. Elbow patches for the
// corner notches the bands leave uncovered are added in one pass after every obstacle is built
// (AddObstacleElbowPatches) — a notch can belong to a rect that is neither meeting face's owner.
private void BuildObstacleSide( Obstacle ob, RectF rect, Direction normal, bool permanent )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float edgeCoord = normal switch
{
Direction.Left => rect.Left,
Direction.Right => rect.Right,
Direction.Down => rect.Bottom,
_ => rect.Top,
};
float alongStart = horizontal ? rect.Left : rect.Bottom;
float alongEnd = horizontal ? rect.Right : rect.Top;
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushNeighbourSpans( rect, normal ) ) )
{
Line line = horizontal
? new Line( new Vector2( seg.Min, edgeCoord ), new Vector2( seg.Max, edgeCoord ) )
: new Line( new Vector2( edgeCoord, seg.Min ), new Vector2( edgeCoord, seg.Max ) );
BuildTiledFace( normal, ob, Direction.None, edgeCoord, seg.Min, seg.Max, line, permanent );
}
}
// Obstacle elbow patches, in ONE pass after every obstacle's faces exist. At each face-segment
// end the grey band stops; if the band-strip square just past the end lies inside an obstacle
// body and no face's band covers it, that body's fill (outside-teal) pokes through the corner
// notch of the L the two perpendicular bands form — plug it. (The arena-wall flavour of the same
// problem is handled in BuildArenaSide.) Probing COVERAGE rather than patching cut-created
// endpoints (the old rule, which this subsumes) also catches the staircase corner three flush
// rects make (one stacked diagonally between two others): there BOTH meeting faces run the full
// length of their own sides — neither end is cut-created — and the notch square belongs to a
// THIRD rect whose faces there were entirely cut away, so no single side can see it needs one.
private void AddObstacleElbowPatches()
{
const float W = Arena.WALL_SIZE;
var done = new HashSet<Vector2>(); // the two faces meeting at an L both probe their shared notch
foreach ( var f in _faces )
{
if ( f.Owner is null or { IsGlass: true } ) // glass faces have no band, so no notches to plug
continue;
bool horizontal = f.Normal == Direction.Up || f.Normal == Direction.Down;
float edge = horizontal ? f.Segment.YMin : f.Segment.XMin;
// The band's centre line sits half a band inside the body (the tile's solid half).
float perpInside = f.Normal == Direction.Left || f.Normal == Direction.Down
? edge + W / 2f
: edge - W / 2f;
float min = horizontal ? f.Segment.XMin : f.Segment.YMin;
float max = horizontal ? f.Segment.XMax : f.Segment.YMax;
TryElbowPatch( horizontal, min - W / 2f, perpInside, done );
TryElbowPatch( horizontal, max + W / 2f, perpInside, done );
}
}
// One candidate notch square (centre along/perp): patch it iff obstacle fill would show there —
// inside some obstacle body but covered by no face band. A collinear neighbour's band continuing
// straight through the probe needs no patch; open playable space past the end needs none either.
private void TryElbowPatch( bool horizontal, float alongCenter, float perpCenter, HashSet<Vector2> done )
{
Vector2 c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
if ( !InsideAnyObstacle( c ) || CoveredByFaceBand( c ) )
return;
if ( done.Add( c ) )
AddElbowPatch( horizontal, alongCenter, perpCenter );
}
private bool InsideAnyObstacle( Vector2 pos )
{
foreach ( var r in _wallObstacleRects )
if ( pos.x > r.Left && pos.x < r.Right && pos.y > r.Bottom && pos.y < r.Top )
return true;
return false;
}
// Does any obstacle face's grey band strip (the WALL_SIZE-deep solid half of its tiles, just
// inside the owner's collision edge) cover this point?
private bool CoveredByFaceBand( Vector2 pos )
{
const float W = Arena.WALL_SIZE;
foreach ( var f in _faces )
{
if ( f.Owner is null or { IsGlass: true } ) // glass faces draw no band, so they cover nothing
continue;
if ( f.Normal == Direction.Up || f.Normal == Direction.Down )
{
float inner = f.Normal == Direction.Up ? f.Segment.YMin - W : f.Segment.YMin;
if ( pos.x >= f.Segment.XMin && pos.x <= f.Segment.XMax && pos.y >= inner && pos.y <= inner + W )
return true;
}
else
{
float inner = f.Normal == Direction.Right ? f.Segment.XMin - W : f.Segment.XMin;
if ( pos.y >= f.Segment.YMin && pos.y <= f.Segment.YMax && pos.x >= inner && pos.x <= inner + W )
return true;
}
}
return false;
}
// A face shorter than one 5px tile has unavoidable overhang. Check whether an adjoining
// obstacle completely hides that overhang within the face's solid band, so it can be moved
// away from an exposed endpoint instead of producing a one-pixel tab at a thin elbow.
private bool ObstacleCoversBandExtension( Direction normal, float edgeCoord, float alongMin, float alongMax )
{
const float W = Arena.WALL_SIZE;
const float EPS = 0.01f;
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float perpMin = normal == Direction.Up || normal == Direction.Right ? edgeCoord - W : edgeCoord;
float perpMax = perpMin + W;
// The owner is deliberately NOT skipped: it self-covers this band only at an INTERIOR
// (flush-cut) endpoint, never at a true corner — there the extension pokes past the owner's
// own along-range and fails the test. That's exactly the distinction we want ("exposed corner
// vs. interior cut"), and it's what makes a face cut short by a stacked neighbour shift the
// right way. Adding a self-skip here would break that case.
foreach ( var r in _wallObstacleRects )
{
bool covered = horizontal
? r.Left <= alongMin + EPS && r.Right >= alongMax - EPS
&& r.Bottom <= perpMin + EPS && r.Top >= perpMax - EPS
: r.Bottom <= alongMin + EPS && r.Top >= alongMax - EPS
&& r.Left <= perpMin + EPS && r.Right >= perpMax - EPS;
if ( covered )
return true;
}
return false;
}
// The along-spans of OTHER obstacles sitting flush (edge-to-edge, within EPS) against this side
// of <paramref name="rect"/> — they cut its face exactly like FlushObstacleSpans cuts an arena
// boundary side. Composite shapes must be authored as non-overlapping edge-to-edge rects.
private List<Span> FlushNeighbourSpans( RectF rect, Direction normal )
{
const float EPS = FLUSH_EPS;
var spans = new List<Span>();
foreach ( var q in _wallObstacleRects )
{
if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
continue; // self
bool flush;
float a, b;
switch ( normal )
{
case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
}
if ( flush )
spans.Add( new Span { Min = a, Max = b } );
}
return spans;
}
// Build a spike face as a row of baked tooth tiles centred ON the collision edge (<paramref
// name="edgeCoord"/>): the tile's solid half sits on the wall/body side, its spikes grow OUTWARD
// into playable space. Works identically for an arena boundary segment and an obstacle side — the
// sprite for "spikes pointing <normal>" is the arena strip of the OPPOSITE side (e.g. spikes-up =
// the bottom wall's "down" tile). Horizontal faces (normal Up/Down) tile along X, vertical along Y.
private WallFace BuildTiledFace( Direction normal, Obstacle owner, Direction arenaSide, float edgeCoord, float alongStart, float alongEnd, Line segment, bool permanent = false )
{
var face = new WallFace { Owner = owner, ArenaSide = arenaSide, Normal = normal, Segment = segment };
face.BandColor = WallColorLinear;
string spriteDir = Globals.GetStringForDirection( Globals.GetOppositeDirection( normal ) );
bool horizontal = normal == Direction.Up || normal == Direction.Down;
// Usually ceil so the teeth visually cover the whole deadly face. A single trailing pixel is the
// exception: leave it as plain band instead of adding a final tile that overlaps its neighbour by
// four pixels. Sub-5px faces still need one tile so the hazard remains visible.
float len = alongEnd - alongStart;
int count = (int)(len / SPIKE_TILE);
float remainder = len - count * SPIKE_TILE;
if ( count == 0 || remainder > 1.01f ) count++;
// GLASS faces deviate: they sit on the glass depth (under blocks — a block visibly slides
// over the teeth it is immune to), draw NO wall band (the pane's own 1px border marks the
// edge), and tint the normally-white teeth toward the glass colour as the "player-only" cue.
bool glass = owner is { IsGlass: true };
// Draw the resting wall as one exact-size quad. Repeating 5px plain tiles forced the final
// tile to overlap its neighbour on non-multiple lengths; after the CRT downsample/halation pass,
// those overlapping endpoints read as soft one-pixel tabs. Teeth still need the baked tiles for
// animation, but the static two-pixel band has no reason to inherit their fixed-size geometry.
float rootZ = owner is null
? Globals.DEPTH_ARENA_WALL
: Globals.DepthToZ( glass ? Globals.DEPTH_GLASS : Globals.DEPTH_WALL );
if ( !glass )
{
float insideOffset = Arena.WALL_SIZE / 2f;
Vector2 bandCenter = normal switch
{
Direction.Up => new Vector2( (alongStart + alongEnd) / 2f, edgeCoord - insideOffset ),
Direction.Down => new Vector2( (alongStart + alongEnd) / 2f, edgeCoord + insideOffset ),
Direction.Right => new Vector2( edgeCoord - insideOffset, (alongStart + alongEnd) / 2f ),
_ => new Vector2( edgeCoord + insideOffset, (alongStart + alongEnd) / 2f ),
};
Vector2 bandSize = horizontal
? new Vector2( len, Arena.WALL_SIZE )
: new Vector2( Arena.WALL_SIZE, len );
var bandGo = CreateChild( "WallBand" );
bandGo.WorldPosition = new Vector3( bandCenter.x, bandCenter.y, rootZ );
var band = SpriteLayer.Add( bandGo, "sprites/pixel.sprite", bandSize, "idle", WallFace.ORDER_BAND );
band.Opaque = true;
band.Color = WallColorLinear;
face.Sprites.Add( band );
}
// The final tile clamps flush to the far end (alongEnd) so a non-5-multiple face is fully
// covered without overhanging that corner; it overlaps its previous tile instead. A face
// shorter than one tile normally centres the unavoidable overhang. At a thin elbow, shift
// all of it into the adjoining obstacle when exactly one endpoint can hide the full excess.
float maxAlong = alongEnd - SPIKE_TILE / 2f;
if ( len < SPIKE_TILE )
{
float overflow = SPIKE_TILE - len;
bool coveredBefore = owner is not null
&& ObstacleCoversBandExtension( normal, edgeCoord, alongStart - overflow, alongStart );
bool coveredAfter = owner is not null
&& ObstacleCoversBandExtension( normal, edgeCoord, alongEnd, alongEnd + overflow );
maxAlong = coveredBefore == coveredAfter
? (alongStart + alongEnd) / 2f
: coveredBefore ? alongEnd - SPIKE_TILE / 2f : alongStart + SPIKE_TILE / 2f;
}
// Teeth are normally pure white (a hazard reads the same across every theme); GLASS teeth
// shade toward the glass colour instead — the signal that only the player dies on them.
Color teethColor = glass
? GammaToLinear( LevelCosmeticsRandomizer.GlassTeethTint( Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor ) )
: Color.White;
int teethOrder = glass ? GLASS_ORDER_TEETH : WallFace.ORDER_TEETH;
for ( int i = 0; i < count; i++ )
{
float along = alongStart + SPIKE_TILE * (i + 0.5f);
if ( along > maxAlong ) along = maxAlong;
Vector2 c = horizontal ? new Vector2( along, edgeCoord ) : new Vector2( edgeCoord, along );
var go = CreateChild( "WallSpike" );
go.WorldPosition = new Vector3( c.x, c.y, rootZ );
// Teeth overlay (band removed from the art) drawn white on the nearer sub-layer, so the
// coloured band beneath always matches the non-spiked walls. Hidden until spikes appear.
var teeth = SpriteLayer.Add( go, $"sprites/arena/tile_{spriteDir}_teeth.sprite",
new Vector2( SPIKE_TILE, SPIKE_TILE ), "spiked", teethOrder );
teeth.Opaque = true;
teeth.Color = teethColor;
teeth.Enabled = false;
face.TeethSprites.Add( teeth );
}
if ( permanent ) MakePermanent( face );
_faces.Add( face );
return face;
}
// Full-width baked strip for an unsplit boundary side (the classic look; one sprite, no tiles).
private void AddWall( Direction side, Vector2 center, Vector2 size, bool permanent = false )
{
var go = CreateChild( $"Wall_{side}" );
go.WorldPosition = new Vector3( center.x, center.y, Globals.DEPTH_ARENA_WALL );
// Created on the band sub-layer — the same depth WallFace.Play sets for "plain" — so the
// strip's resting depth doesn't jump on its first spike cycle.
var sr = SpriteLayer.Add( go, $"sprites/arena/{Globals.GetStringForDirection( side )}.sprite", size, "plain", WallFace.ORDER_BAND );
sr.Opaque = true;
sr.Color = WallSpriteTintLinear;
var face = new WallFace
{
ArenaSide = side,
Normal = Globals.GetOppositeDirection( side ), // arena spikes grow inward
Owner = null,
Segment = ArenaSegmentLine( side, 0f, side == Direction.Down || side == Direction.Up ? Arena.WIDTH : Arena.HEIGHT ),
BandColor = WallSpriteTintLinear, // map baked wall grey to the direct colour; teeth stay white
};
face.Sprites.Add( sr );
// Teeth overlay (band removed from the art) drawn white on the nearer sub-layer, so the
// coloured band beneath always matches the non-spiked walls. Hidden until spikes appear.
var teeth = SpriteLayer.Add( go, $"sprites/arena/{Globals.GetStringForDirection( side )}_teeth.sprite", size, "spiked", WallFace.ORDER_TEETH );
teeth.Opaque = true;
teeth.Color = Color.White;
teeth.Enabled = false;
face.TeethSprites.Add( teeth );
if ( permanent ) MakePermanent( face );
_faces.Add( face );
}
// Set a freshly-built face permanently spiked: deadly from frame one, no grow-in transition, no
// timer (never retracts), and skipped by the warning blink (see TickArenaSpikes / BlinkWallSpikes).
private static void MakePermanent( WallFace f )
{
f.Permanent = true;
f.SpikesPresent = true;
f.Switching = false;
f.Play( "spiked" );
}
private static Line ArenaSegmentLine( Direction side, float a, float b )
{
float w = Arena.WIDTH, h = Arena.HEIGHT;
return side switch
{
Direction.Left => new Line( new Vector2( 0, a ), new Vector2( 0, b ) ),
Direction.Right => new Line( new Vector2( w, a ), new Vector2( w, b ) ),
Direction.Down => new Line( new Vector2( a, 0 ), new Vector2( b, 0 ) ),
_ => new Line( new Vector2( a, h ), new Vector2( b, h ) ),
};
}
public override void Tick( float dt )
{
TickBackgroundBlocks( dt );
// Note: blocks haven't moved yet this tick, so the ambience squares collide against last
// tick's rects — a one-tick lag that's invisible at these speeds and keeps the ambience
// entirely outside the sim's per-step order.
TickBackgroundParticles( dt );
if ( _tutorialSecondPromptDelay >= 0f )
{
_tutorialSecondPromptDelay -= dt;
if ( _tutorialSecondPromptDelay <= 0f )
{
_tutorialSecondPromptDelay = -1f;
ShowTutorialInstruction( "DO IT AGAIN" );
}
}
// The popup's dismiss fade runs while the sim is already live again (the hold completing
// unfreezes input immediately — see BlocksSimulation), so its countdown lives here, not in
// TickMenu. Cosmetic only, and tutorial prompts never run in replays (_tutorialPromptsEnabled).
if ( _tutorialPromptPhase == TutorialPromptPhase.Dismissing )
{
_tutorialPromptDismissTimer -= dt;
if ( _tutorialPromptDismissTimer <= 0f )
HideTutorialInstruction();
}
// Clear the player's per-tick stasis flags BEFORE the blocks tick: a Stasis block's trail sets them
// (blocks tick before the player), and the player consumes them across several later tick phases
// (input gating + gravity), so they must be reset here at frame start rather than mid-player-tick.
foreach ( var player in Players ) player.ResetStasis();
foreach ( var imp in Impostors ) imp.ResetStasis();
if ( Player.SwarmTracing ) Player.SwarmTraceTick++;
// Original per-step order: blocks -> projectiles -> player (last) -> particles.
foreach ( var b in Blocks ) if ( !b.Dead ) b.Tick( dt );
TickVictoryExplosionSfx( dt );
foreach ( var f in Fireballs ) if ( !f.Dead ) f.Tick( dt );
foreach ( var t in Teardrops ) if ( !t.Dead ) t.Tick( dt );
foreach ( var bullet in BlockBullets ) if ( !bullet.Dead ) bullet.Tick( dt );
// Gunner bullets tick BEFORE the player so a close-range hit's repel (applied via the player's
// isolated shockwave channel) moves the player this same frame.
foreach ( var b in Bullets ) if ( !b.Dead ) b.Tick( dt );
foreach ( var projectile in SwapperProjectiles ) if ( !projectile.Dead ) projectile.Tick( dt );
_twinsController?.PreTick();
_playerStepStarts.Clear();
foreach ( var player in Players ) _playerStepStarts.Add( player.Pos );
foreach ( var player in Players ) player.Tick( dt );
ResolvePlayerPairCollisions();
_twinsController?.EndStep();
foreach ( var portal in ImpostorPortals ) if ( !portal.Dead ) portal.Tick( dt );
TickImpostors( dt );
// SWARM TRACE: one line per body once everything has moved (see Player.TraceSwarmTick).
if ( Player.SwarmTracing )
{
foreach ( var player in Players ) if ( player.IsSwarmBody && !player.IsDead ) player.TraceSwarmTick();
foreach ( var imp in Impostors ) if ( imp.IsSwarmClone && !imp.IsDead ) imp.TraceSwarmTick();
}
// Coins tick after the impostors so a friendly clone's final position this tick can collect.
// COLLECTION stops the moment the run is decided — Coin.Tick bails at IsGameOver, so the
// frozen-_gameTime beat can't hand out free points. The _ended gate here additionally stops
// the (still-sparkling) coins once EndGame has fired: the stage then only persists under the
// ~0.3s cover wipe, and test-mode return-to-edit sets _ended without a game-over beat at all.
if ( !_ended )
foreach ( var c in Coins ) if ( !c.Dead ) c.Tick( dt );
foreach ( var p in Particles ) if ( !p.Dead ) p.Tick( dt );
foreach ( var portal in Portals ) portal.Tick( dt );
foreach ( var f in CoinFloaters ) if ( !f.Dead ) f.Tick( dt );
// A mimic that reached a new phase this tick swaps itself for a real disguise type. Run this AFTER
// every entity has ticked (the player + Gunner bullets press block sides during THEIR ticks, so a
// phase-up only lands here) so the swap happens BEFORE the frame renders -- otherwise the disguise's
// phase-2 form (e.g. a mimic-Shade's spotlight vision cover) would flash for one frame before the
// swap. Deferred like this the swap also never destroys a block mid-tick.
ProcessMimicTransforms();
// Repro test levels run their scripted drivers here; a no-op for every real level.
TestLevels.OnStageTick( this );
// Screenshake decay + oscillation. The flip rolls are COSMETIC (camera-shake direction only) and
// run every tick, so they must draw from the cosmetic stream — rolling them on the authoritative
// stream would advance the sim's RNG twice per frame and couple every block decision to the
// camera shake (see the two-stream split in Rng.cs).
_shakeH *= CAM_SHAKE_RECOVERY;
_shakeV *= CAM_SHAKE_RECOVERY;
if ( Rng.CosmeticValue() < 0.80f ) _shakeHPos = !_shakeHPos;
if ( Rng.CosmeticValue() < 0.80f ) _shakeVPos = !_shakeVPos;
// Advance arena-wall spike grow/retract transitions + live-spike timers.
TickArenaSpikes( dt );
// Deadly-spike warning blink (blocks + arena walls, in lock-step).
_spikeBlinkTimer -= dt;
if ( _spikeBlinkTimer <= 0f )
{
_spikeBlink = !_spikeBlink;
foreach ( var b in Blocks ) b.BlinkSpikes( _spikeBlink );
BlinkWallSpikes( _spikeBlink );
_spikeBlinkTimer = _spikeBlink ? SPIKE_BLINK_TIME_ON : SPIKE_BLINK_TIME_OFF;
}
if ( _gameOver )
{
_gameOverTimer -= dt;
// Hold for the beat, or let the player skip the wait with Confirm. The skip is disallowed in
// ANY replay (including a hijacked practice run): a normal replay can't skip anyway (recorded
// input never sets ConfirmJust), and a hijack must play the beat out fully before freezing.
if ( _gameOverTimer <= 0f || ( InputState.ConfirmJust && !IsReplay ) )
EndGame();
}
else
{
_gameTime += dt;
}
// Tutorial ghost: recording taps the frame's FINAL player pose (after the pair resolve and
// every visual update above); playback advances its loop. Both are tick-driven and Rng-free,
// so runs, replays, and scrubs reproduce identically with or without them.
GhostRecorder.CaptureStep( this );
_ghost?.Tick( Player );
ReapDead();
}
private void TickVictoryExplosionSfx( float dt )
{
if ( _victoryExplosionSfxTimer < 0f || Blocks.Count == 0 ) return;
_victoryExplosionSfxTimer -= dt;
if ( _victoryExplosionSfxTimer > 0f ) return;
Block source = Blocks[Rng.CosmeticInt( 0, Blocks.Count )];
Audio.PlaySfx( SfxType.BlockExplosion, source.Position, Rng.CosmeticFloat( 0.6f, 0.9f ) );
_victoryExplosionSfxTimer = Rng.CosmeticFloat(
VICTORY_EXPLOSION_SFX_INTERVAL_MIN,
VICTORY_EXPLOSION_SFX_INTERVAL_MAX );
}
/// <summary>Resolve living player pairs once after every body has moved. Keeping this outside
/// <see cref="Player.Tick"/> removes stable-list-order bias: neither twin treats the other's old
/// position as authoritative, and block pressure can move whichever body has valid static space
/// on a glue-free axis (see <see cref="Player.CanAcceptTwinResolution"/>).</summary>
private void ResolvePlayerPairCollisions()
{
int count = Math.Min( Players.Count, _playerStepStarts.Count );
for ( int firstIndex = 0; firstIndex < count; firstIndex++ )
{
Player first = Players[firstIndex];
if ( first.IsDead ) continue;
for ( int secondIndex = firstIndex + 1; secondIndex < count; secondIndex++ )
{
Player second = Players[secondIndex];
if ( second.IsDead ) continue;
// Re-sample contact flags ONLY for a pair the solver corrected. Everywhere else the flags
// are a start-of-tick probe that survives to the next tick (_onFloorLastTick's fall-damage
// gate and the pre-refresh readers like HandleDash key off that timing), so refreshing
// unconditionally would shift cross-tick flag semantics for every character.
if ( ResolvePlayerPairCollision( first, second, _playerStepStarts[firstIndex], _playerStepStarts[secondIndex] ) )
{
first.RefreshTwinResolvedContacts();
second.RefreshTwinResolvedContacts();
}
}
}
}
/// <summary>True when a correction was applied (positions moved and/or motion into the sibling
/// stopped); false when the pair never collided or is statically trapped and left compressed.</summary>
private static bool ResolvePlayerPairCollision( Player first, Player second, Vector2 firstStart, Vector2 secondStart )
{
if ( first.IsTwinDashing || second.IsTwinDashing ) return false;
RectF firstRect = first.GetRect();
RectF secondRect = second.GetRect();
float overlapX = Math.Min( firstRect.Right, secondRect.Right ) - Math.Max( firstRect.Left, secondRect.Left );
float overlapY = Math.Min( firstRect.Top, secondRect.Top ) - Math.Max( firstRect.Bottom, secondRect.Bottom );
RectF firstStartRect = first.GetRect( firstStart.x, firstStart.y );
RectF secondStartRect = second.GetRect( secondStart.x, secondStart.y );
float startOverlapX = Math.Min( firstStartRect.Right, secondStartRect.Right ) - Math.Max( firstStartRect.Left, secondStartRect.Left );
float startOverlapY = Math.Min( firstStartRect.Top, secondStartRect.Top ) - Math.Max( firstStartRect.Bottom, secondStartRect.Bottom );
if ( (startOverlapX <= 0f || startOverlapY <= 0f) &&
TryGetSweptPlayerCollision( first, second, firstStart, secondStart,
out bool sweptHorizontal, out bool sweptFirstBefore, out float collisionTime ) )
{
Vector2 sweptOutward = sweptHorizontal
? new Vector2( sweptFirstBefore ? -1f : 1f, 0f )
: new Vector2( 0f, sweptFirstBefore ? -1f : 1f );
float separation = sweptHorizontal
? (first.Width + second.Width) * 0.5f
: (first.Height + second.Height) * 0.5f;
Vector2 firstMove = first.Pos - firstStart;
Vector2 secondMove = second.Pos - secondStart;
Vector2 splitFirst = Vector2.Zero;
Vector2 splitSecond = Vector2.Zero;
Vector2 fullSweptFirst = Vector2.Zero;
Vector2 fullSweptSecond = Vector2.Zero;
if ( sweptHorizontal )
{
float targetFirst = firstStart.x + firstMove.x * collisionTime;
float targetSecond = secondStart.x + secondMove.x * collisionTime;
splitFirst = new Vector2( targetFirst - first.X, 0f );
splitSecond = new Vector2( targetSecond - second.X, 0f );
fullSweptFirst = new Vector2( second.X + (sweptFirstBefore ? -separation : separation) - first.X, 0f );
fullSweptSecond = new Vector2( first.X + (sweptFirstBefore ? separation : -separation) - second.X, 0f );
}
else
{
float targetFirst = firstStart.y + firstMove.y * collisionTime;
float targetSecond = secondStart.y + secondMove.y * collisionTime;
splitFirst = new Vector2( 0f, targetFirst - first.Y );
splitSecond = new Vector2( 0f, targetSecond - second.Y );
fullSweptFirst = new Vector2( 0f, second.Y + (sweptFirstBefore ? -separation : separation) - first.Y );
fullSweptSecond = new Vector2( 0f, first.Y + (sweptFirstBefore ? separation : -separation) - second.Y );
}
return ApplyPlayerPairCorrection( first, second, firstStart, secondStart,
splitFirst, splitSecond, fullSweptFirst, fullSweptSecond, sweptOutward );
}
if ( overlapX > 0f && overlapY > 0f )
{
bool horizontal = overlapX <= overlapY;
bool firstBefore = FirstBeforeSecond( first, second, firstStart, secondStart, horizontal );
Vector2 firstOutward = horizontal
? new Vector2( firstBefore ? -1f : 1f, 0f )
: new Vector2( 0f, firstBefore ? -1f : 1f );
float depth = horizontal ? overlapX : overlapY;
Vector2 fullFirst = firstOutward * depth;
Vector2 fullSecond = -fullFirst;
return ApplyPlayerPairCorrection( first, second, firstStart, secondStart,
fullFirst * 0.5f, fullSecond * 0.5f, fullFirst, fullSecond, firstOutward );
}
return false;
}
private static bool ApplyPlayerPairCorrection( Player first, Player second,
Vector2 firstStart, Vector2 secondStart, Vector2 splitFirst, Vector2 splitSecond,
Vector2 fullFirst, Vector2 fullSecond, Vector2 firstOutward )
{
bool resolved = false;
if ( first.CanAcceptTwinResolution( splitFirst ) && second.CanAcceptTwinResolution( splitSecond ) )
{
first.ApplyTwinResolution( splitFirst );
second.ApplyTwinResolution( splitSecond );
resolved = true;
}
else
{
float firstInward = Math.Max( 0f, Vector2.Dot( first.Pos - firstStart, -firstOutward ) );
float secondInward = Math.Max( 0f, Vector2.Dot( second.Pos - secondStart, firstOutward ) );
bool tryFirstFirst = firstInward >= secondInward;
if ( tryFirstFirst )
resolved = TryMoveFirstOnly() || TryMoveSecondOnly();
else
resolved = TryMoveSecondOnly() || TryMoveFirstOnly();
bool TryMoveFirstOnly()
{
if ( !first.CanAcceptTwinResolution( fullFirst ) ) return false;
first.ApplyTwinResolution( fullFirst );
return true;
}
bool TryMoveSecondOnly()
{
if ( !second.CanAcceptTwinResolution( fullSecond ) ) return false;
second.ApplyTwinResolution( fullSecond );
return true;
}
}
if ( !resolved ) return false; // Both are statically trapped: ordinary twins compress harmlessly.
first.StopMotionIntoTwin( firstOutward );
second.StopMotionIntoTwin( -firstOutward );
return true;
}
private static bool FirstBeforeSecond( Player first, Player second,
Vector2 firstStart, Vector2 secondStart, bool horizontal )
{
float firstThen = horizontal ? firstStart.x : firstStart.y;
float secondThen = horizontal ? secondStart.x : secondStart.y;
if ( firstThen != secondThen ) return firstThen < secondThen;
float firstNow = horizontal ? first.X : first.Y;
float secondNow = horizontal ? second.X : second.Y;
return firstNow == secondNow || firstNow < secondNow;
}
private static bool TryGetSweptPlayerCollision( Player first, Player second,
Vector2 firstStart, Vector2 secondStart, out bool horizontal, out bool firstBefore, out float collisionTime )
{
horizontal = false;
firstBefore = false;
collisionTime = 0f;
RectF firstStartRect = first.GetRect( firstStart.x, firstStart.y );
RectF secondStartRect = second.GetRect( secondStart.x, secondStart.y );
Vector2 relativeMove = (first.Pos - firstStart) - (second.Pos - secondStart);
SweepAxis( firstStartRect.Left, firstStartRect.Right, secondStartRect.Left, secondStartRect.Right,
relativeMove.x, out float xEntry, out float xExit );
SweepAxis( firstStartRect.Bottom, firstStartRect.Top, secondStartRect.Bottom, secondStartRect.Top,
relativeMove.y, out float yEntry, out float yExit );
float entry = Math.Max( xEntry, yEntry );
float exit = Math.Min( xExit, yExit );
if ( entry < 0f || entry > 1f || entry > exit ) return false;
collisionTime = entry;
horizontal = xEntry > yEntry || (xEntry == yEntry && Math.Abs( relativeMove.x ) >= Math.Abs( relativeMove.y ));
firstBefore = horizontal
? firstStart.x <= secondStart.x
: firstStart.y <= secondStart.y;
return true;
}
private static void SweepAxis( float firstMin, float firstMax, float secondMin, float secondMax,
float relativeMove, out float entry, out float exit )
{
if ( relativeMove > 0f )
{
entry = (secondMin - firstMax) / relativeMove;
exit = (secondMax - firstMin) / relativeMove;
}
else if ( relativeMove < 0f )
{
entry = (secondMax - firstMin) / relativeMove;
exit = (secondMin - firstMax) / relativeMove;
}
else if ( firstMax <= secondMin || firstMin >= secondMax )
{
entry = float.PositiveInfinity;
exit = float.NegativeInfinity;
}
else
{
entry = float.NegativeInfinity;
exit = float.PositiveInfinity;
}
}
private void ReapDead()
{
ReapList( Fireballs );
ReapList( Bullets );
ReapList( SwapperProjectiles );
ReapList( BlockBullets );
ReapList( Teardrops );
ReapList( Particles );
ReapList( ImpostorPortals );
ReapList( Coins );
ReapList( CoinFloaters );
// Impostors are Players tracked by their own IsDead flag (not the Entity2D.Dead flag ReapList uses).
Impostors.RemoveAll( imp =>
{
if ( imp.IsDead ) { imp.GameObject?.Destroy(); return true; }
return false;
} );
}
private static void ReapList<T>( List<T> list ) where T : Entity2D
{
list.RemoveAll( e =>
{
if ( e.Dead ) { e.GameObject?.Destroy(); return true; }
return false;
} );
}
public override void SyncTransforms()
{
SyncBackgroundBlocks();
SyncBackgroundParticles();
foreach ( var b in Blocks ) b.SyncTransform();
foreach ( var f in Fireballs ) f.SyncTransform();
foreach ( var b in Bullets ) b.SyncTransform();
foreach ( var projectile in SwapperProjectiles ) projectile.SyncTransform();
foreach ( var bullet in BlockBullets ) bullet.SyncTransform();
foreach ( var t in Teardrops ) t.SyncTransform();
foreach ( var player in Players ) player.SyncTransform();
_twinsController?.SyncPresentation();
foreach ( var imp in Impostors ) imp.SyncTransform();
foreach ( var p in Particles ) p.SyncTransform();
foreach ( var portal in Portals ) portal.SyncTransform();
foreach ( var portal in ImpostorPortals ) portal.SyncTransform();
// Coins are deliberately absent: stationary, positioned once at spawn (fractional depth layer).
foreach ( var f in CoinFloaters ) f.SyncTransform();
ApplyScreenshake();
}
private void ApplyScreenshake()
{
if ( Manager.Camera is null ) return;
// The GIF-export overlay owns the camera framing while it's open (shrunk preview / tight capture);
// don't fight it by snapping the camera back to the arena centre each SyncTransforms.
if ( Manager.IsGifPreviewActive ) return;
// Player-tunable intensity: slider-space 0..100 → 0..1 factor. 0 pins the camera dead centre.
float shakeScale = Settings.Current.Screenshake / 100f;
float ox = _shakeH * (_shakeHPos ? 1f : -1f) * CAM_SHAKE_STRENGTH * Arena.STEP * shakeScale;
float oy = _shakeV * (_shakeVPos ? 1f : -1f) * CAM_SHAKE_STRENGTH * Arena.STEP * shakeScale;
ox = MathF.Round( ox );
oy = MathF.Round( oy );
Manager.Camera.WorldPosition = new Vector3( Arena.WIDTH / 2f + ox, Arena.HEIGHT / 2f + oy, 2000f );
}
}