Entities/Blocks/BlockSiren.cs
namespace BlockParty;
/// <summary>
/// Siren block — after a hard slam (phase 1+) it holds its post-slam pause much longer than a normal
/// block and "sings": a pull that drags the player toward the block for the whole pause. The pull is
/// UNIFORM — same strength wherever the player stands (no falloff) — and deliberately modest: a drift
/// at/under the walk speed (so it's always physically escapable — barely, at phase 2) and far under a
/// fall's terminal speed, so it drags you across floors and bends your jumps rather than reeling you
/// in against a fall. Phase 2 sings longer, pulls harder, and trembles a little more (still well under
/// the Squid's laser shake).
///
/// The pull strength ramps up from zero at the start of the song and back down to zero at the end
/// (sustain in between). The tell is a stream of small "note" motes flickering into existence at
/// random spots around the arena and drifting toward the block's face — their drift speed follows the
/// same envelope, so the early and late motes barely creep while mid-song ones stream in fast.
///
/// The pull is a bounded DRIFT through the player's dedicated siren channel (ApplySirenPull, reset
/// every frame): its speed is exactly the envelope's value each tick and dies the instant the song
/// ends. It deliberately does NOT ride the magnet channel — that channel INTEGRATES its feed toward a
/// 400 px/s terminal (what lets a Magnet beam out-pull gravity), which turned a long song into an
/// un-out-walkable, silently-banked launch (worst while stuck to a sticky block: the whole song's pull
/// accumulated with no motion to spend it on, then fired as one yank on release).
/// </summary>
public sealed class BlockSiren : Block
{
bool _suckPending; // armed by a hard slam at phase 1+; consumed when the eyes finish closing
bool _sucking;
int _suckPhase;
float _suckTimer; // elapsed song time
float _suckDuration; // this song's length (captured at start so a mid-song phase-up can't cut/stretch it)
float _particleAccum;
float _sfxTimer;
protected override bool CanActivateTimedAbility => !_suckPending && !_sucking;
protected override float EyesCloseTime => _suckPending ? 0.3f : base.EyesCloseTime;
// Per-phase song length / pull strength / tremble. Index by Phase (0 never sings).
// The pause is several times a normal block's 0.28–0.60s recovery. PULL_SPEED is the full-envelope
// drift VELOCITY (px/s, see ApplySirenPull — not an acceleration): kept at/under the ~86 px/s walk
// speed so walking away always works at phase 1 and barely works at phase 2, and a constant drift
// can never suspend the player against gravity's unbounded fall speed.
static readonly float[] SUCK_TIME = { 0f, 2.5f, 4.0f };
static readonly float[] PULL_SPEED = { 0f, 50f, 75f };
static readonly float[] SUCK_SHAKE = { 0f, 0.10f, 0.22f }; // Squid's laser shakes at 0.5
// Ramp the pull 0 → full at the start of the song and full → 0 at the end (see Envelope).
static readonly float[] PULL_RAMP_TIME = { 0f, 0.4f, 0.6f };
const float SUCK_SFX_INTERVAL = 1f;
// Only a real slam starts a song (same hard-impact bar as the Squid's laser); slow grinds don't.
const float SUCK_MIN_IMPACT_SPEED = 50.0f;
// Suck-stream motes: spawn cadence is constant through the song so the ramps read (slow early
// motes / slow dying motes), while their drift SPEED follows the envelope. Lifetime is set so a
// full-strength mote expires right as it reaches the block face; a ramp-time mote barely creeps
// and shrinks out where it stands. Motes appear ANYWHERE in the arena — the pull is uniform
// across the whole stage, so its tell covers the whole stage too.
const float PARTICLES_PER_SECOND = 26f;
const float PARTICLE_MAX_SPEED = 150f;
public override void Impact( Block otherBlock )
{
bool hardSlam = _moveSpeed > SUCK_MIN_IMPACT_SPEED && Phase >= 1;
ImpactEffects( otherBlock );
if ( IsDead ) return;
_suckPending = hardSlam; // the song starts once the eyes finish closing (see OnEyesClosed)
if ( hardSlam )
Audio.PlaySfx( SfxType.SirenPrepare, Position, 0.25f );
CloseEyesAndPickNewDirection();
}
/// <summary>The long "singing" pause: when a song is armed the block stays put for the whole
/// song instead of the normal short recovery. Fixed per phase (no Rng draw) — deterministically
/// branching on sim state keeps replays exact without consuming the authoritative stream.</summary>
protected override float EyesStayClosedTime()
=> _suckPending ? SUCK_TIME[Phase] : base.EyesStayClosedTime();
// Eyes are shut and the pause timer (set from EyesStayClosedTime above, same tick, same phase)
// is running — begin the song so pull and pause end together.
protected override void OnEyesClosed()
{
if ( !_suckPending ) return;
_suckPending = false;
_sucking = true;
_suckPhase = Phase;
_suckTimer = 0f;
_suckDuration = SUCK_TIME[Phase];
_particleAccum = 0f;
_sfxTimer = SUCK_SFX_INTERVAL;
Audio.PlaySfx( SfxType.SirenSuck, Position, 0.33f );
_mouth.OnAnimationEnd = null;
_mouth.PlayAnimation( $"mouth_open_{_suckPhase}" );
}
protected override void NextPhase()
{
base.NextPhase();
if ( !_sucking ) return;
_mouth.OnAnimationEnd = null;
_mouth.PlayAnimation( $"mouth_open_{Phase}" );
}
/// <summary>Pull strength 0..1: linear ramp up and down over the active phase's ramp time.</summary>
float Envelope()
{
float rampTime = PULL_RAMP_TIME[_suckPhase];
float up = _suckTimer / rampTime;
float down = (_suckDuration - _suckTimer) / rampTime;
return Math.Clamp( MathF.Min( up, down ), 0f, 1f );
}
public override void Tick( float dt )
{
base.Tick( dt );
if ( !_sucking ) return;
_suckTimer += dt;
if ( _suckTimer >= _suckDuration || IsDead )
{
_sucking = false;
if ( !IsDead )
{
_mouth.OnAnimationEnd = OnMouthFinishedClosing;
_mouth.PlayAnimation( $"mouth_open_finish_{Phase}" );
}
return;
}
float env = Envelope();
_sfxTimer -= dt;
if ( _sfxTimer <= 0f )
{
Audio.PlaySfx( SfxType.SirenSuck, Position, 0.33f );
_sfxTimer = SUCK_SFX_INTERVAL;
}
// The pull: a uniform drift toward the block's centre, identical wherever the player is (no
// distance falloff), scaled by the envelope. Fed as this tick's drift VELOCITY through the
// dedicated siren channel (reset every frame), so it can never accumulate across the song and
// cuts off instantly when the song ends. Summoner impostors are player bodies too — the song
// drags them the same way, like every other field force (wind/magnet/stasis/reverse/shockwave).
if ( Stage is not null )
{
foreach ( var player in Stage.LivingPlayers )
PullBody( player, env );
foreach ( var imp in Stage.Impostors )
PullBody( imp, env );
}
// Tremble with the song (cosmetic — cosmetic stream, like the mimic aura).
AddShake( new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * SUCK_SHAKE[_suckPhase] * env );
// Suck-stream motes (cosmetic).
_particleAccum += dt * PARTICLES_PER_SECOND;
while ( _particleAccum >= 1f )
{
_particleAccum -= 1f;
SpawnSuckParticle( env );
}
}
void OnMouthFinishedClosing( string _ )
{
_mouth.OnAnimationEnd = null;
if ( !IsDead )
_mouth.PlayAnimation( $"mouth_{Phase}" );
}
/// <summary>Feed one player body (the real player or a Summoner impostor) this tick's song drift.
/// Strength is indexed by <see cref="_suckPhase"/>, not the live Phase: the song's identity (length,
/// pull, and tremble) was captured when it began, so a mid-song phase-up — the block is
/// stopped and pressable for the whole song — can't upgrade the pull partway through.</summary>
void PullBody( Player body, float env )
{
if ( body is null || body.IsDead ) return;
Vector2 toBlock = Position - body.Position;
if ( toBlock.LengthSquared > 1f )
body.ApplySirenPull( Utils.Normalized( toBlock ) * PULL_SPEED[_suckPhase] * env );
}
/// <summary>One inward-drifting "note" mote: appears at a uniformly random spot anywhere inside
/// the arena walls and drifts straight toward the block at envelope-scaled speed.
/// Purely visual, so everything rolls on the COSMETIC stream (never perturbs the sim), and it
/// rides the non-colliding straight-line particle path so blocks/walls can't deflect it.</summary>
void SpawnSuckParticle( float env )
{
Vector2 pos = new Vector2(
Rng.CosmeticFloat( Arena.WALL_SIZE + 2f, Arena.WIDTH - Arena.WALL_SIZE - 2f ),
Rng.CosmeticFloat( Arena.WALL_SIZE + 2f, Arena.HEIGHT - Arena.WALL_SIZE - 2f ) );
Vector2 toBlock = Position - pos;
float travel = toBlock.Length;
if ( travel < 1f ) return;
// Full-strength motes arrive at the face just as they expire; ramp-time motes share the same
// lifetime but crawl at env-scaled speed, so they barely move and fade where they spawned —
// the visual readout of the pull ramping up/down.
Vector2 vel = (toBlock / travel) * PARTICLE_MAX_SPEED * env;
float lifetime = travel / PARTICLE_MAX_SPEED;
var kind = Rng.CosmeticInt( 0, 2 ) == 0 ? ParticleKind.SirenNote0 : ParticleKind.SirenNote1;
Stage.AddWindParticle( pos, vel, 1f, 0f, kind, lifetime, Rng.CosmeticInt( 1, 3 ) );
}
public override void Die()
{
_mouth.OnAnimationEnd = null;
base.Die();
_sucking = false;
_suckPending = false;
}
}