Entities/Teardrop.cs
namespace BlockParty;
public enum TeardropVariant
{
Standard,
Piercing
}
/// <summary>
/// The Sad block's teardrop. Port of the original <c>Teardrop</c>: falls under
/// gravity and kills the player on contact (checked in Player via <see cref="Hitbox"/> while
/// not splashed). Standard tears splash on solid geometry; piercing tears pass through it and
/// are removed once offscreen. While falling it blinks its palette as a tell (Teardrop0 ↔ Teardrop1),
/// swapping between baked "fall"/"fall_alt" anims. The fall is driven MANUALLY (PlaybackSpeed=0
/// + CurrentFrameIndex from our own clock) so the fast palette swap doesn't restart engine
/// playback and freeze it on the first frames; the splash plays normally (engine-driven).
/// </summary>
public sealed class Teardrop : Entity2D
{
public GameStage Stage { get; set; }
public bool HasSplashed { get; private set; }
const float PLAYER_HITBOX_SIZE = 2f;
const float GRAVITY = -75.0f;
const float SPLASH_TIME = 0.2f;
// Native art pixel sizes (Size = art dims; see SpriteLayer/Fireball convention).
const float FALL_SIZE = 11f;
const float SPLASH_SIZE = 17f;
// Palette blink (the "tell"): alt flashes briefly, steady holds longer.
const float PALETTE_BLINK_TIME = 0.15f;
const float PALETTE_BLINK_ON_TIME = 0.05f;
float _paletteBlinkTimer;
bool _paletteAlt;
// Manual fall animation (5 frames over 0.25s, plays once then holds the last frame).
const int FALL_FRAMES = 5;
const float FALL_PERIOD = 0.25f;
float _animTime;
string _curAnim;
public RectF Hitbox => new RectF(
X - PLAYER_HITBOX_SIZE * 0.5f, Y - PLAYER_HITBOX_SIZE * 0.5f,
X + PLAYER_HITBOX_SIZE * 0.5f, Y + PLAYER_HITBOX_SIZE * 0.5f );
Block _creatingBlock;
TeardropVariant _variant;
float _splashTimer;
SpriteRenderer _sprite;
public void Setup( Vector2 velocity, Block creatingBlock, TeardropVariant variant )
{
Velocity = velocity;
Size = new Vector2( FALL_SIZE, FALL_SIZE );
_creatingBlock = creatingBlock;
_variant = variant;
}
/// <summary>Carry the self-collision exclusion over when the creating block gets mimic-swapped
/// mid-fall — the disguise stands where the old instance was, so without this the drop would
/// splash on its own source the tick after the swap.</summary>
public void OnBlockReplaced( Block old, Block replacement )
{
if ( ReferenceEquals( _creatingBlock, old ) ) _creatingBlock = replacement;
}
public void CreateVisuals()
{
string spritePath = _variant == TeardropVariant.Piercing
? "sprites/teardrop_pierce.sprite"
: "sprites/teardrop.sprite";
_sprite = SpriteLayer.Add( GameObject, spritePath, Size, "fall" );
_sprite.PlaybackSpeed = 0f; // fall is scrubbed manually; splash re-enables playback
_curAnim = "fall";
}
public override void Tick( float dt )
{
if ( HasSplashed )
{
_splashTimer -= dt;
if ( _splashTimer <= 0f ) Dead = true;
return;
}
_animTime += dt;
BlinkPalette( dt );
Pos += Velocity * dt;
Velocity += new Vector2( 0, GRAVITY ) * dt;
if ( _variant == TeardropVariant.Piercing )
{
if ( IsFullyOutsideArena() ) Dead = true;
return;
}
foreach ( Block block in Stage.GetBlocks() )
{
if ( block != _creatingBlock && GetRect().Intersects( block.GetRect() ) )
{
Splash();
return;
}
}
// Interior obstacles splash the drop just like a block or wall.
foreach ( Entity2D ob in Stage.GetSolidObstacles() )
{
if ( GetRect().Intersects( ob.GetRect() ) )
{
Splash();
return;
}
}
// Splash on the floor, on either side wall (or the top) — it must not drift out of the
// arena — or once fully offscreen as a backstop.
var r = GetRect();
if ( Y < 8
|| r.Left <= Arena.WALL_SIZE
|| r.Right >= Arena.WIDTH - Arena.WALL_SIZE
|| r.Top >= Arena.HEIGHT - Arena.WALL_SIZE
|| IsFullyOutsideArena() )
Splash();
}
void Splash()
{
if ( HasSplashed ) return;
HasSplashed = true;
_splashTimer = SPLASH_TIME;
if ( _sprite is not null )
{
_sprite.Size = new Vector2( SPLASH_SIZE, SPLASH_SIZE ); // splash art is larger than the drop
// The bigger splash quad is centred on Pos, so it would otherwise hang (SPLASH-FALL)/2 px
// below the impact point. Nudge it up so the splash bottom sits where the drop landed.
_sprite.GameObject.LocalPosition += new Vector3( 0, (SPLASH_SIZE - FALL_SIZE) / 2f, 0 );
_sprite.PlaybackSpeed = 1f; // hand the splash back to engine-driven playback
_sprite.PlayAnimation( "splash" );
}
Audio.PlaySfx( SfxType.TearSplash, Position, Rng.Float( 0.4f, 0.48f ) );
}
void BlinkPalette( float dt )
{
_paletteBlinkTimer -= dt;
if ( _paletteBlinkTimer <= 0f )
{
_paletteAlt = !_paletteAlt;
_paletteBlinkTimer = _paletteAlt ? PALETTE_BLINK_ON_TIME : PALETTE_BLINK_TIME;
}
ApplyFrame();
}
void ApplyFrame()
{
if ( _sprite is null ) return;
// PlayAnimation only on a palette change (it resets the clock to 0); PlaybackSpeed=0 keeps
// the CurrentFrameIndex we then scrub. Fall plays once over FALL_PERIOD, then holds frame 4.
string name = _paletteAlt ? "fall_alt" : "fall";
if ( name != _curAnim )
{
_sprite.PlayAnimation( name );
_curAnim = name;
}
int frame = Math.Clamp( (int)(_animTime / FALL_PERIOD * FALL_FRAMES), 0, FALL_FRAMES - 1 );
_sprite.CurrentFrameIndex = frame;
}
bool IsFullyOutsideArena()
{
var r = GetRect();
return r.Left > Arena.WIDTH || r.Right < 0 || r.Bottom > Arena.HEIGHT || r.Top < 0;
}
}