Entities/Blocks/BlockSlicer.cs
namespace BlockParty;
/// <summary>
/// Slicer block — on a hard SLAM it lays down a full-level "laser wall": a thin line that spans the
/// entire arena, oriented parallel to the face that slammed (i.e. perpendicular to its travel direction),
/// pinned at the block's flush contact position. Each line starts as a harmless grey
/// telegraph, then arms and becomes lethal at full strength for a few seconds, then powers down over a
/// short HARMLESS fade-out. Touching a LIVE line kills the player.
///
/// <list type="bullet">
/// <item>Phase 0: no lines (the block hasn't woken up).</item>
/// <item>Phase 1: block slams create one line on the contact face; arena-wall slams create none.</item>
/// <item>Phase 2: block and arena-wall slams create one line on the contact face.</item>
/// </list>
///
/// The lines sit BELOW the blocks (so blocks slide over them) but their kill band is a plain rect test
/// against the player, unaffected by draw order. Determinism: line positions come from the block's
/// flush impact rect, and their arm/expire timing + the kill test are pure functions of the fixed step
/// and player position; nothing reads Time.Delta — so replays reproduce them exactly. The render
/// (pulse/flicker) reads the accumulated age, which is itself deterministic.
///
/// This block draws from BOTH Rng streams, and the split is load-bearing:
/// <list type="bullet">
/// <item>AUTHORITATIVE (<see cref="AddLine"/>) — only the phase-2 lethal lifetime. That value decides how
/// long the kill band is live, so it IS simulation state and belongs on the seeded sim stream.</item>
/// <item>COSMETIC (<see cref="EmitSendParticles"/>) — every charge-particle property: where on the line it
/// lands, its spawn jitter, colour, size, lifetime and accel. None of it touches the kill band, the arm
/// timing or anything else the sim reads, so it must NOT advance the authoritative stream. The particle
/// COUNT and cadence are fixed constants driven off the same fixed step, so a given input-replay still
/// reproduces the visuals identically — the two streams just can't perturb each other.</item>
/// </list>
/// SendParticlesRemaining/SendParticleTimer live on the line for convenience but are write-only as far as
/// the sim is concerned: nothing outside the particle emitter ever reads them.
/// </summary>
public sealed class BlockSlicer : Block
{
// --- timing -------------------------------------------------------------------------------
// A grey warning line appears first (harmless), then the line arms and is lethal at FULL strength for
// its whole deadly window, then powers down over a short HARMLESS fade-out.
const float INDICATOR_TIME = 1.25f; // grey telegraph before it arms
const float DEADLY_TIME_P1 = 2.6f; // phase-1 lethal window (fully lethal, full brightness)
// Phase 2 lines last a bit longer, with a randomised lifetime. (Authoritative Rng: lifetime changes
// how long the kill band is live, so it's simulation state — deterministic for replays.)
const float DEADLY_TIME_P2_MIN = 3.2f;
const float DEADLY_TIME_P2_MAX = 5.2f;
// After the lethal window the line visibly powers down over this long — it is NOT deadly during the
// fade (the fade telegraphs "switching off" so a dim, near-gone line can't be a hidden instakill).
const float FADE_OUT_TIME = 0.45f;
// --- visuals ------------------------------------------------------------------------------
const float INDICATOR_MIN_THICKNESS = 3.0f;
const float INDICATOR_MAX_THICKNESS = 5.0f; // thickens as it's about to arm
const float INDICATOR_WAVE_AMPLITUDE = 3.0f;
const float INDICATOR_WAVELENGTH = 40.0f;
const float INDICATOR_WAVE_SPEED = 10.0f;
const float INDICATOR_SEGMENT_LENGTH = 8.0f;
const float INDICATOR_FADE_IN_TIME = 0.5f;
const int SEND_PARTICLE_COUNT = 26;
const float SEND_PARTICLE_EMIT_TIME = 0.25f;
const float SEND_PARTICLE_HORIZONTAL_JITTER = 8f;
const float SEND_PARTICLE_MIN_HEIGHT = 4f;
const float SEND_PARTICLE_MAX_HEIGHT = 12f;
const float DEADLY_THICKNESS = 3.0f; // the lethal core width (also the kill-band width)
const float GLOW_THICKNESS = 12.0f; // wide soft halo behind the core
// How far to shove a wall-hugging line's centre toward the arena centre so it isn't half-hidden
// behind the wall (moves the whole line off the ~2px wall band into open play).
const float WALL_EDGE_NUDGE = 2.0f;
// The non-core layers (halo + inner glow) breathe — their SIZE pulses (subtly + rapidly) so the beam
// feels alive; the two layers pulse OUT OF SYNC (a phase offset) so they don't move as one.
const float SIZE_PULSE_SPEED = 18.0f;
const float HALO_PULSE_AMOUNT = 0.14f; // ± fraction of the halo width
const float GLOW_PULSE_AMOUNT = 0.11f; // ± fraction of the inner-glow width
const float GLOW_PULSE_PHASE = 2.1f; // radians the glow layer lags the halo (off-sync)
// A vibrant brightness/colour flash that pulses the whole live beam.
const float COLOR_FLASH_SPEED = 16.0f;
const float INDICATOR_PULSE_SPEED = 9.0f; // grey line breathes so it reads as "charging"
const float DEADLY_FLICKER_SPEED = 40.0f; // fast crackle on the live beam
const float ARM_FLASH_TIME = 0.15f; // duration of the white-hot flash the instant it arms
static readonly Color INDICATOR_COLOR = new( 0.70f, 0.73f, 0.78f ); // neutral grey warning
// Phase 1 = hot red/orange laser; phase 2 = a distinct electric magenta/violet so the escalated
// wall slam reads differently at a glance.
static readonly Color DEADLY_CORE_COLOR_P1 = new( 1.0f, 0.12f, 0.10f ); // hot red core
static readonly Color DEADLY_GLOW_COLOR_P1 = new( 1.0f, 0.38f, 0.14f ); // orange-red halo
static readonly Color DEADLY_CORE_COLOR_P2 = new( 1.0f, 0.15f, 0.95f ); // hot magenta core
static readonly Color DEADLY_GLOW_COLOR_P2 = new( 0.65f, 0.20f, 1.0f ); // violet halo
// --- state --------------------------------------------------------------------------------
sealed class LaserLine
{
public bool Vertical; // true = vertical line (fixed X, spans full height); false = horizontal
public float Coord; // the fixed coordinate the line sits at (X if vertical, else Y)
public float Age;
public float DeadlyTime; // how long this line stays lethal after arming (randomised in phase 2)
public bool Phase2; // colour set to use (captured at spawn, so a later phase-up doesn't recolour it)
public bool ArmedSfxPlayed;
public int SendParticlesRemaining;
public float SendParticleTimer;
public Vector2 SendOrigin;
}
readonly List<LaserLine> _lines = new();
// Pooled line quads (shared across all this block's live lines and indicator segments). Stage-level overlay
// sprites so they sit in world space independent of the block's own transform.
readonly List<SpriteRenderer> _lineSprites = new();
public override void Tick( float dt )
{
base.Tick( dt ); // move / bounce / phase / eye-cycle; a slam calls Impact (below), laying lines
UpdateLines( dt );
RenderLines();
}
public override void DestroyStageVisuals()
{
base.DestroyStageVisuals();
// The lines' kill test lives in this instance's Tick, so they die with it — drop them along with
// their quads rather than leaving deadly-looking beams frozen on screen.
_lines.Clear();
DestroyOverlayPool( _lineSprites );
}
// ----------------------------------------------------------------------------------------
public override void Impact( Block otherBlock )
{
ImpactEffects( otherBlock );
if ( IsDead ) return;
// Phase 1 activates only against another block. Phase 2 also activates against wall-like solids,
// which arrive as a null block (LastImpactOther distinguishes arena walls from obstacles).
// The block is snapped flush here, so GetRect() gives the exact contact edges.
bool canSpawnLine = Phase >= 2 || (Phase == 1 && otherBlock is not null);
if ( canSpawnLine && SlammedThisTick )
SpawnLine();
CloseEyesAndPickNewDirection();
}
void SpawnLine()
{
RectF r = GetRect();
// The contact face is perpendicular to the direction of travel.
AddLine( MoveDirection, r );
// A telegraph sizzle as the warning line appears.
Audio.PlaySfx( SfxType.LaserTracer, Position, 0.3f );
}
/// <summary>Add a full-level line at <paramref name="face"/> of the block's flush impact rect. A
/// left/right face gives a vertical line (spanning the height); a top/bottom face gives a horizontal
/// one — always oriented parallel to the face (perpendicular to the block's travel into it).</summary>
void AddLine( Direction face, RectF r )
{
bool vertical = face is Direction.Left or Direction.Right;
float coord = face switch
{
Direction.Left => r.Left,
Direction.Right => r.Right,
Direction.Up => r.Top,
Direction.Down => r.Bottom,
_ => 0f,
};
// If the line lands right along an arena wall (a slam into the wall), nudge its centre a few px
// toward the arena centre — otherwise the thin line half-hides under/behind the wall and is hard
// to see. (Shifts the kill band too, keeping it matched to what's drawn.)
float span = vertical ? Arena.WIDTH : Arena.HEIGHT;
if ( coord <= Arena.WALL_SIZE + 0.5f ) coord += WALL_EDGE_NUDGE;
else if ( coord >= span - Arena.WALL_SIZE - 0.5f ) coord -= WALL_EDGE_NUDGE;
bool phase2 = Phase >= 2;
float deadly = phase2 ? Rng.Float( DEADLY_TIME_P2_MIN, DEADLY_TIME_P2_MAX ) : DEADLY_TIME_P1;
_lines.Add( new LaserLine
{
Vertical = vertical,
Coord = coord,
Phase2 = phase2,
DeadlyTime = deadly,
SendParticlesRemaining = SEND_PARTICLE_COUNT,
SendOrigin = Position,
} );
}
// ----------------------------------------------------------------------------------------
/// <summary>Age each line, arm/expire it, and while it's live kill the player if they touch its band.</summary>
void UpdateLines( float dt )
{
for ( int i = _lines.Count - 1; i >= 0; i-- )
{
var line = _lines[i];
line.Age += dt;
EmitSendParticles( line, dt );
if ( line.Age >= INDICATOR_TIME + line.DeadlyTime + FADE_OUT_TIME )
{
_lines.RemoveAt( i );
continue;
}
// Lethal ONLY during the deadly window — not the leading grey telegraph nor the trailing
// power-down fade (both harmless, so the visible fade can't hide a still-live line).
bool deadly = line.Age >= INDICATOR_TIME && line.Age < INDICATOR_TIME + line.DeadlyTime;
if ( deadly && !line.ArmedSfxPlayed )
{
Audio.PlaySfx( SfxType.Laser, Position, 0.33f );
line.ArmedSfxPlayed = true;
}
// Don't kill during the win sequence (blocks die, but the player lingers) — only a live line
// from an alive block is lethal.
if ( deadly && !IsDead )
{
float half = DEADLY_THICKNESS * 0.5f;
RectF band = line.Vertical
? new RectF( line.Coord - half, 0f, line.Coord + half, Arena.HEIGHT )
: new RectF( 0f, line.Coord - half, Arena.WIDTH, line.Coord + half );
// A live line kills any player body it touches — the real player AND any Summoner impostors.
if ( Stage is not null )
{
foreach ( var player in Stage.LivingPlayers ) KillInBand( player );
foreach ( var imp in Stage.Impostors ) KillInBand( imp );
}
void KillInBand( Player body )
{
if ( body is null || body.IsDead || !band.Intersects( body.GetRect() ) ) return;
// Spray blood away from the line (out along its normal, with a slight upward bias).
Vector2 dir = line.Vertical
? new Vector2( body.X >= line.Coord ? 1f : -1f, 0.2f )
: new Vector2( 0.2f, body.Y >= line.Coord ? 1f : -1f );
body.KilledByLaser( Utils.Normalized( dir ) );
}
}
}
}
void EmitSendParticles( LaserLine line, float dt )
{
if ( line.SendParticlesRemaining <= 0 || Stage is null ) return;
line.SendParticleTimer -= dt;
float interval = SEND_PARTICLE_EMIT_TIME / SEND_PARTICLE_COUNT;
while ( line.SendParticlesRemaining > 0 && line.SendParticleTimer <= 0f )
{
float along = Rng.CosmeticFloat( 0f, line.Vertical ? Arena.HEIGHT : Arena.WIDTH );
Vector2 target = line.Vertical ? new Vector2( line.Coord, along ) : new Vector2( along, line.Coord );
Vector2 start = line.SendOrigin + new Vector2(
Rng.CosmeticFloat( -SEND_PARTICLE_HORIZONTAL_JITTER, SEND_PARTICLE_HORIZONTAL_JITTER ),
Rng.CosmeticFloat( SEND_PARTICLE_MIN_HEIGHT, SEND_PARTICLE_MAX_HEIGHT ) );
// Colour the stream like the beam it's charging — phase 1 red/orange, phase 2 magenta/violet —
// reading line.Phase2 (captured at spawn) so a mid-charge phase-up can't recolour it, exactly
// like the render below. These were AddSpikes0/1, which is the stream Spikey sends at a face
// right before spikes graft there: the same motes can't mean two different incoming threats.
// ONE draw either way, so the accent stays a flat 25% and the cosmetic stream is phase-agnostic.
bool accent = Rng.CosmeticValue() < 0.25f;
ParticleKind kind = line.Phase2
? (accent ? ParticleKind.SlicerChargeDeep1 : ParticleKind.SlicerChargeDeep0)
: (accent ? ParticleKind.SlicerCharge1 : ParticleKind.SlicerCharge0);
TargetedParticle particle = Stage.AddTargetedParticle( start, Vector2.Zero, 0.9f, 0f, kind,
Rng.CosmeticFloat( 1.25f, 1.55f ), Rng.CosmeticInt( 2, 5 ), target,
Rng.CosmeticFloat( 4800f, 6000f ), null );
particle.SettleAtTarget();
line.SendParticlesRemaining--;
line.SendParticleTimer += interval;
}
}
// ----------------------------------------------------------------------------------------
/// <summary>Draw each line: a breathing grey telegraph while charging, then an orange glow + hot red
/// core (with a white-hot flash as it arms) while live, fading out at the end. Below the blocks.</summary>
void RenderLines()
{
int idx = 0;
float baseZ = Globals.DepthToZ( Globals.DEPTH_BLOCK - 1, StageIndex ); // below blocks, above background
foreach ( var line in _lines )
{
if ( line.Age < INDICATOR_TIME )
{
// Charging: thick, animated grey wave so wall-edge indicators remain obvious.
float p = line.Age / INDICATOR_TIME; // 0 → 1
float thick = INDICATOR_MIN_THICKNESS + (INDICATOR_MAX_THICKNESS - INDICATOR_MIN_THICKNESS) * p;
float pulse = 0.6f + 0.4f * (0.5f + 0.5f * MathF.Sin( line.Age * INDICATOR_PULSE_SPEED ));
float fadeIn = Math.Clamp( line.Age / INDICATOR_FADE_IN_TIME, 0f, 1f );
fadeIn *= fadeIn;
float alpha = fadeIn * (0.35f + 0.45f * p) * pulse;
idx = PlaceWavyIndicator( idx, line, thick, INDICATOR_COLOR.WithAlpha( alpha ), baseZ + 0.002f );
}
else
{
// Live: wide soft halo + bright glow + white-hot core. White flash at the moment it arms;
// full strength for the whole lethal window, then a harmless power-down fade. Layered +
// higher alpha so the live beam clearly pops off the field.
float deadlyElapsed = line.Age - INDICATOR_TIME;
bool inDeadly = deadlyElapsed < line.DeadlyTime;
// Full brightness while lethal; fade to 0 over FADE_OUT_TIME once the lethal window ends.
float fade = inDeadly ? 1f : Math.Clamp( 1f - (deadlyElapsed - line.DeadlyTime) / FADE_OUT_TIME, 0f, 1f );
float hot = Math.Clamp( 1f - deadlyElapsed / ARM_FLASH_TIME, 0f, 1f ); // white flash as it arms
float flicker = 0.9f + 0.1f * MathF.Sin( line.Age * DEADLY_FLICKER_SPEED );
// Vibrant brightness flash pulsing the whole live beam (0 → 1), plus a size pulse for the
// non-core layers (halo/glow breathe out of sync; the core stays a steady kill line).
float flash = 0.5f + 0.5f * MathF.Sin( line.Age * COLOR_FLASH_SPEED );
float haloScale = 1f + HALO_PULSE_AMOUNT * MathF.Sin( line.Age * SIZE_PULSE_SPEED );
float glowScale = 1f + GLOW_PULSE_AMOUNT * MathF.Sin( line.Age * SIZE_PULSE_SPEED + GLOW_PULSE_PHASE );
Color glow = line.Phase2 ? DEADLY_GLOW_COLOR_P2 : DEADLY_GLOW_COLOR_P1;
Color core = line.Phase2 ? DEADLY_CORE_COLOR_P2 : DEADLY_CORE_COLOR_P1;
// Wide faint outer halo — a soft bloom that makes the beam read from across the arena.
idx = PlaceLine( idx, line, GLOW_THICKNESS * 2f * haloScale, glow.WithAlpha( (0.16f + 0.16f * flash) * fade * flicker ), baseZ );
// Inner glow — flashes toward white for a vivid pulse.
Color glowCol = Color.Lerp( glow, Color.White, hot * 0.5f + 0.35f * flash );
idx = PlaceLine( idx, line, GLOW_THICKNESS * glowScale, glowCol.WithAlpha( (0.4f + 0.3f * flash) * fade * flicker ), baseZ + 0.002f );
// Hot core — steady width (the kill band), but brightness flashes vibrantly toward white.
Color coreCol = Color.Lerp( core, Color.White, Math.Clamp( 0.3f + 0.45f * hot + 0.35f * flash, 0f, 1f ) );
idx = PlaceLine( idx, line, DEADLY_THICKNESS, coreCol.WithAlpha( fade ), baseZ + 0.004f );
}
}
for ( int k = idx; k < _lineSprites.Count; k++ )
_lineSprites[k].Enabled = false;
}
/// <summary>Draw the charging indicator as an animated polyline that settles onto the straight laser
/// coordinate as it arms. At arena edges the wave bends only inward instead of behind the wall.</summary>
int PlaceWavyIndicator( int idx, LaserLine line, float thickness, Color color, float z )
{
float length = line.Vertical ? Arena.HEIGHT : Arena.WIDTH;
float crossSpan = line.Vertical ? Arena.WIDTH : Arena.HEIGHT;
float waveProgress = Math.Clamp( 1f - line.Age / INDICATOR_TIME, 0f, 1f );
float waveAmplitude = INDICATOR_WAVE_AMPLITUDE * waveProgress;
bool atMinEdge = line.Coord <= Arena.WALL_SIZE + WALL_EDGE_NUDGE + 0.5f;
bool atMaxEdge = line.Coord >= crossSpan - Arena.WALL_SIZE - WALL_EDGE_NUDGE - 0.5f;
int segmentCount = (int)MathF.Ceiling( length / INDICATOR_SEGMENT_LENGTH );
for ( int segment = 0; segment < segmentCount; segment++ )
{
float startAlong = segment * INDICATOR_SEGMENT_LENGTH;
float endAlong = Math.Min( startAlong + INDICATOR_SEGMENT_LENGTH, length );
Vector2 start = WavePoint( startAlong );
Vector2 end = WavePoint( endAlong );
Vector2 delta = end - start;
float segmentLength = delta.Length;
PlaceSegment( idx++, (start + end) * 0.5f, Utils.Normalized( delta ), segmentLength + 1f, thickness, color, z );
}
return idx;
Vector2 WavePoint( float along )
{
float phase = along / INDICATOR_WAVELENGTH * MathF.PI * 2f - line.Age * INDICATOR_WAVE_SPEED;
float offset = MathF.Sin( phase ) * waveAmplitude;
float inwardWave = (offset + waveAmplitude) * 0.5f;
if ( atMinEdge ) offset = inwardWave;
else if ( atMaxEdge ) offset = -inwardWave;
return line.Vertical
? new Vector2( line.Coord + offset, along )
: new Vector2( along, line.Coord + offset );
}
}
/// <summary>Place one full-level line quad (a thin rect spanning the whole arena along the line axis).</summary>
int PlaceLine( int idx, LaserLine line, float thickness, Color color, float z )
{
Vector2 direction = line.Vertical ? Vector2.Up : Vector2.Right;
Vector2 centre = line.Vertical
? new Vector2( line.Coord, Arena.HEIGHT * 0.5f )
: new Vector2( Arena.WIDTH * 0.5f, line.Coord );
float length = line.Vertical ? Arena.HEIGHT : Arena.WIDTH;
PlaceSegment( idx, centre, direction, length, thickness, color, z );
return idx + 1;
}
void PlaceSegment( int idx, Vector2 centre, Vector2 direction, float length, float thickness, Color color, float z )
{
while ( idx >= _lineSprites.Count )
{
var segment = Stage.CreateOverlaySprite();
_lineSprites.Add( segment );
}
var sr = _lineSprites[idx];
sr.Enabled = true;
sr.Color = color;
sr.Size = new Vector2( length, thickness );
sr.GameObject.WorldRotation = SpriteLayer.FlatRotation( new Vector2( -direction.y, direction.x ) );
sr.GameObject.WorldPosition = new Vector3( centre.x, centre.y, z );
}
}