Entities/Blocks/BlockSpikey.cs
namespace BlockParty;
/// <summary>
/// Spikey block — on any resolved impact it grafts deadly spikes onto the facing side. Phase 1:
/// only onto a block it rams; phase 2: onto a block, or (when it rams a bare wall) onto the
/// ARENA wall it hits. It opens a "third eye" in its forehead and fires a burst of little
/// targeted particles that stream onto the side before the spikes appear there.
/// Port of the gameplay core of the original BlockSpikey.
/// </summary>
public sealed class BlockSpikey : Block
{
const float BLOCK_SPIKE_TIME = 6f;
const float ARENA_SPIKE_TIME = 4f;
const float THIRD_EYE_HIDE = 0.2f;
// Forehead offset the spike-send particles fire from (centre + up a little).
static readonly Vector2 ThirdEyeOffset = new( 0, 12f );
SpriteRenderer _thirdEye;
float _thirdEyeTimer = -1f; // counts down the hide animation; -1 = idle
bool _thirdEyeHiding;
// Pending graft — applied once the particle burst has had time to land. Exactly one of _grBlock
// (a block's side) / _grFace (an arena wall or obstacle face) is set.
bool _spikesPending;
protected override bool CanActivateTimedAbility => !_spikesPending;
Block _grBlock; // target block; null => a wall face
Direction _grSide; // block side (block target only)
WallFace _grFace; // arena wall / obstacle face target
float _grTime;
float _applyTimer;
// Particle burst emission.
int _emitRemaining;
float _emitInterval;
float _emitTimer;
Vector2 _emitStart;
Line _emitLine;
Entity2D _emitAnchor; // block (target tracks it) or null (arena wall)
public override void CreateVisuals()
{
base.CreateVisuals();
_thirdEye = SpriteLayer.Add( GameObject, SpritePath, new Vector2( 40, 40 ), "third_eye_appear", childOrder: 5 );
_thirdEye.Enabled = false;
}
public override void Impact( Block otherBlock )
{
ImpactEffects( otherBlock );
if ( IsDead ) return;
if ( Phase == 1 || Phase == 2 )
{
if ( otherBlock is not null )
{
// Spike the side of the block we rammed (its side facing us) — unless it already has
// spikes there (live, growing in, or retracting). Don't even open the third eye / fire
// the particle burst for a graft that would be a no-op.
var side = Globals.GetOppositeDirection( MoveDirection );
if ( !otherBlock.HasSpikes( side ) )
BeginSpikeSend( otherBlock, side, null, BLOCK_SPIKE_TIME, GetSideLineForBlock( side, otherBlock ),
otherBlock, Rng.Int( 8, 12 ), Rng.Float( 0.1f, 0.2f ), 0.6f );
}
else if ( Phase == 2 )
{
// Phase 2 only: ramming a bare solid grafts spikes onto the face we hit — an interior
// obstacle's face if that's what we rammed, or the arena wall on this side when nothing
// else was hit. Both queries are position-aware: a split side grafts only the segment
// we're against. Any other non-block solid (the hardened Twin statue) has no face to
// graft onto — never fall through to the arena wall from a mid-arena impact.
// Skip entirely (no third eye / particles) if the face is already spiked — a permanent
// level spike, or a temporary graft still live/switching there.
var faceNormal = Globals.GetOppositeDirection( MoveDirection );
WallFace face = LastImpactOther switch
{
Obstacle ob => Stage.FindObstacleFace( ob, faceNormal, Position ),
null => Stage.FindArenaFace( MoveDirection, Position ), // the boundary segment we rammed
_ => null,
};
if ( face is not null && !face.SpikesPresent )
BeginSpikeSend( null, Direction.None, face, ARENA_SPIKE_TIME, face.Segment,
null, Rng.Int( 15, 22 ), Rng.Float( 0.1f, 0.2f ), 0.8f );
}
}
CloseEyesAndPickNewDirection();
}
void BeginSpikeSend( Block block, Direction side, WallFace face, float spikeTime, Line targetLine, Entity2D anchor, int numParticles, float emitTime, float applyDelay )
{
// Land any still-pending graft before starting a new one (impacts shouldn't overlap, but be safe).
FlushPendingSpikes();
_spikesPending = true;
_grBlock = block;
_grSide = side;
_grFace = face;
_grTime = spikeTime;
_applyTimer = applyDelay;
_emitStart = Position + ThirdEyeOffset;
_emitLine = targetLine;
_emitAnchor = anchor;
_emitRemaining = numParticles;
_emitInterval = (numParticles > 0) ? emitTime / numParticles : emitTime;
_emitTimer = 0f; // fire the first one immediately
ShowThirdEye();
Audio.PlaySfx( SfxType.SpikesSend, Position );
}
void EmitSpikeParticle()
{
Vector2 pointOnLine = _emitLine.A + Utils.Normalized( _emitLine.B - _emitLine.A ) * Rng.Float( 0f, _emitLine.Length );
Vector2 targetOffset = (_emitAnchor is not null) ? pointOnLine - _emitAnchor.Position : pointOnLine;
var kind = (Rng.Float( 0f, 1f ) < 0.25f) ? ParticleKind.AddSpikes1 : ParticleKind.AddSpikes0;
Stage.AddTargetedParticle( _emitStart, Vector2.Zero, 0.90f, 0f, kind,
Rng.Float( 1.2f, 1.85f ), Rng.Int( 3, 5 ), targetOffset, Rng.Float( 1400f, 1700f ), _emitAnchor );
}
/// <summary>The graft target got mimic-swapped mid-send: carry the pending spikes (and the particle
/// anchor) to the disguise, so they land on the block actually standing there.</summary>
public override void OnBlockReplaced( Block old, Block replacement )
{
if ( ReferenceEquals( _grBlock, old ) ) _grBlock = replacement;
if ( ReferenceEquals( _emitAnchor, old ) ) _emitAnchor = replacement;
}
void FlushPendingSpikes()
{
if ( !_spikesPending ) return;
if ( _grBlock is not null && !_grBlock.Replaced )
_grBlock.AddSpikesToSide( _grSide, _grTime );
else if ( _grFace is not null )
Stage.AddSpikesToFace( _grFace, _grTime );
_spikesPending = false;
_grBlock = null;
_grFace = null;
HideThirdEye();
}
void ShowThirdEye()
{
_thirdEye.Enabled = true;
_thirdEye.PlayAnimation( "third_eye_appear" );
_thirdEyeHiding = false;
_thirdEyeTimer = -1f; // stays open until the graft lands
}
void HideThirdEye()
{
if ( _thirdEye is null || !_thirdEye.Enabled ) return;
_thirdEye.PlayAnimation( "third_eye_disappear" );
_thirdEyeHiding = true;
_thirdEyeTimer = THIRD_EYE_HIDE;
}
public override void Tick( float dt )
{
base.Tick( dt );
// Stream out the spike-send particles.
if ( _emitRemaining > 0 )
{
_emitTimer -= dt;
while ( _emitRemaining > 0 && _emitTimer <= 0f )
{
EmitSpikeParticle();
_emitRemaining--;
_emitTimer += _emitInterval;
}
}
// Graft the spikes once the burst has had time to land.
if ( _spikesPending )
{
_applyTimer -= dt;
if ( _applyTimer <= 0f )
FlushPendingSpikes();
}
// Third-eye hide animation.
if ( _thirdEyeHiding && _thirdEyeTimer > 0f )
{
_thirdEyeTimer -= dt;
if ( _thirdEyeTimer <= 0f )
{
_thirdEye.Enabled = false;
_thirdEyeHiding = false;
_thirdEyeTimer = -1f;
}
}
}
static Line GetSideLineForBlock( Direction dir, Block b ) => dir switch
{
Direction.Left => new Line( new Vector2( b.Left, b.Bottom ), new Vector2( b.Left, b.Top ) ),
Direction.Right => new Line( new Vector2( b.Right, b.Bottom ), new Vector2( b.Right, b.Top ) ),
Direction.Down => new Line( new Vector2( b.Left, b.Bottom ), new Vector2( b.Right, b.Bottom ) ),
_ => new Line( new Vector2( b.Left, b.Top ), new Vector2( b.Right, b.Top ) ),
};
}