Entities/Blocks/BlockStraightLaser.cs
namespace BlockParty;
/// <summary>
/// A persistent cardinal laser emitter. Phase 1 chooses one face when the phase begins;
/// phase 2 permanently covers all four directions after one shared warning period.
/// </summary>
public sealed class BlockStraightLaser : Block
{
sealed class BeamVisual
{
public Direction Direction;
public Line Line;
public readonly List<SpriteRenderer> Core = new();
public readonly List<SpriteRenderer> Glow = new();
}
const float INDICATOR_TIME = 1.25f;
const float LASER_LENGTH = 384f;
const float CORE_INCREMENT = 4f;
const float GLOW_INCREMENT = 8f;
const int CORE_SEGMENT_COUNT = 60;
const int GLOW_SEGMENT_COUNT = 30;
const float WIDTH_STEP_TIME = 0.033f;
const int WIDTH_CYCLE_LENGTH = 12;
const float IMPACT_PARTICLE_INTERVAL = 0.06f;
const float DEADLY_SFX_PITCH = 0.85f;
// Draw below block bodies so the clipped endpoint cannot paint over the solid which stopped it.
const float GLOW_LOCAL_Z = -0.35f;
const float CORE_LOCAL_Z = -0.25f;
static readonly int[] INDICATOR_WIDTHS = { 1, 1, 2, 3, 2, 1 };
static readonly int[] LASER_WIDTHS = { 2, 4, 6, 4 };
static readonly Color INDICATOR_COLOR = new( 0.18f, 0.18f, 0.18f );
static readonly Color INDICATOR_PULSE_COLOR = new( 0.52f, 0.52f, 0.52f );
static readonly Color CORE_COLOR = new( 0.82f, 1f, 0.32f );
static readonly Color GLOW_COLOR = new( 0.08f, 0.82f, 0.42f );
// Built at construction, NOT in CreateVisuals: lethality flows through these (_activeBeamCount,
// beam.Line), so the sim must not depend on the visual-setup call having run (same reasoning as
// BlockLaser's _beams array). CreateVisuals only fills the sprite pools inside them.
readonly BeamVisual[] _beams = { new(), new(), new(), new() };
readonly Random _renderRng = new();
int _activeBeamCount;
int _beamPhase = -1;
float _indicatorTimer;
float _impactParticleTimer;
float _widthTimer;
int _widthCounter;
bool _dangerous;
bool _intervalOff;
/// <summary>While boxed in at phase 1+, the authored ability timings loop the laser on/off: eyes
/// stay open (laser on) for the Interval, shut (laser off) for the ClosedTime, forever. Each
/// re-open replays the full indicator warning, so the Interval should be authored longer than
/// <see cref="INDICATOR_TIME"/> to get any deadly time. Non-enclosed lasers never enter the loop.</summary>
protected override bool HasEnclosedEyeLoop => true;
/// <summary>An enclosed laser's beams never re-aim (directions are fixed when the phase begins),
/// so the loop's eye cycles keep the current gaze instead of swinging toward the player.</summary>
protected override Direction GetEnclosedGazeDirection() => MoveDirection;
public override void CreateVisuals()
{
base.CreateVisuals();
foreach ( BeamVisual beam in _beams )
{
BuildPool( beam.Glow, GLOW_SEGMENT_COUNT );
BuildPool( beam.Core, CORE_SEGMENT_COUNT );
}
}
void BuildPool( List<SpriteRenderer> pool, int count )
{
for ( int i = 0; i < count; i++ )
{
SpriteRenderer sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Vector2.One );
sprite.Enabled = false;
pool.Add( sprite );
}
}
public override void Tick( float dt )
{
base.Tick( dt );
if ( IsDead ) return;
SynchronizePhase();
if ( _activeBeamCount == 0 || Phase == 0 ) return;
// The enclosed loop's off-leg: the beams vanish (and can't kill) whenever the eyes are anything
// but fully open. Beam directions stay armed in _beams, so the re-open resumes the same set.
if ( EnclosedEyeLoopRunning && EyesOpenAmount < 1f )
{
if ( !_intervalOff )
{
_intervalOff = true;
_dangerous = false;
for ( int i = 0; i < _activeBeamCount; i++ )
{
HidePool( _beams[i].Core );
HidePool( _beams[i].Glow );
}
}
return;
}
if ( _intervalOff )
{
_intervalOff = false;
_indicatorTimer = INDICATOR_TIME;
_impactParticleTimer = 0f;
Audio.PlaySfx( SfxType.LaserTracer, Position, 0.25f );
}
if ( !_dangerous )
{
_indicatorTimer -= dt;
if ( _indicatorTimer <= 0f )
{
_dangerous = true;
Audio.PlaySfx( SfxType.Laser, Position, 0.33f, DEADLY_SFX_PITCH );
}
}
ComputeBeams();
if ( _dangerous )
{
foreach ( Player player in Stage.LivingPlayers ) HitBody( player );
foreach ( Player impostor in Stage.Impostors ) HitBody( impostor );
TickImpactParticles( dt );
}
RenderBeams( dt );
}
protected override void NextPhase()
{
int previousPhase = Phase;
base.NextPhase();
if ( !IsDead && Phase != previousPhase ) SynchronizePhase();
}
void SynchronizePhase()
{
if ( _beamPhase == Phase ) return;
_beamPhase = Phase;
if ( Phase == 1 )
{
StartPhaseOneBeam();
}
else if ( Phase >= 2 )
{
StartBeamSet( Direction.Left, Direction.Right, Direction.Down, Direction.Up );
}
else
{
ClearBeams();
}
}
void StartPhaseOneBeam()
{
Direction original = Globals.GetOppositeDirection( MoveDirection );
if ( !IsFaceCenterBlocked( original ) )
{
StartBeamSet( original );
return;
}
var openDirections = new List<Direction>( 3 );
foreach ( Direction direction in Globals.GetAllDirections() )
if ( direction != original && !IsFaceCenterBlocked( direction ) )
openDirections.Add( direction );
Direction selected = openDirections.Count == 0
? original
: openDirections[Rng.Int( 0, openDirections.Count )];
StartBeamSet( selected );
}
bool IsFaceCenterBlocked( Direction direction )
{
Vector2 outward = Globals.GetVectorForDirection( direction );
float faceDistance = direction is Direction.Left or Direction.Right ? Width * 0.5f : Height * 0.5f;
Vector2 faceCenter = Position + outward * faceDistance;
if ( faceCenter.x <= Arena.WALL_SIZE || faceCenter.x >= Arena.WIDTH - Arena.WALL_SIZE
|| faceCenter.y <= Arena.WALL_SIZE || faceCenter.y >= Arena.HEIGHT - Arena.WALL_SIZE )
return true;
foreach ( Block block in Stage.GetBlocks() )
if ( block != this && !block.PhasingIn && block.GetRect().Touches( faceCenter ) ) return true;
foreach ( Entity2D obstacle in Stage.GetSolidObstacles() )
if ( obstacle.GetRect().Touches( faceCenter ) ) return true;
return false;
}
void StartBeamSet( params Direction[] directions )
{
ClearBeams();
_activeBeamCount = Math.Min( directions.Length, _beams.Length );
for ( int i = 0; i < _activeBeamCount; i++ )
_beams[i].Direction = directions[i];
_indicatorTimer = INDICATOR_TIME;
_impactParticleTimer = 0f;
_dangerous = false;
Audio.PlaySfx( SfxType.LaserTracer, Position, 0.25f );
}
void ComputeBeams()
{
for ( int i = 0; i < _activeBeamCount; i++ )
_beams[i].Line = ClipBeam( _beams[i].Direction );
}
Line ClipBeam( Direction beamDirection )
{
Vector2 direction = Globals.GetVectorForDirection( beamDirection );
float faceDistance = beamDirection is Direction.Left or Direction.Right ? Width * 0.5f : Height * 0.5f;
Vector2 start = Position + direction * faceDistance;
var ray = new Line( start, start + direction * LASER_LENGTH );
Vector2 end = ArenaEndpoint( start, beamDirection );
float nearestDistanceSquared = (end - start).LengthSquared;
foreach ( Block block in Stage.GetBlocks() )
{
if ( block == this || block.PhasingIn ) continue; // the beam passes straight through a phasing Teleport block
if ( !ray.Intersects( block.GetRect(), out Vector2 point ) ) continue;
UseNearest( point );
}
foreach ( Entity2D obstacle in Stage.GetSolidObstacles() )
{
if ( !ray.Intersects( obstacle.GetRect(), out Vector2 point ) ) continue;
UseNearest( point );
}
return new Line( start, end );
void UseNearest( Vector2 point )
{
float distanceSquared = (point - start).LengthSquared;
if ( distanceSquared >= nearestDistanceSquared ) return;
nearestDistanceSquared = distanceSquared;
end = point;
}
}
static Vector2 ArenaEndpoint( Vector2 start, Direction direction ) => direction switch
{
Direction.Left => new Vector2( 0f, start.y ),
Direction.Right => new Vector2( Arena.WIDTH, start.y ),
Direction.Down => new Vector2( start.x, 0f ),
Direction.Up => new Vector2( start.x, Arena.HEIGHT ),
_ => start,
};
void HitBody( Player body )
{
if ( body is null || body.IsDead ) return;
for ( int i = 0; i < _activeBeamCount; i++ )
{
BeamVisual beam = _beams[i];
if ( !beam.Line.Intersects( body.GetRect() ) ) continue;
body.KilledByLaser( Globals.GetVectorForDirection( beam.Direction ) );
return;
}
}
void TickImpactParticles( float dt )
{
_impactParticleTimer -= dt;
if ( _impactParticleTimer > 0f ) return;
_impactParticleTimer += IMPACT_PARTICLE_INTERVAL;
for ( int i = 0; i < _activeBeamCount; i++ )
{
BeamVisual beam = _beams[i];
Vector2 direction = Globals.GetVectorForDirection( beam.Direction );
Vector2 tangent = new( -direction.y, direction.x );
float tangentSpeed = Rng.CosmeticValue() < 0.22f
? Rng.CosmeticFloat( 65f, 110f ) * (Rng.CosmeticValue() < 0.5f ? -1f : 1f)
: Rng.CosmeticFloat( -34f, 34f );
Vector2 velocity = -direction * Rng.CosmeticFloat( 20f, 48f )
+ tangent * tangentSpeed;
Color color = Rng.CosmeticValue() < 0.35f ? CORE_COLOR : GLOW_COLOR;
Stage.AddColoredParticle( beam.Line.B - direction * 0.5f, velocity,
Rng.CosmeticFloat( 0.82f, 0.92f ), color, Rng.CosmeticFloat( 0.16f, 0.3f ),
Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
void RenderBeams( float dt )
{
_widthTimer -= dt;
if ( _widthTimer <= 0f )
{
_widthCounter = (_widthCounter - 1 + WIDTH_CYCLE_LENGTH) % WIDTH_CYCLE_LENGTH;
_widthTimer = WIDTH_STEP_TIME;
}
for ( int i = 0; i < _activeBeamCount; i++ ) RenderBeam( _beams[i] );
}
void RenderBeam( BeamVisual beam )
{
float length = beam.Line.Length;
Vector2 direction = length > 0.001f ? (beam.Line.B - beam.Line.A) / length : Vector2.Zero;
if ( _dangerous )
{
PlaceLine( beam.Glow, beam.Line.A, direction, length, GLOW_INCREMENT,
RenderFloat( 8f, 11f ), GLOW_COLOR, GLOW_LOCAL_Z );
}
else
{
HidePool( beam.Glow );
}
int[] widths = _dangerous ? LASER_WIDTHS : INDICATOR_WIDTHS;
for ( int i = 0; i < beam.Core.Count; i++ )
{
float distance = (i + 0.5f) * CORE_INCREMENT;
if ( distance - CORE_INCREMENT * 0.5f > length )
{
beam.Core[i].Enabled = false;
continue;
}
int pulseIndex = (_widthCounter + i) % widths.Length;
float width = widths[pulseIndex];
Color color = _dangerous
? CORE_COLOR
: width >= 2f ? INDICATOR_PULSE_COLOR : INDICATOR_COLOR;
PlaceSegment( beam.Core[i], beam.Line.A + direction * distance, direction,
CORE_INCREMENT + 1f, width, color, CORE_LOCAL_Z );
}
}
void PlaceLine( List<SpriteRenderer> pool, Vector2 start, Vector2 direction, float length,
float increment, float width, Color color, float localZ )
{
for ( int i = 0; i < pool.Count; i++ )
{
float distance = (i + 0.5f) * increment;
if ( distance - increment * 0.5f > length )
{
pool[i].Enabled = false;
continue;
}
PlaceSegment( pool[i], start + direction * distance, direction,
increment + 1f, width, color, localZ );
}
}
void PlaceSegment( SpriteRenderer sprite, Vector2 worldPosition, Vector2 direction,
float length, float width, Color color, float localZ )
{
sprite.Enabled = true;
sprite.Color = color;
sprite.Size = new Vector2( length, width );
sprite.GameObject.LocalRotation = SpriteLayer.FlatRotation( new Vector2( -direction.y, direction.x ) );
Vector2 localPosition = worldPosition - Pos;
sprite.GameObject.LocalPosition = new Vector3( localPosition.x, localPosition.y, localZ );
}
float RenderFloat( float min, float max ) => min + (float)_renderRng.NextDouble() * (max - min);
static void HidePool( List<SpriteRenderer> pool )
{
foreach ( SpriteRenderer sprite in pool ) sprite.Enabled = false;
}
void ClearBeams()
{
foreach ( BeamVisual beam in _beams )
{
HidePool( beam.Core );
HidePool( beam.Glow );
}
_activeBeamCount = 0;
_dangerous = false;
_intervalOff = false;
}
public override void Die()
{
base.Die();
ClearBeams();
}
}