Entities/Block.cs
namespace BlockParty;
public enum BlockType { Dragon, Squid, Smile, Spikey, Sad, Wind, Magnet, Sticky, Shade, Stasis, Shockwave, Slicer, Mimic, Teleport, Summoner, Hunter, Siren, Wisp, Venom, Reverse, Laser, Bullet, DragonBounce, LaserPierce, StraightLaser, SadPierce, SquidPierce }
/// <summary>
/// Enemy "block" — a faithful port of the gameplay core of the original <c>Block</c>:
/// accelerate in a direction, bounce off walls/other blocks, and on a hard impact pause
/// (eye close → pick a new direction → wait → eye open → resume). The player presses the
/// four side-buttons; pressing all four advances the block a phase (3 phases); when every
/// block hits max phase the player wins.
///
/// Phase 3a scope: movement, collision, impact, eye-cycle, side-press → phasing, layered
/// phase-coloured sprites, and shake. Deferred to later sub-phases: palette blink, speed
/// lines, block spikes, and subtype-specific behaviour/attacks (Dragon fireballs, Squid
/// teardrops/laser, Smile invisibility, Spikey spikes, Sad).
/// </summary>
public class Block : Entity2D
{
public BlockType BlockType { get; protected set; }
public Direction MoveDirection { get; private set; }
public int Phase { get; private set; }
public int ScoreStartPhase { get; private set; }
private bool _scoreStartLeft, _scoreStartRight, _scoreStartUp, _scoreStartDown;
/// <summary>Per-block "mimic" memory, or null for a normal block. A block that originated as a Mimic
/// (BlockType.Mimic) carries this; the stage swaps the instance for a real disguise type at each
/// phase-up (GameStage.ProcessMimicTransforms) and hands this state to the replacement so the disguise
/// remembers it is a mimic and transforms again at the next phase.</summary>
public MimicState Mimic { get; set; }
/// <summary>True once a mimic transform has swapped this instance out for its disguise (set the same
/// sim tick, in GameStage.TransformMimic). SIM code must test THIS, never engine IsValid(): Destroy()
/// only queues the delete, so IsValid() flips at the NEXT FRAME's flush — a boundary that never comes
/// inside a one-frame re-sim (timeline scrub, hijack rebuild, replay_verify), which desynced replays.</summary>
public bool Replaced { get; set; }
/// <summary>A mimic transform swapped <paramref name="old"/> for <paramref name="replacement"/> this
/// tick — re-point any reference held to the old instance (see GameStage.TransformMimic).</summary>
public virtual void OnBlockReplaced( Block old, Block replacement ) { }
/// <summary>True if this block type can obscure the player's line of sight (phase-dependent — see
/// <see cref="VisionMode"/>). Lets the stage decide whether to spawn the vision cover.</summary>
public virtual bool BlocksVision => false;
/// <summary>The line-of-sight cover this block imposes RIGHT NOW (by phase), or null when it isn't
/// blocking. Drawn by the stage's shared <see cref="VisionOccluder"/>.</summary>
public virtual VisionBlockMode? VisionMode => null;
/// <summary>This block's index in the stage's spawn order (0-based), assigned at spawn. Used to give
/// per-block lane overlays (Wind/Magnet) a unique world-Z sub-offset so overlapping lanes from
/// different blocks don't z-fight, however many of them a level composes.</summary>
public int StageIndex { get; set; }
// TEMP DEBUG: force a starting move direction (used by GameStage's single-block debug setup).
public void DebugSetMoveDirection( Direction dir ) => MoveDirection = dir;
// TEMP DEBUG: seed the current travel speed (used by TestLevels' scripted repro setups, e.g. to
// start two blocks with a fixed speed differential). Still clamped to the phase cap by TickMovement.
public void DebugSetMoveSpeed( float speed ) => _moveSpeed = speed;
public const int NUM_PHASES = 3;
public bool IsStopped { get; set; }
public bool IsDead { get; private set; }
public TurnMode TurnMode { get; private set; }
/// <summary>While a Teleport block is phasing / fading in at a new location it is intangible to the
/// player and to the squid laser (other blocks still collide with it, so it stays a solid obstacle to
/// them). The player-vs-block scans and BlockSquid.ComputeBeam skip a block with this set; the
/// block-vs-block collision does not.</summary>
public bool PhasingIn { get; set; }
/// <summary>True for the simulation tick in which a phasing block becomes solid. Projectiles that
/// were already inside use their pre-move position as the impact point rather than a swept face.</summary>
public bool BecameSolidThisTick { get; protected set; }
/// <summary>Set for the single tick on which this block lands a hard SLAM (the impact loud enough to
/// throw dust + play the impact sfx — speed over the collision threshold, not a slow grind). Cleared
/// at the top of every <see cref="Tick"/>. Blocks tick before the player, so the player can read it
/// the same tick to release a sticky grab into its post-slam window (see Player.HandleStickyBlocks);
/// the fling momentum comes from <see cref="PreImpactVelocity"/>.</summary>
public bool SlammedThisTick { get; private set; }
/// <summary>Set for the single tick on which this block came to rest in a way that hands its banked
/// momentum to an attached player: a real slam (every slam sets this alongside
/// <see cref="SlammedThisTick"/>) or a full-speed soft brake (<see cref="SoftStopAndRepick"/> — the
/// hunter re-aiming mid-lane). A quiet grind stop — a failed move that crept sub-pixel into a
/// neighbour for its grind window and re-stopped at a crawl — leaves this false, so it can never
/// fling. Cleared at the top of every <see cref="Tick"/>; blocks tick before the player, so the
/// inertia-fling sites read it the same tick, taking the momentum itself from
/// <see cref="PreImpactVelocity"/> (subject to their own per-axis speed floors).</summary>
public bool InertiaHandoffThisTick { get; private set; }
/// <summary>The velocity this block was travelling at when it last came to rest (captured in
/// <see cref="ImpactEffects"/> just before <see cref="Entity2D.Velocity"/> is zeroed). This is what
/// the momentum-inheritance mechanics read — the sticky slam release and the riding/hugging inertia
/// flings all fire on/after the stop tick, when the block itself is already stationary. Velocity is
/// always the block's CURRENT motion (zero while stopped or dead), so "is it moving toward me?"
/// queries (crushing, spikes, platform carries) can read it raw.</summary>
public Vector2 PreImpactVelocity { get; private set; }
/// <summary>The block's resolved world displacement during its most recent fixed step. Projectiles
/// use this instead of live velocity because a block may stop and zero its velocity on impact after
/// it has already moved into them. Instant jumps (teleports) are excluded — see
/// <see cref="ExcludeJumpFromStepDisplacement"/> — so a swept collision never treats a teleport as
/// travelled distance.</summary>
public Vector2 StepDisplacement { get; private set; }
// Instant position jumps this step (teleports), subtracted out of StepDisplacement.
Vector2 _stepJumpDelta;
/// <summary>Subtypes that move by instant jump rather than travel (BlockTeleport) must report the
/// jump here so it is left out of <see cref="StepDisplacement"/>: a bullet overlapping the arrival
/// spot would otherwise sweep the whole jump as if the block flew there, placing the impact at a
/// bogus point along that fictional path.</summary>
protected void ExcludeJumpFromStepDisplacement( Vector2 jump ) => _stepJumpDelta += jump;
/// <summary>DEBUG: when set, each block draws its position/velocity/intended move direction as
/// world-space text above itself (see <see cref="DrawDebugInfo"/>, driven by GameManager once
/// per rendered frame). A mutable field (not const) so it can be toggled at runtime.</summary>
public static bool ShowDebugInfo = false;
/// <summary>The solid this block last rammed hard enough to <see cref="Impact"/>: another Block, an
/// interior <see cref="Obstacle"/>, the hardened Twin statue (a <see cref="Player"/>), or null for a
/// bare arena wall. Lets a subtype (Spikey) tell those apart — every non-block case arrives as
/// <c>Impact(null)</c>. Only null means an arena wall.</summary>
protected Entity2D LastImpactOther { get; private set; }
protected float _moveSpeed;
float _mimicAuraTimer; // countdown between subtle transformed-mimic aura puffs (cosmetic)
float _mimicTintTime;
// Slowly breathe from a cool cast to saturated blue, keeping the disguise readable on its
// own. Only body layers receive it: buttons and spikes retain their phase / hazard colors.
const float MIMIC_TINT_PERIOD = 2.2f;
static readonly Color MIMIC_DISGUISE_TINT = new( 0.8f, 0.9f, 1f );
static readonly Color MIMIC_DISGUISE_PEAK_TINT = new( 0.22f, 0.50f, 1f );
// Per-phase accel / max-speed. Not readonly: a subtype (e.g. BlockHunter) reassigns these in OnSetup
// to change its speed profile per phase.
protected float[] _accelerations = { 50f, 55f, 60f };
protected float[] _maxSpeeds = { 220f, 230f, 240f };
protected float _currentCollidingTime;
protected const float REQUIRED_COLLISION_SPEED = 40.0f;
protected const float REQUIRED_COLLISION_TIME = 0.33f;
public const float DEFAULT_ABILITY_INTERVAL = 3f;
public const float DEFAULT_ABILITY_CLOSED_TIME = 1f;
float _abilityInterval = DEFAULT_ABILITY_INTERVAL;
float _abilityRandomness;
float _abilityStartOffset;
bool _abilityStartOffsetPending;
float _abilityClosedTime = DEFAULT_ABILITY_CLOSED_TIME;
float _abilityTimer = -1f;
bool _timedAbilityActivation;
bool _enclosedThisTick;
bool _enclosedEyeCycle;
// True only while the cycle in flight is TickEnclosedEyes' AMBIENT blink (not an ability-initiated
// close that merely wants the enclosed-gaze reopen, which _enclosedEyeCycle also covers). Only this
// kind may be held for a busy ability in Tick — holding an ability's own cycle deadlocks a subtype
// whose attack STARTS from OnEyesClosed (an enclosed Siren froze forever waiting on its own close).
bool _ambientEnclosedBlink;
float _enclosedEyeTimer = -1f;
const float ENCLOSED_EYE_INTERVAL_MIN = 1.5f;
const float ENCLOSED_EYE_INTERVAL_MAX = 3f;
/// <summary>Subtypes with a long-running attack report busy here; the enclosed-ability countdown
/// pauses while this is false, so the next activation comes a full interval after the attack ends.</summary>
protected virtual bool CanActivateTimedAbility => true;
/// <summary>Lane blocks (Wind/Magnet) override: while enclosed at phase 1+, the ability timings drive
/// a strict lane on/off loop — Interval (± Randomness) counts only eyes-fully-open time (lane on),
/// then the eyes shut for the authored ClosedTime (lane off) and re-open — and the ambient enclosed
/// blink is suppressed, so the authored loop is the ONLY lull cadence.</summary>
protected virtual bool HasEnclosedEyeLoop => false;
bool EnclosedEyeLoopActive => HasEnclosedEyeLoop && Phase >= 1;
/// <summary>True while the authored enclosed on/off loop is actually driving this block's cadence:
/// a loop-capable subtype, at phase 1+, boxed in this tick. A subtype whose ability isn't already
/// eye-driven (BlockStraightLaser's beams) gates on this + <see cref="EyesOpenAmount"/> so ONLY the
/// enclosed loop toggles it — ordinary eye cycles while free-roaming must not.</summary>
protected bool EnclosedEyeLoopRunning => EnclosedEyeLoopActive && _enclosedThisTick;
public float AbilityInterval => _abilityInterval;
public float AbilityRandomness => _abilityRandomness;
public float AbilityClosedTime => _abilityClosedTime;
public void ConfigureTurnMode( TurnMode mode ) => TurnMode = mode;
/// <summary>Configure the cadence used while this block is physically blocked in all four directions.</summary>
public void ConfigureAbilityTimer( float interval, float randomness, float startOffset = 0f,
float closedTime = DEFAULT_ABILITY_CLOSED_TIME )
{
_abilityInterval = MathF.Max( 0.05f, interval );
_abilityRandomness = Math.Clamp( randomness, 0f, MathF.Max( 0f, _abilityInterval - 0.05f ) );
_abilityStartOffset = Math.Clamp( startOffset, 0f, _abilityInterval );
_abilityStartOffsetPending = true;
_abilityClosedTime = MathF.Max( 0f, closedTime );
_abilityTimer = -1f;
}
// Eye / pause cycle.
enum EyeState { Open, Closing, Waiting, Opening }
EyeState _eyeState = EyeState.Open;
float _eyeTimer;
float _eyesOpenTime = float.MaxValue;
bool _facingEyeCycle;
Direction? _eyeCycleFacingOverride;
float _facingEyesClosedTime;
const float EYES_OPEN_TIME = 0.2f;
protected virtual float EyesCloseTime => 0.2f;
const float EYES_STAY_CLOSED_TIME_MIN = 0.28f;
const float EYES_STAY_CLOSED_TIME_MAX = 0.60f;
/// <summary>How open the eyes currently are, 0 (fully shut) → 1 (fully open), ramping across the
/// close/open animations. Lets a subtype fade an eyes-gated effect (e.g. the Wind block's gust)
/// in and out with the eye-cycle. During Closing the timer counts full→0 (so open = timer/close),
/// during Opening it counts full→0 as the eyes open (so open = 1 - timer/open).</summary>
protected float EyesOpenAmount => _eyeState switch
{
EyeState.Open => 1f,
EyeState.Closing => Math.Clamp( _eyeTimer / EyesCloseTime, 0f, 1f ),
EyeState.Waiting => 0f,
EyeState.Opening => Math.Clamp( 1f - _eyeTimer / EYES_OPEN_TIME, 0f, 1f ),
_ => 1f,
};
// Per-side state. Each side is EITHER a pressable button OR spikes, never both at once — a single
// state machine per side makes that exclusivity structural. This was previously 8 parallel
// Dictionary<Direction,…> maps advanced by TWO independent tickers (button-pop + spike add/retract)
// that BOTH wrote the same per-side sprite; a spike graft landing mid button-pop let the still-
// running pop finish and stomp the spike sprite (a deadly side that rendered as a harmless button).
// One mode per side removes that whole class of bug — the modes below are mutually exclusive:
//
// Out ──press──▶ Pressed ──phase-up──▶ Retracting ──▶ Popping ──▶ Out
// (any) ──graft──▶ SpikesAdding ──▶ Spiked ──timeout──▶ SpikesRetracting ──▶ Popping ──▶ Out
// └─▶ Pressed (if pressed when grafted)
//
// Spikes SUSPEND a press rather than erase it: PressedUnderSpikes latches at graft time and is only
// read while HasSpikes. It still counts toward phase-up (the spikes just stay put through the
// phase-up); if no phase-up consumes it, the press returns on retract instead of a fresh pop.
//
enum SideMode
{
Out, // resting button, pressable
Pressed, // button pressed in (counts toward phase-up)
Retracting, // pressed button retracting in (phase-up transition, part 1)
Popping, // new phase's button popping out (phase-up part 2, or after spikes retract)
SpikesAdding, // spikes growing in — harmless + unpressable
Spiked, // spikes live and deadly
SpikesRetracting, // spikes retracting away
}
class SideState
{
public SideMode Mode = SideMode.Out;
public float Timer; // countdown for the current timed mode (every mode but Out/Pressed)
public float SpikeLiveTime; // deadly duration to run once SpikesAdding finishes growing
public SpriteRenderer Sprite;
public float StickyTime;
public SpriteRenderer StickyGoo;
public bool PressedUnderSpikes; // was Pressed when spikes were grafted; meaningful only while HasSpikes
public bool IsPressed => Mode == SideMode.Pressed;
public bool HasSpikes => Mode is SideMode.SpikesAdding or SideMode.Spiked or SideMode.SpikesRetracting;
// "Switching" = mid transition animation, so harmless + unpressable (Out/Pressed/Spiked are settled).
public bool IsSwitching => Mode is SideMode.SpikesAdding or SideMode.SpikesRetracting or SideMode.Retracting or SideMode.Popping;
}
readonly Dictionary<Direction, SideState> _side = new();
bool _spikeBlinkOn;
const float SPIKE_ADD_TIME = 0.25f;
const float SPIKE_RETRACT_TIME = 0.2f;
// A lethal surface always keeps a tell: spiked sides and sticky goo never fade below these
// on an invisible (Smile) block, even though the body fades to 0.
const float SPIKE_MIN_ALPHA = 0.35f;
const float STICKY_GOO_INVIS_FACTOR = 0.6f;
// Button pop animation. On phase-up the pressed button retracts in, then the new phase's button
// pops back out (rather than snapping); a spike retract likewise ends by popping a fresh button
// out. Mirrors the original's UnpressSideCoroutine.
const float BTN_RETRACT_TIME = 0.15f;
const float BTN_POP_OUT_TIME = 0.2f; // out0/out1 pop (frames 0->3)
const float BTN_POP_MAX_TIME = 0.15f; // max pop (frames 0->2)
// Public so the level editor's spawn previews tint their unpressed phase-1 buttons the same way.
public static readonly Color PHASE_1_OUT_TINT = new Color( 1f, 0.65f, 0.65f );
const float STICKY_GOO_FADE_SPEED = 14f;
const float STICKY_GOO_DIM_ALPHA = 0.35f;
const float STICKY_GOO_BRIGHT_ALPHA = 0.82f;
const float STICKY_GOO_PULSE_SPEED = 10f;
float _stickyGooPulse;
public bool IsSidePressed( Direction dir ) => _side[dir].IsPressed;
/// <summary>Pressed, OR pressed-but-suspended under a spike graft: the player's earned progress on this
/// side. Drives phase-up and the final tally. <see cref="IsSidePressed"/> is the strict button state
/// (a spiked side is never pressable / never a button).</summary>
public bool IsSidePressedOrSuspended( Direction dir )
{
var s = _side[dir];
return s.IsPressed || (s.HasSpikes && s.PressedUnderSpikes);
}
/// <summary>Every side's full sim state packed 4 bits each (L,R,U,D): mode + suspended-press flag. For
/// the replay checkpoint hash — Switching/Spiked/suspended all change what the next press does.</summary>
public int SideStateCode()
{
int code = 0, shift = 0;
foreach ( var dir in Globals.GetAllDirections() )
{
var s = _side[dir];
code |= ((int)s.Mode | (s.PressedUnderSpikes ? 8 : 0)) << shift;
shift += 4;
}
return code;
}
public bool WasSidePressedAtScoreStart( Direction dir ) => dir switch
{
Direction.Left => _scoreStartLeft,
Direction.Right => _scoreStartRight,
Direction.Up => _scoreStartUp,
Direction.Down => _scoreStartDown,
_ => false,
};
/// <summary>Record authored progress that existed before gameplay so the score and tally can
/// exclude it. Kept separate from <see cref="SetInitialPhase"/>, which mimic transforms also use.</summary>
public void SetScoreStart( int phase, IReadOnlyList<Direction> pressedSides = null )
{
ScoreStartPhase = Math.Clamp( phase, 0, NUM_PHASES - 1 );
_scoreStartLeft = pressedSides?.Contains( Direction.Left ) ?? false;
_scoreStartRight = pressedSides?.Contains( Direction.Right ) ?? false;
_scoreStartUp = pressedSides?.Contains( Direction.Up ) ?? false;
_scoreStartDown = pressedSides?.Contains( Direction.Down ) ?? false;
}
public void CopyScoreStartFrom( Block other )
{
ScoreStartPhase = other.ScoreStartPhase;
_scoreStartLeft = other._scoreStartLeft;
_scoreStartRight = other._scoreStartRight;
_scoreStartUp = other._scoreStartUp;
_scoreStartDown = other._scoreStartDown;
}
// Shake (per-layer visual offset).
Vector2 _shakeVector;
const float SHAKE_RECOVERY = 0.5f;
const float SHAKE_STRENGTH = 0.02f;
float _stunTimer;
float _stunParticleTimer;
bool _resumeAfterStun;
const float STUN_PARTICLE_INTERVAL = 0.11f;
static readonly Color STUN_SPARK_COLOR = new( 0.45f, 0.92f, 1f );
static readonly Color STUN_SPARK_HIGHLIGHT = new( 1f, 0.9f, 0.35f );
public bool IsStunned => _stunTimer > 0f;
// Layered sprite renderers.
protected string SpritePath;
protected SpriteRenderer _face, _eyes, _mouth, _eyebrows;
float _layersAlpha = 1f;
// Speed lines (trailing motion streaks).
class SpeedLine { public int offset; public float length; public float acceleration; }
readonly List<SpeedLine> _speedLines = new();
readonly List<SpriteRenderer> _speedLineSprites = new();
const int MAX_SPEED_LINES = 6;
static readonly Color SPEEDLINE_COLOR = new Color( 248 / 255f, 245 / 255f, 230 / 255f );
// Palette blink (phase 1/2 the FACE alternates between phase colour and the phase-0 colour;
// the other layers have no alt art and never blink).
float _paletteBlinkTimer;
bool _paletteAlt;
/// <summary>When set, the palette blink toggles this face anim (+"_alt") instead of the regular
/// face_{Phase} — e.g. "face_shooting_2" while the Dragon family shoots, so the blink keeps
/// going through the attack.</summary>
protected string FaceBlinkAnimOverride;
/// <summary>When set, the eye blink cycle leaves the eyes alone (e.g. Squid's shooting eyes).</summary>
protected bool SuppressEyeBlink;
const float PALETTE_BLINK_TIME_PHASE_1 = 0.15f;
const float PALETTE_BLINK_TIME_PHASE_2 = 0.1f;
const float PALETTE_BLINK_ON_TIME = 0.05f;
static readonly Vector2 BLOCK_SIZE = new Vector2( 40, 40 );
/// <summary>Native pixel size of the side-spike frames: 42x42 so the teeth overhang the
/// block edge by 1px (button art is 40x40 like the block).</summary>
static readonly Vector2 SPIKE_FRAME_SIZE = new Vector2( 42, 42 );
/// <summary>Play a side-layer animation with the quad sized to the art's NATIVE pixels
/// (spikes 42, buttons 40). Rendering the 42px spike art in the 40px box shrank it ~5%,
/// which the retro CRT pipeline's 240px grid turns into dropped tooth rows that crawl as
/// the block moves at subpixel positions — native size keeps every texel 1:1 on the grid.</summary>
static void PlaySideAnim( SpriteRenderer sprite, string anim )
{
sprite.Size = anim.StartsWith( "spikes", StringComparison.Ordinal ) ? SPIKE_FRAME_SIZE : BLOCK_SIZE;
sprite.PlayAnimation( anim );
}
/// <summary>Spike anim for a side in its current spike mode (grow / settled / retract). Coloured like
/// the button underneath: max yellow on a maxed block, the pressed colour while a press is suspended
/// under the spikes, else plain (see tools/bake.py SPIKE_PRESSED_BAR).</summary>
string SpikeAnim( SideState s, Direction dir )
{
string variant = Phase >= NUM_PHASES - 1 ? "_max" : s.PressedUnderSpikes ? $"_pressed{Phase}" : "";
string stage = s.Mode == SideMode.SpikesAdding ? "_add" : s.Mode == SideMode.SpikesRetracting ? "_retract" : "";
return $"spikes{variant}{stage}_{Dir( dir )}";
}
/// <summary>What a settled (Spiked) side shows right now: the shared red alt frame during the deadly
/// blink's on-window, else its coloured spikes.</summary>
string SettledSpikeAnim( SideState s, Direction dir ) => _spikeBlinkOn ? $"spikes_alt_{Dir( dir )}" : SpikeAnim( s, dir );
// ----------------------------------------------------------------------------------------
public void Setup( BlockType type, string spritePath )
{
BlockType = type;
SpritePath = spritePath;
Size = BLOCK_SIZE;
Depth = Globals.DEPTH_BLOCK;
MoveDirection = Globals.GetRandomDirection();
_moveSpeed = 0f;
Phase = 0;
foreach ( var dir in Globals.GetAllDirections() )
{
// Setup runs before CreateVisuals (which assigns each side's Sprite), so create the state
// here and leave the renderer to be wired up there.
var s = _side.TryGetValue( dir, out var existing ) ? existing : (_side[dir] = new SideState());
s.Mode = SideMode.Out;
s.Timer = 0f;
s.SpikeLiveTime = 0f;
s.StickyTime = 0f;
}
InitSpeedLines();
OnSetup();
}
/// <summary>Hook: called at the end of <see cref="Setup"/> (before CreateVisuals). Subtypes override to
/// adjust per-type tunables such as the per-phase <see cref="_accelerations"/>/<see cref="_maxSpeeds"/>.</summary>
protected virtual void OnSetup() { }
void InitSpeedLines()
{
_speedLines.Clear();
int n = Rng.Int( 1, 6 );
for ( int i = 0; i < n; i++ )
_speedLines.Add( new SpeedLine { offset = Rng.Int( 1, (int)BLOCK_SIZE.x - 1 ), length = 0f, acceleration = Rng.Float( 0.0001f, 0.0021f ) } );
}
public bool HasSpikes( Direction dir ) => _side[dir].HasSpikes;
public bool IsSwitchingSide( Direction dir ) => _side[dir].IsSwitching;
/// <summary>Single "deadly now" predicate for a block side: spikes present AND settled (not mid
/// grow-in / retract). Symmetric with <c>GameStage.WallDeadlyAt</c> / <c>ObstacleFaceDeadlyAt</c>
/// so kill checks never have to AND HasSpikes with !IsSwitchingSide by hand.</summary>
public bool SideDeadly( Direction dir ) => _side[dir].HasSpikes && !_side[dir].IsSwitching;
public bool HasAnySpikes() => HasSpikes( Direction.Left ) || HasSpikes( Direction.Right ) || HasSpikes( Direction.Down ) || HasSpikes( Direction.Up );
/// <summary>Sticky strength currently coating this face: 0 for none, 1 for movable glue, 2 for locking glue.</summary>
public virtual int StickyPhase( Direction dir ) => !IsDead && _side[dir].StickyTime > 0f ? 2 : 0;
/// <summary>Temporarily coat one face in phase-2 sticky goo. Reapplication refreshes its lifetime.</summary>
public void AddStickyToSide( Direction dir, float time )
{
var s = _side[dir];
s.StickyTime = MathF.Max( s.StickyTime, time );
if ( s.StickyGoo is null )
{
s.StickyGoo = SpriteLayer.Add( GameObject, "sprites/blocks/sticky_goo.sprite",
BLOCK_SIZE, $"goo_{Dir( dir )}", childOrder: 6 );
s.StickyGoo.Opaque = false;
s.StickyGoo.AlphaCutoff = 0f;
s.StickyGoo.Color = new Color( 1f, 1f, 1f, 0f );
}
s.StickyGoo.Enabled = true;
}
/// <summary>Add deadly spikes to a side for <paramref name="time"/> seconds (called by Spikey).
/// Plays the grow-in animation; the side is "switching" (harmless, unpressable) until it lands.</summary>
public void AddSpikesToSide( Direction dir, float time )
{
// Enter the spike branch of the side's state machine. Because a side has exactly ONE mode, this
// unconditionally cancels any in-flight button pop/retract — no separate ticker can finish later
// and stomp the spike sprite, so the old "deadly side rendering as a button" bug is now
// unrepresentable rather than merely patched.
var s = _side[dir];
// Suspend (not erase) an existing press. A re-graft onto an already spiked side (two Spikeys' delayed
// sends landing in a row) keeps the press it is already holding.
s.PressedUnderSpikes = s.Mode == SideMode.Pressed || (s.HasSpikes && s.PressedUnderSpikes);
s.Mode = SideMode.SpikesAdding;
s.Timer = SPIKE_ADD_TIME;
s.SpikeLiveTime = time; // deadly countdown begins once the grow-in finishes (see TickSides)
RefreshSideTint( s );
PlaySideAnim( s.Sprite, SpikeAnim( s, dir ) );
Audio.PlaySfx( SfxType.SpikesAdd, Position );
}
/// <summary>Carry another block's spike grafts over (mimic swap): same modes + timers, so the sim is
/// unchanged. The presses under them were consumed by the phase-up that triggered the swap.</summary>
public void CopySpikesFrom( Block other )
{
foreach ( var dir in Globals.GetAllDirections() )
{
var o = other._side[dir];
if ( !o.HasSpikes ) continue;
var s = _side[dir];
s.Mode = o.Mode;
s.Timer = o.Timer;
s.SpikeLiveTime = o.SpikeLiveTime;
s.PressedUnderSpikes = false;
RefreshSideTint( s );
PlaySideAnim( s.Sprite, s.Mode == SideMode.Spiked ? SettledSpikeAnim( s, dir ) : SpikeAnim( s, dir ) );
}
}
/// <summary>Carry another block's transferred sticky goo over (mimic swap): each face keeps its remaining
/// glue time, so a player stuck to the mimic stays stuck for exactly as long as they would have.</summary>
public void CopyStickyFrom( Block other )
{
foreach ( var dir in Globals.GetAllDirections() )
{
float time = other._side[dir].StickyTime;
if ( time > 0f ) AddStickyToSide( dir, time );
}
}
void RetractSpikes( Direction dir )
{
var s = _side[dir];
s.Mode = SideMode.SpikesRetracting;
s.Timer = SPIKE_RETRACT_TIME;
PlaySideAnim( s.Sprite, SpikeAnim( s, dir ) );
Audio.PlaySfx( SfxType.SpikesRetract, Position );
}
/// <summary>Advance every side's state machine one step (called from Tick). Replaces the old
/// separate button-pop and spike add/retract tickers: one machine per side, so a side can never be
/// simultaneously mid-button-pop and spiked (which is what let the pop stomp the spike sprite).</summary>
void TickSides( float dt )
{
foreach ( var dir in Globals.GetAllDirections() )
{
var s = _side[dir];
switch ( s.Mode )
{
case SideMode.Retracting:
if ( (s.Timer -= dt) <= 0f )
StartButtonPop( dir ); // retract done -> pop the new phase's button out
break;
case SideMode.Popping:
if ( (s.Timer -= dt) <= 0f )
{
s.Mode = SideMode.Out; // settle on the resting button
PlaySideAnim( s.Sprite, $"{SideOutState()}_{Dir( dir )}" );
}
break;
case SideMode.SpikesAdding:
if ( (s.Timer -= dt) <= 0f )
{
s.Mode = SideMode.Spiked; // spikes are now live + deadly
s.Timer = s.SpikeLiveTime; // begin the deadly-duration countdown
PlaySideAnim( s.Sprite, SettledSpikeAnim( s, dir ) );
}
break;
case SideMode.Spiked:
if ( (s.Timer -= dt) <= 0f )
RetractSpikes( dir );
break;
case SideMode.SpikesRetracting:
if ( (s.Timer -= dt) <= 0f )
{
if ( s.PressedUnderSpikes ) RestoreSuspendedPress( dir ); // spikes gone -> press comes back
else StartButtonPop( dir ); // spikes gone -> pop a fresh, pressable button out
}
break;
}
}
}
void TickStickySides( float dt )
{
_stickyGooPulse += dt * STICKY_GOO_PULSE_SPEED;
float pulse = 0.5f + 0.5f * MathF.Sin( _stickyGooPulse );
float activeAlpha = STICKY_GOO_DIM_ALPHA + (STICKY_GOO_BRIGHT_ALPHA - STICKY_GOO_DIM_ALPHA) * pulse;
float step = Math.Clamp( dt * STICKY_GOO_FADE_SPEED, 0f, 1f );
foreach ( var dir in Globals.GetAllDirections() )
{
var s = _side[dir];
if ( s.StickyTime > 0f )
s.StickyTime = MathF.Max( 0f, s.StickyTime - dt );
if ( s.StickyGoo is null ) continue;
var color = s.StickyGoo.Color;
float targetAlpha = s.StickyTime > 0f && !IsDead
? activeAlpha * MathF.Max( _layersAlpha, STICKY_GOO_INVIS_FACTOR )
: 0f;
color.a += (targetAlpha - color.a) * step;
s.StickyGoo.Color = color;
if ( s.StickyTime <= 0f && color.a <= 0.01f )
s.StickyGoo.Enabled = false;
}
}
// ----------------------------------------------------------------------------------------
public virtual void CreateVisuals()
{
_face = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, "face_0", childOrder: 0 );
_mouth = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, "mouth_0", childOrder: 2 );
_eyes = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, EyeAnim(), childOrder: 3 );
_eyebrows = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, "eyebrows_0", childOrder: 4 );
RefreshBodyTint();
// Side buttons use pre-rotated baked sprites per direction (no runtime rotation).
foreach ( var dir in Globals.GetAllDirections() )
{
var s = _side[dir];
s.Sprite = SpriteLayer.Add( GameObject, "sprites/sides.sprite", BLOCK_SIZE, $"out0_{Dir( dir )}", childOrder: 1 );
RefreshSideTint( s );
}
// Body layers follow SpriteLayer's default opacity; fades flip to translucent via
// SetLayersAlpha/SetLayersTranslucent and back at full alpha.
// Speed-line streaks (drawn behind the block).
for ( int i = 0; i < MAX_SPEED_LINES; i++ )
{
var sr = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", new Vector2( 1, 1 ), "idle", childOrder: -1 );
sr.Color = SPEEDLINE_COLOR;
sr.Enabled = false;
_speedLineSprites.Add( sr );
}
}
/// <summary>Force this block to <paramref name="phase"/> and refresh the face/eye/mouth/eyebrow +
/// resting side buttons to match, WITHOUT running the phase-up transition or scoring. Used when the
/// stage swaps a mimic out for a real disguise block spawned directly at the phase it just reached,
/// and when a level authors a block's starting phase / pre-pressed sides
/// (see <see cref="LevelDef.BlockStarts"/>).</summary>
/// <param name="phase">Phase to force (clamped to 0..<see cref="NUM_PHASES"/>-1).</param>
/// <param name="pressedSides">Optional sides to mark pre-pressed for this phase (below max phase):
/// the buttons render depressed, but scoring / sfx / phase-up do NOT fire — the block just starts
/// partway through its current phase.</param>
public void SetInitialPhase( int phase, IReadOnlyList<Direction> pressedSides = null )
{
Phase = Math.Clamp( phase, 0, NUM_PHASES - 1 );
RefreshBodyTint();
_face.PlayAnimation( $"face_{Phase}" );
_eyebrows.PlayAnimation( $"eyebrows_{Phase}" );
_mouth.PlayAnimation( $"mouth_{Phase}" );
_eyes.PlayAnimation( EyeAnim() );
foreach ( var dir in Globals.GetAllDirections() )
{
var s = _side[dir];
s.Mode = SideMode.Out;
s.Timer = 0f;
RefreshSideTint( s );
PlaySideAnim( s.Sprite, $"{SideOutState()}_{Dir( dir )}" );
}
// Pre-pressed sides: mark the buttons down for this phase without any scoring/sfx/phase-up. Only
// meaningful below max phase (a maxed block has no pressable buttons). IsSidePressed reads Mode ==
// Pressed; the separate score-start snapshot lets the tally distinguish these from earned presses.
if ( pressedSides is not null && Phase < NUM_PHASES - 1 )
{
foreach ( var dir in pressedSides )
{
if ( dir == Direction.None ) continue;
ShowPressed( dir );
}
}
}
/// <summary>Set the travel direction (used when a mimic transform carries the disguise's heading over
/// so it does not visibly stop dead on the swap).</summary>
public void SetMoveDirection( Direction dir ) => MoveDirection = dir;
/// <summary>Turn to face <paramref name="dir"/> mid-travel: updates <see cref="MoveDirection"/> and,
/// when the eyes are steady-open, replays the matching eye animation (during a close/open the eye
/// cycle owns them and re-resolves the direction itself). For subtypes whose facing follows a free
/// velocity (BlockWisp) rather than being fixed per leg.</summary>
protected void SetFacing( Direction dir )
{
if ( dir == MoveDirection || dir == Direction.None ) return;
MoveDirection = dir;
if ( _eyeState == EyeState.Open && !IsDead )
_eyes?.PlayAnimation( EyeAnim() );
}
/// <summary>Close the eyes before facing <paramref name="dir"/> without stopping movement or running
/// recovery hooks. Requests keep updating the gaze while the eyes are closing or fully shut.</summary>
protected void FaceAfterBlink( Direction dir, float minimumOpenTime, float eyesClosedTime )
{
if ( IsDead || dir == Direction.None || SuppressEyeBlink || !CanActivateTimedAbility ) return;
if ( _facingEyeCycle && _eyeState is EyeState.Closing or EyeState.Waiting )
{
_eyeCycleFacingOverride = dir;
return;
}
if ( _eyeState != EyeState.Open || dir == MoveDirection || _eyesOpenTime < minimumOpenTime ) return;
_facingEyeCycle = true;
_eyeCycleFacingOverride = dir;
_facingEyesClosedTime = MathF.Max( 0f, eyesClosedTime );
StartEyeCycle( enclosedEyeCycle: false );
}
/// <summary>Pulse the transformed-mimic tint and emit occasional drifting cloud puffs. The pulse
/// follows game time; particle rolls use only the cosmetic Rng stream.</summary>
void TickMimicAura( float dt )
{
_mimicTintTime = (_mimicTintTime + dt) % MIMIC_TINT_PERIOD;
RefreshBodyTint();
_mimicAuraTimer -= dt;
if ( _mimicAuraTimer > 0f ) return;
_mimicAuraTimer = Rng.CosmeticFloat( 0.14f, 0.24f );
var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
var offset = new Vector2(
Rng.CosmeticFloat( -BLOCK_SIZE.x * 0.4f, BLOCK_SIZE.x * 0.4f ),
Rng.CosmeticFloat( -BLOCK_SIZE.y * 0.3f, BLOCK_SIZE.y * 0.5f ) );
var vel = new Vector2( Rng.CosmeticFloat( -6f, 6f ), Rng.CosmeticFloat( 8f, 20f ) );
Stage.AddMimicCloud( Pos + offset, vel, kind, Rng.CosmeticFloat( 0.4f, 0.7f ), Rng.CosmeticInt( 2, 4 ) );
}
static string Dir( Direction d ) => Globals.GetStringForDirection( d );
string EyeAnim() => $"eyes_{Globals.GetStringForDirection( MoveDirection )}_{Phase}";
/// <summary>Restore the face/mouth to the current phase (used by subtypes after attacking).</summary>
protected void RestoreFaceAndMouth()
{
_face.PlayAnimation( $"face_{Phase}" );
_mouth.PlayAnimation( $"mouth_{Phase}" );
}
// ======================================================================================
public override void Tick( float dt )
{
Vector2 stepStart = Pos;
BecameSolidThisTick = false;
SlammedThisTick = false; // set again below only if we land a hard slam this tick
InertiaHandoffThisTick = false; // ...or come to rest in a way that flings an attached player
_enclosedThisTick = IsBlockedInEveryDirection();
bool stunned = TickStun( dt );
// Shake decay (applied to layer offsets each step).
ApplyShakeOffsets( _shakeVector );
_shakeVector *= SHAKE_RECOVERY;
_shakeVector *= -1f;
// While dead (win sequence) keep the death face: the eye-cycle and palette-blink replay
// the live phase art each frame and would stomp the dead expression set in Die() (the
// original only swapped palette SwatchIndex, not the image, so its dead face survived).
if ( !IsDead )
{
// An active subtype animation owns its eyes. If an AMBIENT enclosed blink was already in
// progress, hold that cycle until the ability finishes rather than stomping its eye art.
// Ability-initiated cycles are never held: a subtype whose attack fires from OnEyesClosed
// (Siren) is busy until that very cycle advances, so holding it would deadlock.
if ( !stunned && !SuppressEyeBlink && (!_ambientEnclosedBlink || CanActivateTimedAbility) )
TickEyeCycle( dt );
if ( !stunned ) TickEnclosedEyes( dt );
BlinkPalette( dt );
}
TickSides( dt );
TickStickySides( dt );
TickEnclosedAbility( dt );
// The slow blue pulse and cloud shimmer identify a transformed mimic. Cosmetic only.
if ( Mimic != null && Phase >= 1 && !IsDead ) TickMimicAura( dt );
// Death throes (win condition): every block jitters violently and sheds dust during the
// post-win hold before the score tally. GameStage emits the shared explosion-sfx cadence so
// adding blocks doesn't make the mix progressively louder.
if ( IsDead )
{
float DEATH_SHAKE_STRENGTH = 70f;
AddShake( new Vector2( Rng.Float( -1f, 1f ), Rng.Float( -1f, 1f ) ) * DEATH_SHAKE_STRENGTH * dt );
_moveSpeed *= 0.97f;
if ( Rng.Value() < 0.10f ) AddDeathDust();
}
if ( _enclosedThisTick )
{
// Movement is suppressed, so collision consumers must see a stationary solid. Keep
// _moveSpeed (and subtype-owned intent) intact so the block can resume when freed.
Velocity = Vector2.Zero;
DecaySpeedLines( 0.5f );
RenderSpeedLines();
}
else
{
TickMovement( dt );
}
StepDisplacement = Pos - stepStart - _stepJumpDelta;
_stepJumpDelta = Vector2.Zero;
}
bool TickStun( float dt )
{
if ( _stunTimer <= 0f ) return false;
_stunTimer -= dt;
if ( _stunTimer <= 0f )
{
_stunTimer = 0f;
if ( _resumeAfterStun ) IsStopped = false;
return false;
}
IsStopped = true;
Velocity = Vector2.Zero;
_moveSpeed = 0f;
AddShake( new Vector2( Rng.CosmeticFloat( -0.7f, 0.7f ), Rng.CosmeticFloat( -0.35f, 0.35f ) ) );
_stunParticleTimer -= dt;
if ( _stunParticleTimer <= 0f )
{
_stunParticleTimer += STUN_PARTICLE_INTERVAL;
EmitStunBolt();
}
return true;
}
void EmitStunBolt()
{
const float FACE_INSET = 2f;
Vector2 origin = new(
Rng.CosmeticFloat( Left + FACE_INSET, Right - FACE_INSET ),
Rng.CosmeticFloat( Bottom + FACE_INSET, Top - FACE_INSET ) );
float angle = Rng.CosmeticFloat( 0f, MathF.PI * 2f );
Vector2 forward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
Vector2 side = new( -forward.y, forward.x );
int segmentCount = Rng.CosmeticInt( 5, 10 );
int branchAt = Rng.CosmeticValue() < 0.45f ? Rng.CosmeticInt( 1, segmentCount - 1 ) : -1;
float stepLength = Rng.CosmeticFloat( 1.9f, 2.8f );
float bend = Rng.CosmeticFloat( 1f, 2.2f );
float bendSign = Rng.CosmeticValue() < 0.5f ? -1f : 1f;
float center = (segmentCount - 1) * 0.5f;
for ( int i = 0; i < segmentCount; i++ )
{
float zig = (i % 2 == 0 ? -bend : bend) * bendSign;
Vector2 pos = origin + forward * ((i - center) * stepLength) + side * zig;
pos = new Vector2(
Math.Clamp( pos.x, Left + FACE_INSET, Right - FACE_INSET ),
Math.Clamp( pos.y, Bottom + FACE_INSET, Top - FACE_INSET ) );
Color color = Rng.CosmeticValue() < 0.3f ? STUN_SPARK_HIGHLIGHT : STUN_SPARK_COLOR;
Stage.AddColoredParticle( pos, Vector2.Zero, 1f, color,
Rng.CosmeticFloat( 0.1f, 0.2f ), 2, Globals.DEPTH_PARTICLE_1 );
if ( i != branchAt ) continue;
Vector2 branchDirection = Utils.Normalized( side * (Rng.CosmeticValue() < 0.5f ? -1f : 1f) + forward * 0.35f );
int branchLength = Rng.CosmeticInt( 2, 5 );
for ( int branchStep = 1; branchStep <= branchLength; branchStep++ )
{
Vector2 branchPos = pos + branchDirection * (stepLength * branchStep);
branchPos = new Vector2(
Math.Clamp( branchPos.x, Left + FACE_INSET, Right - FACE_INSET ),
Math.Clamp( branchPos.y, Bottom + FACE_INSET, Top - FACE_INSET ) );
Stage.AddColoredParticle( branchPos, Vector2.Zero, 1f, STUN_SPARK_HIGHLIGHT,
Rng.CosmeticFloat( 0.09f, 0.17f ), 2, Globals.DEPTH_PARTICLE_1 );
}
}
}
void TickEnclosedEyes( float dt )
{
if ( !_enclosedThisTick )
{
_enclosedEyeTimer = -1f;
_enclosedEyeCycle = false;
_ambientEnclosedBlink = false;
return;
}
// The authored on/off loop owns the eyes: an ambient blink is a surprise lane lull, so the
// loop's cadence must be the only one. (_enclosedEyeCycle stays untouched — the loop's own
// cycles set it and TickEyeCycle clears it on completion.)
if ( EnclosedEyeLoopActive )
{
_enclosedEyeTimer = -1f;
return;
}
if ( _eyeState != EyeState.Open || SuppressEyeBlink || !CanActivateTimedAbility ) return;
if ( _enclosedEyeTimer < 0f )
_enclosedEyeTimer = Rng.Float( ENCLOSED_EYE_INTERVAL_MIN, ENCLOSED_EYE_INTERVAL_MAX );
_enclosedEyeTimer -= dt;
if ( _enclosedEyeTimer > 0f ) return;
CloseEyesAndPickNewDirection();
_enclosedEyeCycle = true;
_ambientEnclosedBlink = true;
_enclosedEyeTimer = -1f;
}
void TickEnclosedAbility( float dt )
{
if ( IsDead || IsStunned || Phase < 1 || !_enclosedThisTick ||
(!HasSlamActivatedAbility() && !HasEnclosedEyeLoop) )
{
_abilityTimer = -1f;
return;
}
// The authored spawn offset advances only the first interval. Re-entering an enclosure later
// always arms a complete interval, matching the cadence after an activation.
if ( _abilityTimer < 0f )
{
ResetAbilityTimer( _abilityStartOffsetPending ? _abilityStartOffset : 0f );
_abilityStartOffsetPending = false;
}
// The lane on/off loop: the interval measures LANE-ON time only, so the countdown runs only
// while the eyes are fully open. Firing just closes the eyes (direction stays pinned — see
// GetEnclosedGazeDirection); the eye cycle's Waiting leg then holds the authored ClosedTime
// before re-opening, and the next interval arms on this fire (it can't tick until re-open).
if ( EnclosedEyeLoopActive )
{
// Zero off-time = the loop is OFF: a permanently-on lane (otherwise it would blink
// pointlessly — close and immediately reopen — every interval).
if ( _abilityClosedTime <= 0f ) return;
if ( _eyeState != EyeState.Open ) return;
_abilityTimer -= dt;
if ( _abilityTimer > 0f ) return;
CloseEyesAndPickNewDirection();
ResetAbilityTimer();
return;
}
// While the subtype's long-running attack is busy (a Laser mid-volley, a Siren mid-song), the
// countdown PAUSES: the interval only elapses over idle time, so the next attack lands a full
// interval after the previous one ends. Pausing (rather than letting the timer run negative)
// also keeps the "< 0" sentinel above meaning exclusively "not armed" — a timer that expired
// under a busy attack used to trip it and re-arm a fresh interval, making the post-attack gap
// a random fraction of the interval instead of a dependable beat.
if ( !CanActivateTimedAbility ) return;
_abilityTimer -= dt;
if ( _abilityTimer > 0f ) return;
IsColliding( MoveDirection, out Entity2D other );
float previousSpeed = _moveSpeed;
bool previousSlammed = SlammedThisTick;
_timedAbilityActivation = true;
_moveSpeed = _maxSpeeds[Phase];
SlammedThisTick = true;
LastImpactOther = other;
try
{
Impact( other as Block );
}
finally
{
_timedAbilityActivation = false;
_moveSpeed = previousSpeed;
SlammedThisTick = previousSlammed;
}
ResetAbilityTimer();
}
void ResetAbilityTimer( float elapsed = 0f )
{
float jitter = _abilityRandomness > 0f ? Rng.Float( -_abilityRandomness, _abilityRandomness ) : 0f;
_abilityTimer = MathF.Max( 0.05f, _abilityInterval + jitter - elapsed );
}
bool IsBlockedInEveryDirection()
{
foreach ( Direction direction in Globals.GetAllDirections() )
if ( !IsEnclosedByObstacleOrArena( direction ) ) return false;
return true;
}
bool IsEnclosedByObstacleOrArena( Direction direction )
{
Vector2 offset = Globals.GetVectorForDirection( direction );
float x = X + offset.x;
float y = Y + offset.y;
if ( !IsInBounds( x, y, direction ) ) return true;
foreach ( Obstacle obstacle in Stage.GetObstacles() )
{
if ( obstacle.IsGlass ) continue; // glass is solid only to players — it can't box a block in
if ( PenetratesOnPixelGrid( x, y, obstacle ) ) return true;
}
// Fences are solid to blocks, so they enclose exactly like a normal obstacle does.
foreach ( Obstacle fence in Stage.GetFences() )
if ( PenetratesOnPixelGrid( x, y, fence ) ) return true;
return false;
}
/// <summary>What a laser block's fresh aim refuses to point into: nothing (a beam stopped by nothing),
/// arena walls only (a piercing beam), or walls plus the obstacles that stop the beam.</summary>
protected enum AimAvoid { Nothing, Walls, WallsAndObstacles }
/// <summary>True when a laser-blocking surface sits flush against this block's <paramref name="direction"/>
/// face. Arena walls always count; obstacles only when <paramref name="includeObstacles"/> — and glass and
/// fences never do, since beams pass straight through them.</summary>
protected bool IsTouchingBeamBlocker( Direction direction, bool includeObstacles )
{
Vector2 offset = Globals.GetVectorForDirection( direction );
float x = X + offset.x;
float y = Y + offset.y;
if ( !IsInBounds( x, y, direction ) ) return true;
if ( !includeObstacles ) return false;
foreach ( Entity2D obstacle in Stage.GetSolidObstacles() )
if ( PenetratesOnPixelGrid( x, y, obstacle ) ) return true;
return false;
}
/// <summary>Which of this block's faces a beam from <paramref name="origin"/> leaves through (the nearer
/// slab crossing of the block's own rect).</summary>
Direction BeamExitFace( Vector2 origin, Vector2 direction )
{
RectF rect = GetRect( X, Y );
float tx = direction.x > 0f ? (rect.Right - origin.x) / direction.x
: direction.x < 0f ? (rect.Left - origin.x) / direction.x : float.MaxValue;
float ty = direction.y > 0f ? (rect.Top - origin.y) / direction.y
: direction.y < 0f ? (rect.Bottom - origin.y) / direction.y : float.MaxValue;
if ( tx <= ty ) return direction.x > 0f ? Direction.Right : Direction.Left;
return direction.y > 0f ? Direction.Up : Direction.Down;
}
/// <summary>True when a beam fired from <paramref name="origin"/> along <paramref name="direction"/> exits
/// through a face that's flush against a blocker — it dies the instant it's born (a squid wedged in a
/// corner firing into that corner).</summary>
protected bool AimsIntoTouchingSurface( Vector2 origin, Vector2 direction, AimAvoid avoid )
=> avoid != AimAvoid.Nothing
&& direction.LengthSquared > 0.0001f
&& IsTouchingBeamBlocker( BeamExitFace( origin, direction ), avoid == AimAvoid.WallsAndObstacles );
/// <summary>Slide an aim off a surface the block is resting against: mirror it across that face (same
/// angle, other side), else run it along the face, else any still-open cardinal. Returns the aim
/// unchanged when it already points somewhere the beam can travel.</summary>
protected Vector2 DeflectAimOffSurfaces( Vector2 origin, Vector2 direction, AimAvoid avoid )
{
if ( !AimsIntoTouchingSurface( origin, direction, avoid ) ) return direction;
bool horizontal = BeamExitFace( origin, direction ) is Direction.Left or Direction.Right;
Vector2 mirrored = horizontal
? new Vector2( -direction.x, direction.y )
: new Vector2( direction.x, -direction.y );
if ( !AimsIntoTouchingSurface( origin, mirrored, avoid ) ) return mirrored;
Vector2 alongFace = horizontal
? new Vector2( 0f, direction.y >= 0f ? 1f : -1f )
: new Vector2( direction.x >= 0f ? 1f : -1f, 0f );
if ( !AimsIntoTouchingSurface( origin, alongFace, avoid ) ) return alongFace;
foreach ( Direction cardinal in Globals.GetAllDirections() )
if ( !IsTouchingBeamBlocker( cardinal, avoid == AimAvoid.WallsAndObstacles ) )
return Globals.GetVectorForDirection( cardinal );
return direction; // boxed in on every side: nothing to aim at, fire as asked
}
// Wind/Magnet are NOT here: their enclosed cadence is the lane on/off eye loop (HasEnclosedEyeLoop),
// not an interval-fired Impact.
bool HasSlamActivatedAbility() => BlockType is
BlockType.Dragon or BlockType.Squid or BlockType.SquidPierce or BlockType.Smile or BlockType.Spikey or
BlockType.Shockwave or BlockType.Slicer or BlockType.Teleport or BlockType.Summoner or
BlockType.Siren or BlockType.Laser or BlockType.Bullet or BlockType.DragonBounce or
BlockType.LaserPierce;
/// <summary>The movement half of <see cref="Tick"/>: integrate along <see cref="MoveDirection"/>,
/// resolve collisions, fire <see cref="Impact"/>, and drive the speed lines. Virtual so a subtype
/// with a fundamentally different mover (BlockWisp's free 2D float) can replace it while keeping
/// all the shared upkeep (eye cycle, sides, palette blink, shake) that Tick runs first.</summary>
protected virtual void TickMovement( float dt )
{
if ( IsStopped || IsDead )
{
DecaySpeedLines( 0.5f );
RenderSpeedLines();
return;
}
Velocity = Globals.GetVectorForDirection( MoveDirection ) * _moveSpeed;
Pos += GetMovementDelta( dt );
bool collidingForward = IsColliding( X, Y, MoveDirection, out Entity2D other );
// A corner graze — only a pixel of overlap on the cross axis — reads as "shouldn't have
// hit at all", so slide past it instead of impacting (when safely possible).
if ( collidingForward && other is Block grazedBlock && TryCornerSlip( grazedBlock ) )
collidingForward = IsColliding( X, Y, MoveDirection, out other );
if ( collidingForward )
{
// Push back out of a solid block OR interior obstacle (a bare arena wall leaves other=null
// and is handled by ClampToBounds below). Then Impact(other as Block) — an obstacle hit is
// null-block, i.e. treated exactly like a wall hit (stop + pick a new direction).
if ( other is Block || other is Obstacle )
Unpenetrate( X, Y, other );
_currentCollidingTime += dt;
if ( _moveSpeed > REQUIRED_COLLISION_SPEED || _currentCollidingTime >= REQUIRED_COLLISION_TIME )
{
LastImpactOther = other; // Block, Obstacle, statue Player, or null (bare arena wall) — see Spikey
PressFaceSlammedIntoStatue( other );
Impact( other as Block );
}
else
foreach ( var sl in _speedLines ) sl.length *= Rng.Float( 0.5f, 0.75f );
}
else if ( IsColliding( MoveDirection, out Entity2D pressedOther ) )
{
// Still pressing: after Unpenetrate we rest flush and re-advance by sub-pixel amounts,
// so the pixel-grid collision only registers every few ticks. Without this probe the
// in-between ticks would reset the contact timer (killing the slow-push
// REQUIRED_COLLISION_TIME impact entirely) and accelerate, turning every gentle lean
// into an eventual hard impact. Grid-based like the collision itself, so a sub-pixel
// corner graze still doesn't count as contact. The timer must fire the impact from
// here too: a block pressing at speed 0 (boxed in) never advances onto a colliding
// tick, and would otherwise hold forever instead of re-picking a direction.
_currentCollidingTime += dt;
if ( _currentCollidingTime >= REQUIRED_COLLISION_TIME )
{
LastImpactOther = pressedOther; // Block, Obstacle, statue Player, or null (bare arena wall) — see Spikey
PressFaceSlammedIntoStatue( pressedOther );
Impact( pressedOther as Block );
}
else
foreach ( var sl in _speedLines ) sl.length *= Rng.Float( 0.5f, 0.75f );
}
else
{
if ( !IsDead )
{
_moveSpeed += _accelerations[Phase] * dt;
_moveSpeed = Math.Clamp( _moveSpeed, 0f, _maxSpeeds[Phase] );
}
_currentCollidingTime = 0f;
foreach ( var sl in _speedLines ) sl.length += _moveSpeed * _moveSpeed * sl.acceleration * dt;
}
ClampToBounds( X, Y );
// Obstacles aren't part of ClampToBounds' arena-edge snap: push out of any we've drifted into
// (e.g. shoved by another block's Unpenetrate). Forward-motion hits are already resolved above.
foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
Unpenetrate( X, Y, ob );
RenderSpeedLines();
}
/// <summary>Slamming into the hardened Twin statue reads as the statue pressing back: award this
/// block's impacting face (the one facing travel), credited to the statue player. Fires from both
/// Impact triggers — the hard hit and the slow-lean timeout — since either ends with this face
/// planted against immovable stone. PressSide's own refusals (already pressed, spiked, switching,
/// max phase) still apply. Protected: BlockWisp's replacement mover slams via OnAxisHit (after
/// SetFacing has pointed MoveDirection at the impact) and must award the same press.</summary>
protected void PressFaceSlammedIntoStatue( Entity2D other )
{
if ( other is Player { IsDead: false, IsHardened: true } statue )
PressSide( MoveDirection, statue );
}
/// <summary>Returns this tick's translation. Specialized movers can cap a step without replacing
/// the shared collision, acceleration, speed-line, and displacement bookkeeping.</summary>
protected virtual Vector2 GetMovementDelta( float dt ) => Velocity * dt;
// ----------------------------------------------------------------------------------------
/// <summary>DEBUG (see <see cref="ShowDebugInfo"/>): draw this block's position, velocity and
/// intended move direction as world-space text just above the block. Called by GameManager once
/// per rendered frame after transform sync. Logical pixels map 1:1 to world XY (same convention
/// as Player.DrawCrushProbeOverlay), so positions can be used directly.</summary>
public void DrawDebugInfo()
{
float z = Globals.DepthToZ( Globals.DEPTH_TEXT );
var transform = new Transform( new Vector3( X, Y + 2f, z ) );
string info =
$"pos ({X:0}, {Y:0})\n" +
$"vel ({VelX:0}, {VelY:0})\n" +
$"dir {MoveDirection}";
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.Text( info, transform, "Consolas", 12 );
}
// ----------------------------------------------------------------------------------------
/// <summary>Shrink every speed line by <paramref name="factor"/> (the resting/stopped decay).</summary>
protected void DecaySpeedLines( float factor )
{
foreach ( var sl in _speedLines ) sl.length *= factor;
}
/// <summary>Grow the speed lines for one step of travel at <paramref name="speed"/> — the same
/// speed²-scaled growth the default mover applies, exposed for subtypes that move themselves.</summary>
protected void GrowSpeedLines( float speed, float dt )
{
foreach ( var sl in _speedLines ) sl.length += speed * speed * sl.acceleration * dt;
}
protected void RenderSpeedLines()
{
const float GAP = 2f;
for ( int i = 0; i < _speedLineSprites.Count; i++ )
{
var sr = _speedLineSprites[i];
if ( i >= _speedLines.Count ) { sr.Enabled = false; continue; }
var sl = _speedLines[i];
// Clamp to whole grid pixels: the block's rendered corners land on integer world
// coords (Entity2D.SyncTransform), so a whole-pixel length keeps both ends of the
// streak on cell boundaries, and the half-pixel perpendicular shift makes the 1px
// body fill exactly one 240-grid row/column instead of straddling two (which the
// CRT downsample turns into flickering/ghost double lines).
float len = MathF.Floor( sl.length );
if ( len < 1f ) { sr.Enabled = false; continue; }
sr.Enabled = true;
// offset is measured across the trailing edge; replicate the original layout.
Vector3 local;
Vector2 size;
float perpH = Height / 2f - sl.offset + 0.5f; // for horizontal movement
float perpV = -Width / 2f + sl.offset + 0.5f; // for vertical movement
switch ( MoveDirection )
{
case Direction.Left: // trails to the right (+X)
size = new Vector2( len, 1 );
local = new Vector3( Width / 2f + GAP + len / 2f, perpH, 0 );
break;
case Direction.Right: // trails to the left (-X)
size = new Vector2( len, 1 );
local = new Vector3( -(Width / 2f + GAP + len / 2f), perpH, 0 );
break;
case Direction.Down: // trails up (+Y)
size = new Vector2( 1, len );
local = new Vector3( perpV, Height / 2f + GAP + len / 2f, 0 );
break;
default: // Up: trails down (-Y)
size = new Vector2( 1, len );
local = new Vector3( perpV, -(Height / 2f + GAP + len / 2f), 0 );
break;
}
sr.Size = size;
sr.GameObject.LocalPosition = local + new Vector3( 0, 0, -SpriteLayer.LAYER_Z_STEP );
}
}
// ----------------------------------------------------------------------------------------
void BlinkPalette( float dt )
{
if ( Phase != 1 && Phase != 2 ) return;
_paletteBlinkTimer -= dt;
if ( _paletteBlinkTimer > 0f ) return;
_paletteAlt = !_paletteAlt;
// Only the face blinks (mouth/eyebrows/eyes have no alt art). A subtype may swap in its
// own face anim during an attack (e.g. the Dragon family's shooting face).
string faceAnim = FaceBlinkAnimOverride ?? $"face_{Phase}";
_face.PlayAnimation( _paletteAlt ? $"{faceAnim}_alt" : faceAnim );
_paletteBlinkTimer = _paletteAlt
? (Phase == 1 ? PALETTE_BLINK_TIME_PHASE_1 : PALETTE_BLINK_TIME_PHASE_2)
: PALETTE_BLINK_ON_TIME;
}
/// <summary>How long the block sits with eyes shut after picking a new direction, before it re-opens
/// and resumes moving — i.e. its post-slam recovery pause. Subtypes override to recover faster/slower
/// (e.g. BlockHunter shortens this at phase 2 so it gets moving again sooner). Draws from the
/// authoritative Rng, so overrides must too, to keep the sim deterministic for replays.</summary>
protected virtual float EyesStayClosedTime() => Rng.Float( EYES_STAY_CLOSED_TIME_MIN, EYES_STAY_CLOSED_TIME_MAX );
// ----------------------------------------------------------------------------------------
void TickEyeCycle( float dt )
{
switch ( _eyeState )
{
case EyeState.Open:
_eyesOpenTime += dt;
break;
case EyeState.Closing:
_eyeTimer -= dt;
if ( _eyeTimer <= 0f )
{
PickDirectionAfterClosing();
_eyeState = EyeState.Waiting;
// Enclosed lane-loop cycles hold the authored lane-off time; everything else uses
// the normal recovery pause. (Skipping EyesStayClosedTime here skips its Rng draw —
// deterministic, since the loop condition is itself deterministic.)
_eyeTimer = _facingEyeCycle ? _facingEyesClosedTime
: _enclosedEyeCycle && EnclosedEyeLoopActive ? _abilityClosedTime
: EyesStayClosedTime();
if ( !_facingEyeCycle )
OnEyesClosed(); // recovery eyes shut, new direction chosen, about to wait then re-open
}
break;
case EyeState.Waiting:
_eyeTimer -= dt;
if ( _eyeTimer <= 0f )
{
if ( _facingEyeCycle && _eyeCycleFacingOverride is Direction facingOverride )
MoveDirection = facingOverride;
else if ( _enclosedEyeCycle && _enclosedThisTick )
MoveDirection = GetEnclosedGazeDirection();
_eyes?.PlayAnimation( $"eyes_{Dir( MoveDirection )}_open_{Phase}" );
_eyeState = EyeState.Opening;
_eyeTimer = EYES_OPEN_TIME;
}
break;
case EyeState.Opening:
_eyeTimer -= dt;
if ( _eyeTimer <= 0f )
{
_eyes?.PlayAnimation( EyeAnim() );
_eyeState = EyeState.Open;
_enclosedEyeCycle = false;
_ambientEnclosedBlink = false;
_eyesOpenTime = 0f;
_eyeCycleFacingOverride = null;
if ( _facingEyeCycle )
{
_facingEyeCycle = false;
}
else
{
IsStopped = false;
_moveSpeed = 0f;
InitSpeedLines();
}
}
break;
}
}
void PickDirectionAfterClosing()
{
if ( _facingEyeCycle )
{
if ( _eyeCycleFacingOverride is Direction facingOverride )
MoveDirection = facingOverride;
return;
}
if ( !_enclosedEyeCycle || !_enclosedThisTick )
{
Direction previousDirection = MoveDirection;
// Cycle modes own the whole pick and draw no Rng — deterministic, since the mode is
// authored per spawn slot (same reasoning as the enclosed-loop draw skip in TickEyeCycle).
MoveDirection = TurnMode is TurnMode.Clockwise or TurnMode.CounterClockwise
? PickCycleDirection( previousDirection )
: EnforceAlwaysTurn( previousDirection, GetNewDirection() );
}
}
// ----------------------------------------------------------------------------------------
/// <summary>Default impact: play the effects, then close eyes and pick a new direction.
/// Subtypes override to insert an attack (and may defer the eye-cycle).</summary>
public virtual void Impact( Block otherBlock )
{
ImpactEffects( otherBlock );
CloseEyesAndPickNewDirection();
}
/// <summary>Stops this block without producing a slam, inertia handoff, or impact ability.</summary>
public void Stun( float duration )
{
if ( IsDead || duration <= 0f ) return;
if ( !IsStunned )
{
_resumeAfterStun = !IsStopped;
_stunParticleTimer = 0f;
Audio.PlaySfx( SfxType.LaserHitPlayer, Position, 0.8f, 1.2f );
}
_stunTimer = MathF.Max( _stunTimer, duration );
IsStopped = true;
Velocity = Vector2.Zero;
_moveSpeed = 0f;
_currentCollidingTime = 0f;
Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
}
/// <summary>Come to rest WITHOUT a slam — no screenshake, dust or impact sfx, and
/// <see cref="SlammedThisTick"/> stays false (so the sticky release and the slam-triggered block
/// attacks don't fire) — then run the normal eye-close → re-pick → open cycle. A subtype
/// (BlockHunter) uses this to brake mid-lane and re-aim at the player, instead of only ever
/// re-picking after a hard wall/block slam. Banks the travel velocity in
/// <see cref="PreImpactVelocity"/> and zeroes the live one exactly like <see cref="ImpactEffects"/>,
/// and flags <see cref="InertiaHandoffThisTick"/>: this is a full-speed brake, not a quiet grind
/// stop, so momentum-inheritance (a riding player) and crush checks behave the same as a slam.</summary>
protected void SoftStopAndRepick()
{
if ( IsStopped || IsDead ) return;
IsStopped = true;
InertiaHandoffThisTick = true;
PreImpactVelocity = Velocity;
Velocity = Vector2.Zero;
// Snap to the pixel grid on rest (clears cross-axis sub-pixel drift) — same reasoning as
// ImpactEffects; visually a no-op since rendering already rounds the position.
Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
_currentCollidingTime = 0f;
CloseEyesAndPickNewDirection();
}
/// <summary>Start the eye-close → re-pick → open → resume cycle (block stays stopped until it finishes).</summary>
protected void CloseEyesAndPickNewDirection()
{
if ( IsDead ) return;
if ( _facingEyeCycle )
{
UpgradeFacingBlinkToRecovery();
return;
}
_eyeCycleFacingOverride = null;
StartEyeCycle( _enclosedThisTick );
}
void UpgradeFacingBlinkToRecovery()
{
_facingEyeCycle = false;
_eyeCycleFacingOverride = null;
_enclosedEyeCycle = _enclosedThisTick;
if ( _eyeState == EyeState.Closing )
return; // finish the close already in progress; recovery owns the shut/open tail
if ( _eyeState == EyeState.Waiting )
{
PickDirectionAfterClosing();
_eyeTimer = EyesStayClosedTime();
OnEyesClosed();
return;
}
StartEyeCycle( _enclosedThisTick );
}
void StartEyeCycle( bool enclosedEyeCycle )
{
// Timed abilities commonly enter the normal post-impact eye cycle. Preserve enclosed gaze
// selection for those closes too; otherwise they reopen using GetNewDirection's random fallback.
// Whatever cycle was in flight is stomped here, so it is no longer the ambient blink — the
// caller that IS the ambient blink (TickEnclosedEyes) re-flags itself right after this returns.
_enclosedEyeCycle = enclosedEyeCycle;
_ambientEnclosedBlink = false;
_eyes?.PlayAnimation( $"eyes_{Dir( MoveDirection )}_close_{Phase}" );
_eyeState = EyeState.Closing;
_eyeTimer = EyesCloseTime;
_currentCollidingTime = 0f;
}
protected void ImpactEffects( Block otherBlock )
{
IsStopped = true;
if ( _timedAbilityActivation )
{
Velocity = Vector2.Zero;
Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
_currentCollidingTime = 0f;
return;
}
// Come to rest for real: bank the travel velocity for the momentum-inheritance mechanics
// (sticky slam release, inertia flings) and zero the live one. Tick early-outs on IsStopped
// without recomputing Velocity, so without this a stopped block would keep advertising its
// pre-impact velocity to every "is this block moving toward me?" query (phantom crushes).
PreImpactVelocity = Velocity;
Velocity = Vector2.Zero;
// Snap to the integer pixel grid the moment we come to rest. Blocks move with float
// velocity, so a block accumulates sub-pixel drift along its travel axis; once it stops
// and later turns 90°, that stale drift sits on the CROSS axis and makes its AABB overlap
// a perpendicular neighbour by a fraction of a pixel — so it halts at a corner even though
// (rounded for rendering) it looks like it should slide cleanly past with 0 gap. Rendering
// already rounds the position (Entity2D.SyncTransform), so snapping here is visually a
// no-op while keeping the resting field perfectly grid-aligned.
Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
// A slow contact still stops and re-picks after its grind window, but only a real slam gets
// impact feedback. Keeping every shake alongside the dust/audio prevents a quiet stop from
// producing a delayed visual jolt that reads like a second collision.
if ( _moveSpeed > REQUIRED_COLLISION_SPEED && _currentCollidingTime < REQUIRED_COLLISION_TIME )
{
SlammedThisTick = true; // a real slam (loud impact) — the sticky release keys off this
InertiaHandoffThisTick = true; // a slam always hands off momentum to attached players
Vector2 impactVector = new Vector2(
(MoveDirection == Direction.Left || MoveDirection == Direction.Right) ? 1f : 0f,
(MoveDirection == Direction.Down || MoveDirection == Direction.Up) ? 1f : 0f );
AddShake( impactVector * _moveSpeed * SHAKE_STRENGTH );
if ( MoveDirection == Direction.Left || MoveDirection == Direction.Right )
Stage.AddHorizontalScreenshake( _moveSpeed );
else
Stage.AddVerticalScreenshake( _moveSpeed );
float amt = Utils.Map( _moveSpeed, 0f, _maxSpeeds[Phase], 0f, 1f, true, EasingType.Linear );
int numParticles = (int)MathF.Floor( amt * 12f );
float velAdd = amt * 160f;
Vector2 pos = Position + Globals.GetVectorForDirection( MoveDirection ) * (Width / 2);
for ( int i = 0; i < numParticles; i++ )
{
Vector2 ppos = pos;
if ( MoveDirection == Direction.Left || MoveDirection == Direction.Right )
ppos += new Vector2( 0, Rng.Int( 0, 2 ) == 0 ? Rng.Float( -Height / 2, -Height / 6 ) : Rng.Float( Height / 6, Height / 2 ) );
else
ppos += new Vector2( Rng.Int( 0, 2 ) == 0 ? Rng.Float( -Width / 2, -Width / 6 ) : Rng.Float( Width / 6, Width / 2 ), 0 );
Vector2 vel = new Vector2( impactVector.y * Rng.Float( -1f, 1f ), impactVector.x * Rng.Float( -1f, 1f ) ) * velAdd;
Stage.AddParticle( ppos, vel, Rng.Float( 0.90f, 0.97f ), Globals.GRAVITY_STR_DUST, ParticleKind.Dust, Rng.Float( 0.35f, 0.75f ), Rng.Int( 3, 7 ) );
}
float vol = Utils.Map( _moveSpeed, 0f, 80f, 0f, 1f, true, EasingType.SineEaseIn );
Audio.PlaySfx( SfxType.BlockImpact, Position, vol );
// Block slam: pan the rumble by the block's horizontal offset from the player, and make it
// strongest when the block is closest (a far slam is a faint thud, one right next to you hits
// hard). Heavy/low-frequency to sell the mass of the block against the wall.
var player = Stage?.Player;
if ( player != null )
{
float pan = Math.Clamp( (X - player.X) / (Arena.WIDTH * 0.5f), -1f, 1f );
float prox = Utils.Map( (Position - player.Position).Length, 0f, Arena.WIDTH, 1f, 0.2f, true, EasingType.CubicEaseOut );
Haptics.Pulse( vol * prox, 0.13f, pan, Haptics.TONE_HEAVY );
}
}
}
public GameStage Stage { get; set; }
// ----------------------------------------------------------------------------------------
bool IsColliding( float x, float y, Direction direction, out Entity2D other )
{
other = null;
return IsCollidingWithBlock( x, y, out other )
|| IsCollidingWithObstacle( x, y, out other )
|| !IsInBounds( x, y, direction );
}
protected bool IsColliding( Direction dir ) => IsColliding( dir, out _ );
/// <summary>Probe one pixel ahead in <paramref name="dir"/> (blocks on the pixel grid, walls raw).</summary>
bool IsColliding( Direction dir, out Entity2D other )
{
Vector2 offset = Globals.GetVectorForDirection( dir );
return IsColliding( X + offset.x, Y + offset.y, dir, out other );
}
protected bool IsCollidingWithBlock( float x, float y, out Entity2D other )
{
other = null;
foreach ( Block block in Stage.GetBlocks() )
{
if ( block != this && PenetratesOnPixelGrid( x, y, block ) )
{
other = block;
return true;
}
}
return false;
}
/// <summary>A static interior obstacle stops a block exactly like an arena wall (same pixel-grid
/// test as block-vs-block). Reported as the colliding entity so <see cref="Tick"/> pushes the block
/// back out of it (obstacles aren't covered by the arena-bounds clamp). Uses the BLOCK-solid view,
/// which includes fences — obstacles solid only to blocks.</summary>
protected bool IsCollidingWithObstacle( float x, float y, out Entity2D other )
{
other = null;
foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
{
if ( PenetratesOnPixelGrid( x, y, ob ) )
{
other = ob;
return true;
}
}
return false;
}
/// <summary>Block-vs-block overlap tested on the integer pixel grid the sprites render on
/// (same rounding as Entity2D.SyncTransform), requiring at least one real pixel of overlap
/// (flush contact doesn't collide). Blocks carry sub-pixel float drift on their cross axis,
/// so raw-float AABBs can overlap by a fraction of a pixel while the rendered sprites are
/// exactly flush — stopping a block on a collision that isn't visible on screen. Testing on
/// the rendered grid makes "collides" mean "the sprite pixels overlap": sub-pixel grazes
/// slide past with no position adjustment (so no physics side-effects for a riding player).</summary>
bool PenetratesOnPixelGrid( float x, float y, Entity2D other )
{
RectF a = GetPixelRect( x, y );
RectF b = other.GetPixelRect( other.X, other.Y );
return a.Right > b.Left && a.Left < b.Right && a.Top > b.Bottom && a.Bottom < b.Top;
}
/// <summary>Max rendered-pixel overlap on the cross axis that counts as a "graze" a block
/// slides past rather than impacting.</summary>
const float CORNER_SLIP_PIXELS = 1f;
/// <summary>If the forward collision with <paramref name="other"/> is only a corner graze —
/// at most <see cref="CORNER_SLIP_PIXELS"/> of overlap on the axis perpendicular to travel —
/// resolve it by snapping one of the two blocks flush on that axis so they slide past instead
/// of stopping. Prefers moving this (the detecting) block; if a wall or another block pins it
/// (e.g. grazing along the arena floor), the OTHER block is nudged instead — the graze is
/// mutual, so it shouldn't matter which block's tick happened to notice it first. A slipped
/// position must be verifiably free (no other block, no wall) or that candidate is rejected;
/// if neither block can move, the impact proceeds as normal — so a slip can never create a
/// new overlap that would itself need resolving (no cascades).</summary>
bool TryCornerSlip( Block other )
{
RectF a = GetRect( MathF.Round( X ), MathF.Round( Y ) );
RectF b = other.GetRect( MathF.Round( other.X ), MathF.Round( other.Y ) );
bool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;
float crossOverlap = horizontal
? MathF.Min( a.Top, b.Top ) - MathF.Max( a.Bottom, b.Bottom )
: MathF.Min( a.Right, b.Right ) - MathF.Max( a.Left, b.Left );
if ( crossOverlap <= 0f || crossOverlap > CORNER_SLIP_PIXELS )
return false;
return TrySlipBlock( this, other, horizontal ) || TrySlipBlock( other, this, horizontal );
}
/// <summary>Try to move <paramref name="mover"/> flush against <paramref name="anchor"/> on
/// the graze's cross axis (<paramref name="horizontal"/> = the detecting block's travel axis).
/// Snaps to the pixel grid, which also clears any sub-pixel drift on that axis (same idea as
/// the resting snap in ImpactEffects). Fails without moving anything if the slipped position
/// touches a wall or another block.</summary>
static bool TrySlipBlock( Block mover, Block anchor, bool horizontal )
{
RectF b = anchor.GetRect( MathF.Round( anchor.X ), MathF.Round( anchor.Y ) );
// Slip away from the anchor's centre.
Direction slipDir;
float newX = mover.X, newY = mover.Y;
if ( horizontal )
{
bool slipUp = mover.Y > anchor.Y;
slipDir = slipUp ? Direction.Up : Direction.Down;
newY = slipUp ? b.Top + mover.Height / 2 : b.Bottom - mover.Height / 2;
}
else
{
bool slipRight = mover.X > anchor.X;
slipDir = slipRight ? Direction.Right : Direction.Left;
newX = slipRight ? b.Right + mover.Width / 2 : b.Left - mover.Width / 2;
}
if ( !mover.IsInBounds( newX, newY, slipDir ) || mover.IsCollidingWithBlock( newX, newY, out _ )
|| mover.IsCollidingWithObstacle( newX, newY, out _ ) )
return false;
mover.Pos = new Vector2( newX, newY );
return true;
}
// ----------------------------------------------------------------------------------------
protected virtual Direction GetNewDirection()
{
int tries = 0;
Direction nextDirection = MoveDirection;
while ( tries < 10 )
{
tries++;
nextDirection = GetPreferredNextDirection( MoveDirection );
if ( nextDirection == Globals.GetOppositeDirection( MoveDirection ) && tries < 5 )
continue;
if ( nextDirection != MoveDirection && !IsColliding( nextDirection ) )
return nextDirection;
}
return nextDirection;
}
protected bool IsNewDirectionAllowed( Direction previousDirection, Direction proposedDirection )
{
if ( TurnMode != TurnMode.Always || proposedDirection != Globals.GetOppositeDirection( previousDirection ) )
return true;
bool wasHorizontal = previousDirection is Direction.Left or Direction.Right;
Direction firstTurn = wasHorizontal ? Direction.Up : Direction.Left;
Direction secondTurn = wasHorizontal ? Direction.Down : Direction.Right;
return IsColliding( firstTurn ) && IsColliding( secondTurn );
}
protected Direction EnforceAlwaysTurn( Direction previousDirection, Direction proposedDirection )
{
// A proposal the block is already flush against (GetNewDirection's bounded random search can
// fall through with one) would be a ZERO-TRAVEL leg: it grinds against the contact for the
// slow-push window, then re-picks with previousDirection replaced by the doomed heading — which
// legalises the reversal the flag forbids on the very next pick (slam down → "left" into the
// wall it's touching → forced up, a two-step backtrack). Treat it like a proposed reversal so
// the turn rule substitutes a real perpendicular; a fully boxed block still falls through to
// the proposal below, and a true dead end still reverses via IsNewDirectionAllowed's escape.
bool doomedProposal = TurnMode == TurnMode.Always && IsColliding( proposedDirection );
if ( !doomedProposal && IsNewDirectionAllowed( previousDirection, proposedDirection ) )
return proposedDirection;
bool wasHorizontal = previousDirection is Direction.Left or Direction.Right;
Direction firstTurn = wasHorizontal ? Direction.Up : Direction.Left;
Direction secondTurn = wasHorizontal ? Direction.Down : Direction.Right;
bool firstOpen = !IsColliding( firstTurn );
bool secondOpen = !IsColliding( secondTurn );
if ( firstOpen && secondOpen )
return Rng.Int( 0, 2 ) == 0 ? firstTurn : secondTurn;
if ( firstOpen ) return firstTurn;
if ( secondOpen ) return secondTurn;
return proposedDirection;
}
/// <summary>The whole direction pick for the CW/CCW cycle modes (see <see cref="TurnMode"/>): keep
/// the heading while it's open, take the single cycle turn where GEOMETRY (arena wall / obstacle /
/// fence) blocks it, and otherwise hold the heading and wait — resuming into the blocked contact
/// just presses for the grind window and re-picks, so a queued block sets off (or takes its corner)
/// as soon as the block ahead clears. A blocking BLOCK is traffic, not track, and always means
/// hold: turning around one would let a cycler that turned into a lane someone else then claimed
/// rotate a second time — back along the leg it arrived on, driving against the flow (and a head-on
/// cycler pair can never unjam). Only walls mark the turn, so a cycler can never back up.</summary>
Direction PickCycleDirection( Direction current )
{
if ( !IsColliding( current, out Entity2D blocker ) ) return current;
if ( blocker is Block ) return current;
Direction turn = TurnMode == TurnMode.Clockwise
? Globals.GetClockwiseDirection( current )
: Globals.GetCounterClockwiseDirection( current );
return IsColliding( turn ) ? current : turn;
}
/// <summary>Where an enclosed block's eyes (and travel direction) re-aim on each enclosed blink /
/// interval cycle: toward the player. Virtual so a subtype whose ability is direction-coupled
/// (LaneBlock's lane) can pin it instead of tracking the player.</summary>
protected virtual Direction GetEnclosedGazeDirection()
{
Player target = Stage?.ClosestTargetablePlayer( Position );
if ( target is null ) return Globals.GetRandomDirection();
Vector2 delta = target.Position - Position;
if ( delta.LengthSquared < 0.0001f ) return Globals.GetRandomDirection();
if ( MathF.Abs( delta.x ) >= MathF.Abs( delta.y ) )
return delta.x < 0f ? Direction.Left : Direction.Right;
return delta.y < 0f ? Direction.Down : Direction.Up;
}
protected virtual Direction GetPreferredNextDirection( Direction currentDir ) => Globals.GetRandomDirection();
/// <summary>Hook: eyes have finished closing and a new direction is picked (block still
/// paused, about to wait then re-open). Subtypes use this to time effects to the pause.</summary>
protected virtual void OnEyesClosed() { }
// ----------------------------------------------------------------------------------------
// Two blocks pressed/phased within a tick or two of each other (a corner graze awarding both
// sides, twins/swarm bodies landing together) fire the identical one-shot twice, which reads as
// one LOUD crack rather than two events. These gates keep the first and drop the pile-up.
// Gated here rather than in Audio's per-SfxType coalesce map because the events are shared:
// the press sounds with the level-select menu, glitter with Harden/Mimic, the max-phase chime
// with the score tally — none of those should gate against blocks.
protected const float SFX_COALESCE_WINDOW = 0.1f;
static float _lastPressSfxTime, _lastPhaseSfxTime;
/// <summary>True if enough time has passed to play a coalesced cue again (and stamps it).</summary>
protected static bool TrySfxGate( ref float lastTime )
{
if ( RealTime.Now - lastTime < SFX_COALESCE_WINDOW ) return false;
lastTime = RealTime.Now;
return true;
}
public bool PressSide( Direction dir, Player presser = null )
{
if ( IsSwitchingSide( dir ) ) return false;
if ( Phase >= NUM_PHASES - 1 ) return false;
bool pressed = false;
if ( !IsSidePressed( dir ) && !HasSpikes( dir ) )
{
ShowPressed( dir );
float SHAKE_AMOUNT = 1.0f;
if ( dir == Direction.Left ) AddShake( new Vector2( SHAKE_AMOUNT, 0 ) );
else if ( dir == Direction.Right ) AddShake( new Vector2( -SHAKE_AMOUNT, 0 ) );
else if ( dir == Direction.Down ) AddShake( new Vector2( 0, SHAKE_AMOUNT ) );
else if ( dir == Direction.Up ) AddShake( new Vector2( 0, -SHAKE_AMOUNT ) );
AddSideGlitterParticles( dir, Phase );
// Button + glitter are one cue, so they share a single gate and can never separate.
if ( TrySfxGate( ref _lastPressSfxTime ) )
{
Audio.PlaySfx( Phase == 0 ? SfxType.BlockSidePressed0 : SfxType.BlockSidePressed1, Position );
Audio.PlaySfx( Phase == 0 ? SfxType.Glitter0 : SfxType.Glitter1, Position, 0.75f );
}
// Light crisp tick for the scoring press, panned to the block's side of the player.
var feedbackPlayer = presser ?? Stage?.Player;
if ( feedbackPlayer != null )
{
float pan = Math.Clamp( (X - feedbackPlayer.X) / (Arena.WIDTH * 0.5f), -1f, 1f );
Haptics.Pulse( 0.18f, 0.05f, pan, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
}
pressed = true;
// Desync diagnostics: every awarded press is traced (incl. WHO pressed) so a replay can be
// diffed against its live run press-by-press (see GameManager.TracePressAward — the
// observed desyncs are a single vanished press with otherwise identical kinematics).
Stage?.Manager?.TracePressAward( this, dir, presser );
}
// A press suspended under a spike graft still counts: the phase completes immediately rather than
// waiting for the spikes to retract (NextPhase leaves the spiked side alone and consumes its press).
if ( IsSidePressedOrSuspended( Direction.Left ) && IsSidePressedOrSuspended( Direction.Right ) &&
IsSidePressedOrSuspended( Direction.Down ) && IsSidePressedOrSuspended( Direction.Up ) )
{
NextPhase();
}
return pressed;
}
// ----------------------------------------------------------------------------------------
protected virtual void NextPhase()
{
if ( Phase >= NUM_PHASES - 1 ) return;
int fromPhase = Phase;
// Increment first so the pop-out animation uses the NEW phase's button (at max phase the
// sides become yellow "max" and can't be pressed).
Phase = Math.Min( Phase + 1, NUM_PHASES - 1 );
RefreshBodyTint();
// Move each side out of Pressed immediately (so this NextPhase can't re-fire every frame the
// player stays against the block): StartButtonUnpress switches it to Retracting, then the new
// phase's button pops back out (see TickSides).
foreach ( var dir in Globals.GetAllDirections() )
{
if ( HasSpikes( dir ) )
{
// Spikes stay put; the press under them is consumed. A settled side re-skins to the new phase's
// colour now; a mid-grow/retract side finishes its anim as-is (the engine restarts a swapped
// anim at frame 0, so re-skinning it would visibly replay) and lands in the right colour.
var s = _side[dir];
s.PressedUnderSpikes = false;
if ( s.Mode == SideMode.Spiked ) PlaySideAnim( s.Sprite, SettledSpikeAnim( s, dir ) );
continue;
}
StartButtonUnpress( dir, fromPhase );
}
_face.PlayAnimation( $"face_{Phase}" );
_eyebrows.PlayAnimation( $"eyebrows_{Phase}" );
_mouth.PlayAnimation( $"mouth_{Phase}" );
_eyes.PlayAnimation( EyeAnim() );
_paletteAlt = false;
_paletteBlinkTimer = 0f;
if ( TrySfxGate( ref _lastPhaseSfxTime ) )
Audio.PlaySfx( Phase == NUM_PHASES - 1 ? SfxType.BlockPhaseReachMax : SfxType.BlockPhaseReach1, Position, 0.85f );
Stage.BlockReachedPhase( this );
// Nudge the music pitch up a touch each phase-up, so the song rises in tension across the run.
Audio.AdvanceMusicProgression();
if ( Phase >= NUM_PHASES - 1 )
Stage.BlockReachedMaxPhase( this );
}
string SideOutState()
{
if ( Phase >= NUM_PHASES - 1 ) return "max";
return Phase == 0 ? "out0" : "out1";
}
void RefreshSideTint( SideState side )
{
Color tint = Phase == 1 && side.Mode is SideMode.Out or SideMode.Popping
? PHASE_1_OUT_TINT
: Color.White;
float alpha = side.HasSpikes ? MathF.Max( _layersAlpha, SPIKE_MIN_ALPHA ) : _layersAlpha;
side.Sprite.Color = tint.WithAlpha( alpha );
}
// ----------------------------------------------------------------------------------------
/// <summary>Phase-up transition for one side: retract the pressed button in, then pop the
/// new phase's button out. The side counts as "switching" (unpressable) until it lands.</summary>
void StartButtonUnpress( Direction dir, int fromPhase )
{
var s = _side[dir];
s.Mode = SideMode.Retracting;
s.Timer = BTN_RETRACT_TIME;
RefreshSideTint( s );
PlaySideAnim( s.Sprite, $"pressed{fromPhase}retract_{Dir( dir )}" );
}
/// <summary>Pop the current phase's button out from flat (no retract first; used after spikes
/// retract). Holds the side "switching" until the pop completes.</summary>
void StartButtonPop( Direction dir )
{
var s = _side[dir];
s.Mode = SideMode.Popping;
s.PressedUnderSpikes = false;
bool max = Phase >= NUM_PHASES - 1;
s.Timer = max ? BTN_POP_MAX_TIME : BTN_POP_OUT_TIME;
RefreshSideTint( s );
PlaySideAnim( s.Sprite, max ? $"maxadd_{Dir( dir )}" : $"out{Phase}add_{Dir( dir )}" );
}
/// <summary>Mark a side Pressed and show the pressed button for the current phase (no scoring/sfx).
/// Shared by PressSide, authored starts and spike-retract restores; <paramref name="popIn"/> animates
/// the button up from flat (pressed{N}add) instead of snapping to its resting frame.</summary>
void ShowPressed( Direction dir, bool popIn = false )
{
var s = _side[dir];
s.Mode = SideMode.Pressed;
s.PressedUnderSpikes = false;
RefreshSideTint( s );
PlaySideAnim( s.Sprite, $"pressed{Phase}{(popIn ? "add" : "")}_{Dir( dir )}" );
}
/// <summary>Spikes retracted off a side that was pressed when grafted: the press comes back, popping
/// up from the flat bar the retract ends on. No scoring / sfx — the press was already awarded.</summary>
void RestoreSuspendedPress( Direction dir ) => ShowPressed( dir, popIn: true );
// ----------------------------------------------------------------------------------------
void AddSideGlitterParticles( Direction dir, int phase )
{
// Cosmetic + PLAYER-TRIGGERED (fired from PressSide when the player pushes a side). Must use
// the cosmetic RNG stream: drawing from the authoritative stream here would let this purely
// visual flourish advance the sim's RNG and shift every OTHER block's subsequent movement
// decisions. Keeping incidental visuals off the authoritative stream keeps its draw sequence a
// function of gameplay alone (so a run reproduces exactly on input-replay, and a seed's initial
// layout is identical for every player — e.g. daily challenges).
Vector2 pos = Position + Globals.GetVectorForDirection( dir ) * (Width / 2);
for ( int i = (int)(-Width / 2); i < (int)(Width / 2); i += 2 )
{
if ( Rng.CosmeticFloat( 0f, 1f ) < 0.25f ) continue;
Vector2 ppos = pos;
if ( dir == Direction.Left || dir == Direction.Right ) ppos += new Vector2( 0, i );
else ppos += new Vector2( i, 0 );
Vector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 20f, 40f );
Stage.AddParticle( ppos, vel, Rng.CosmeticFloat( 0.90f, 0.97f ), Globals.GRAVITY_STR_GLITTER,
phase == 0 ? ParticleKind.Glitter0 : ParticleKind.Glitter1, Rng.CosmeticFloat( 0.35f, 0.75f ), Rng.CosmeticInt( 3, 7 ) );
}
}
// ----------------------------------------------------------------------------------------
public void AddShake( Vector2 amount ) => _shakeVector += amount;
/// <summary>Fade the body layers' alpha (Smile invisibility). Speed lines are left alone
/// so an invisible Smile still trails visible speed-lines as a tell. Layers stay enabled
/// (we only change alpha), so blinks/animations keep running underneath.</summary>
protected void SetLayersAlpha( float a )
{
// Opaque layers dither partial alpha, so any fade (or held sub-1 state like the smile
// block's invisibility spikes) needs the translucent path; back to opaque only at full
// alpha so the block rejoins the depth-writing batch (see CreateVisuals).
bool wasFull = _layersAlpha >= 1f;
_layersAlpha = a;
if ( wasFull != a >= 1f )
SetLayersTranslucent( a < 1f );
RefreshBodyTint();
foreach ( var dir in Globals.GetAllDirections() )
RefreshSideTint( _side[dir] );
}
void RefreshBodyTint()
{
// Reapply through fades too, so Smile and Teleport disguises retain their mimic identity.
Color tint = Color.White;
if ( Mimic is not null && BlockType != BlockType.Mimic && Phase >= 1 )
{
// Cosine eases into both ends, with no abrupt flash or pause at the loop boundary.
float pulse = 0.5f - 0.5f * MathF.Cos( _mimicTintTime * (2f * MathF.PI / MIMIC_TINT_PERIOD) );
tint = Color.Lerp( MIMIC_DISGUISE_TINT, MIMIC_DISGUISE_PEAK_TINT, pulse );
}
var color = tint.WithAlpha( _layersAlpha );
_face.Color = color;
_mouth.Color = color;
_eyes.Color = color;
_eyebrows.Color = color;
}
/// <summary>Toggle the body + side layers between opaque (SpriteLayer's default) and
/// smooth-translucent (Opaque off + AlphaCutoff 0, like the lane overlays / Portal rings) so an
/// alpha fade blends cleanly instead of dithering out at the 0.5 cutoff.
/// <see cref="SetLayersAlpha"/> toggles this automatically at the full-alpha boundary; the
/// Teleport block also drives it directly around its fade-in.</summary>
protected void SetLayersTranslucent( bool translucent )
{
void Apply( SpriteRenderer sr )
{
if ( sr is null ) return;
// Restore to the game-wide default, NOT a literal true: while OPAQUE_DEFAULT is off no
// sprite may join the engine's opaque batch (its bounds decide batch draw order — see
// SpriteLayer.Add).
sr.Opaque = !translucent && SpriteLayer.OPAQUE_DEFAULT;
sr.AlphaCutoff = translucent ? 0f : 0.5f;
}
Apply( _face );
Apply( _mouth );
Apply( _eyes );
Apply( _eyebrows );
foreach ( var dir in Globals.GetAllDirections() )
Apply( _side[dir].Sprite );
}
void ApplyShakeOffsets( Vector2 offset )
{
var o = new Vector3( MathF.Round( offset.x ), MathF.Round( offset.y ), 0 );
SetLayerOffset( _face, o, 0 );
SetLayerOffset( _mouth, o, 2 );
SetLayerOffset( _eyes, o, 3 );
SetLayerOffset( _eyebrows, o, 4 );
foreach ( var dir in Globals.GetAllDirections() )
SetLayerOffset( _side[dir].Sprite, o, 1 );
}
static void SetLayerOffset( SpriteRenderer sr, Vector3 offset, int childOrder )
{
if ( sr is null ) return;
sr.GameObject.LocalPosition = new Vector3( offset.x, offset.y, childOrder * SpriteLayer.LAYER_Z_STEP );
}
// ----------------------------------------------------------------------------------------
/// <summary>Destroy any STAGE-PARENTED visuals this block pools (lane overlays, trail rects, ring /
/// line segments — anything from <see cref="GameStage.CreateOverlaySprite"/>). Sprites parented to the
/// block's own GameObject die with it, but stage-level overlay quads do NOT: destroying the block
/// leaves them enabled, frozen at their last rendered frame, for the rest of the run. The stage calls
/// this before destroying a block instance mid-run (the mimic transform swap).</summary>
public virtual void DestroyStageVisuals() { }
/// <summary>Destroy a pool of stage-parented overlay sprites and empty the list — the shared body of
/// the <see cref="DestroyStageVisuals"/> overrides.</summary>
protected static void DestroyOverlayPool( List<SpriteRenderer> pool )
{
foreach ( var sr in pool )
GameStage.DestroyOverlaySprite( sr );
pool.Clear();
}
// ----------------------------------------------------------------------------------------
public virtual void Die()
{
if ( IsDead ) return;
_eyes.PlayAnimation( "eyes_dead" );
_mouth.PlayAnimation( "mouth_dead" );
_eyebrows.PlayAnimation( "eyebrows_dead" );
IsDead = true;
// Dead blocks early-out in Tick without recomputing Velocity; a block killed mid-flight
// would otherwise advertise its last travel velocity through the whole win sequence.
Velocity = Vector2.Zero;
}
/// <summary>Spawn a small burst of dust puffs that drift outward from the block centre — the
/// per-frame death-throes particle effect (ported from the original's AddDeathDust).</summary>
void AddDeathDust()
{
int numParticles = Rng.Int( 1, 3 );
for ( int i = 0; i < numParticles; i++ )
{
Vector2 particlePos = Position + new Vector2( Rng.Float( -Width / 3, Width / 3 ), Rng.Float( -Height / 3, Height / 3 ) );
Vector2 vel = (particlePos - Position) * Rng.Float( 12f, 16f );
vel += new Vector2( Rng.Float( -1f, 1f ), Rng.Float( -1f, 1f ) ) * Rng.Float( 1f, 3f );
Stage.AddParticle(
particlePos,
vel,
Rng.Float( 0.80f, 0.84f ),
Globals.GRAVITY_STR_DUST,
ParticleKind.Dust,
Rng.Float( 0.45f, 1.2f ),
Rng.Int( 15, 25 ) );
}
}
public virtual void PlayerHasDied() { }
/// <summary>Flash spiked sides between normal and alt colour (deadly-warning blink).</summary>
public virtual void BlinkSpikes( bool blinkOn )
{
if ( _spikeBlinkOn == blinkOn ) return;
_spikeBlinkOn = blinkOn;
foreach ( var dir in Globals.GetAllDirections() )
{
if ( HasSpikes( dir ) && !IsSwitchingSide( dir ) )
PlaySideAnim( _side[dir].Sprite, SettledSpikeAnim( _side[dir], dir ) );
}
}
}