Entities/Blocks/BlockLaser.cs
namespace BlockParty;
/// <summary>
/// A player-targeted laser emitter. Its beams pass through blocks and stop only at authored
/// obstacles or arena walls. Phase 1 fires one beam; phase 2 fires a three-beam, 42-degree fan.
/// </summary>
public class BlockLaser : Block
{
enum LaserState { Idle, Tracer, Laser }
sealed class BeamVisual
{
public Line Line;
public readonly List<SpriteRenderer> Core = new();
public readonly List<SpriteRenderer> Glow = new();
public readonly List<SpriteRenderer> Sparks = new();
}
const float TRACER_TIME = 2f;
const float LASER_TIME = 5f;
const float LASER_LENGTH = 384f;
const float FAN_ANGLE = 42f;
const float SFX_TRACER_TIME = 1.25f;
const float SFX_LASER_TIME = 1f;
const float BEAM_INCREMENT = 4f;
const int NUM_CORE = 88;
const float GLOW_INCREMENT = 8f;
const int NUM_GLOW = 44;
const int NUM_SPARK = 24;
const float WIDTH_STEP_TIME = 0.033f;
// The block root is at DEPTH_BLOCK (2). Bias the beam above block art but below players,
// avoiding ties with integer depth planes used by particles and gameplay bodies.
const float GLOW_LOCAL_Z = Globals.DEPTH_PLAYER - Globals.DEPTH_BLOCK - 0.35f;
const float CORE_LOCAL_Z = GLOW_LOCAL_Z + SpriteLayer.LAYER_Z_STEP;
const float SPARK_LOCAL_Z = CORE_LOCAL_Z + SpriteLayer.LAYER_Z_STEP;
static readonly int[] LASER_WIDTHS = { 2, 4, 6, 8, 6, 4 };
static readonly int[] TRACER_WIDTHS = { 1, 1, 2, 3, 2, 1 };
static readonly float[] TRACER_PULSE_AMOUNTS = { 0f, 0.04f, 0.12f, 0.22f, 0.12f, 0.04f };
static readonly Color TRACER_COLOR = new( 0.35f, 0.50f, 0.72f );
static readonly Color CORE_COLOR = new( 0.15f, 1f, 1f );
static readonly Color GLOW_COLOR = new( 0.55f, 0.25f, 1f );
readonly BeamVisual[] _beams = { new(), new(), new() };
readonly Random _renderRng = new();
LaserState _state;
protected override bool CanActivateTimedAbility => _state == LaserState.Idle;
Vector2 _aimDirection;
float _timer;
float _sfxTimer;
float _widthTimer;
int _widthCounter;
// A phase-up during an active volley must not materialize two deadly beams that had no tracer.
int _activeBeamCount = 1;
int _activeFiringPhase = 1;
Color _activeTracerColor = TRACER_COLOR;
Color _activeCoreColor = CORE_COLOR;
Color _activeGlowColor = GLOW_COLOR;
protected virtual Vector2 BeamOriginPosition => Position + new Vector2( 0f, -6f );
protected virtual int BeamCountForPhase( int phase ) => phase >= 2 ? 3 : 1;
protected virtual bool UsesShootingMouth => true;
protected virtual string ShootingEyeAnimation( int phase, Vector2 aimDirection )
=> $"eyes_shooting_{Globals.GetStringForDirection( ClosestCardinalDirection( aimDirection ) )}";
static Direction ClosestCardinalDirection( Vector2 direction )
{
if ( direction.LengthSquared <= 0.0001f ) return Direction.Down;
if ( MathF.Abs( direction.x ) > MathF.Abs( direction.y ) )
return direction.x > 0f ? Direction.Right : Direction.Left;
return direction.y > 0f ? Direction.Up : Direction.Down;
}
protected virtual Vector2 AimDirectionForPhase( int phase )
{
Vector2 target = Stage.ClosestTargetablePlayer( BeamOriginPosition )?.Position ?? Arena.Center;
Vector2 towardTarget = target - BeamOriginPosition;
return towardTarget.LengthSquared > 0.0001f
? Utils.Normalized( towardTarget )
: new Vector2( 0f, -1f );
}
protected virtual IEnumerable<Entity2D> BeamBlockers() => Stage.GetSolidObstacles();
/// <summary>Surfaces a fresh aim won't fire into (its beam would die on the spot). Obstacles and walls
/// both stop this beam; the piercing variant only cares about walls.</summary>
protected virtual AimAvoid FreshAimAvoids => AimAvoid.WallsAndObstacles;
protected virtual Color TracerColorForPhase( int phase ) => TRACER_COLOR;
protected virtual Color CoreColorForPhase( int phase ) => CORE_COLOR;
protected virtual Color GlowColorForPhase( int phase ) => GLOW_COLOR;
protected virtual float BeamGlowLocalZ => GLOW_LOCAL_Z;
protected virtual float BeamCoreLocalZ => CORE_LOCAL_Z;
protected virtual float BeamSparkLocalZ => SPARK_LOCAL_Z;
protected virtual float DeadlyCoreWidthScale => 1f;
protected virtual float DeadlyGlowWidthScale => 1f;
public override void CreateVisuals()
{
base.CreateVisuals();
foreach ( BeamVisual beam in _beams )
{
BuildPool( beam.Glow, NUM_GLOW );
BuildPool( beam.Core, NUM_CORE );
BuildPool( beam.Sparks, NUM_SPARK );
}
}
void BuildPool( List<SpriteRenderer> pool, int count )
{
for ( int i = 0; i < count; i++ )
{
var sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Vector2.One );
sprite.Enabled = false;
pool.Add( sprite );
}
}
public override void Impact( Block otherBlock )
{
ImpactEffects( otherBlock );
if ( IsDead ) return;
if ( _moveSpeed > 50f && Phase >= 1 )
StartLaser();
else
CloseEyesAndPickNewDirection();
}
void StartLaser()
{
int firingPhase = Phase;
_activeFiringPhase = firingPhase;
_activeBeamCount = Math.Clamp( BeamCountForPhase( firingPhase ), 1, _beams.Length );
_aimDirection = DeflectAimOffSurfaces( BeamOriginPosition, AimDirectionForPhase( firingPhase ), FreshAimAvoids );
_activeTracerColor = TracerColorForPhase( firingPhase );
_activeCoreColor = CoreColorForPhase( firingPhase );
_activeGlowColor = GlowColorForPhase( firingPhase );
_state = LaserState.Tracer;
_timer = TRACER_TIME;
_sfxTimer = 0f;
// Every volley starts its pulse animation from the same phase (cosmetic — the width cycle
// would otherwise resume wherever the previous volley left off).
_widthTimer = 0f;
_widthCounter = 0;
SuppressEyeBlink = true;
_eyes.PlayAnimation( ShootingEyeAnimation( firingPhase, _aimDirection ) );
if ( UsesShootingMouth )
_mouth.PlayAnimation( $"mouth_{firingPhase}_shoot_hold" );
}
public override void Tick( float dt )
{
base.Tick( dt );
if ( IsDead || _state == LaserState.Idle ) return;
ComputeBeams();
if ( _state == LaserState.Laser )
{
AddShake( Utils.RandomVector() );
foreach ( Player player in Stage.LivingPlayers ) HitBody( player );
foreach ( Player impostor in Stage.Impostors ) HitBody( impostor );
}
_sfxTimer -= dt;
if ( _sfxTimer <= 0f )
{
if ( _state == LaserState.Tracer )
{
Audio.PlaySfx( SfxType.LaserTracer, Position, 0.25f );
_sfxTimer = SFX_TRACER_TIME;
}
else
{
Audio.PlaySfx( SfxType.Laser, Position, 0.33f );
_sfxTimer = SFX_LASER_TIME;
}
}
_timer -= dt;
if ( _timer <= 0f )
{
if ( _state == LaserState.Tracer )
{
_state = LaserState.Laser;
_timer = LASER_TIME;
_sfxTimer = 0f;
if ( UsesShootingMouth )
_mouth.PlayAnimation( $"mouth_{_activeFiringPhase}_shoot" );
}
else
{
StopLaser();
return;
}
}
RenderBeams( dt );
}
void HitBody( Player body )
{
if ( body is null || body.IsDead ) return;
for ( int i = 0; i < _activeBeamCount; i++ )
{
Line line = _beams[i].Line;
if ( !line.Intersects( body.GetRect() ) ) continue;
body.KilledByLaser( Utils.Normalized( body.Position - line.A ) );
return;
}
}
void ComputeBeams()
{
int count = _activeBeamCount;
for ( int i = 0; i < count; i++ )
{
float angle = count == 1 ? 0f : -FAN_ANGLE * 0.5f + FAN_ANGLE * i / (count - 1);
Vector2 direction = Utils.Normalized( Utils.RotateVector( _aimDirection, angle ) );
_beams[i].Line = ClipBeam( direction );
}
}
Line ClipBeam( Vector2 direction )
{
Vector2 start = BeamOriginPosition;
var ray = new Line( start, start + direction * LASER_LENGTH );
float nearestDistanceSquared = float.MaxValue;
Vector2 end = Vector2.Zero;
bool hit = false;
foreach ( Entity2D obstacle in BeamBlockers() )
{
if ( !ray.Intersects( obstacle.GetRect(), out Vector2 point ) ) continue;
float distanceSquared = (point - start).LengthSquared;
if ( distanceSquared >= nearestDistanceSquared ) continue;
nearestDistanceSquared = distanceSquared;
end = point;
hit = true;
}
if ( !hit )
{
float width = Arena.WIDTH;
float height = Arena.HEIGHT;
if ( ray.Intersects( new Line( Vector2.Zero, new Vector2( width, 0f ) ), out Vector2 point ) ) end = point;
else if ( ray.Intersects( new Line( Vector2.Zero, new Vector2( 0f, height ) ), out point ) ) end = point;
else if ( ray.Intersects( new Line( new Vector2( width, 0f ), new Vector2( width, height ) ), out point ) ) end = point;
else if ( ray.Intersects( new Line( new Vector2( 0f, height ), new Vector2( width, height ) ), out point ) ) end = point;
else end = ray.B;
}
return new Line( start, end );
}
void RenderBeams( float dt )
{
_widthTimer -= dt;
if ( _widthTimer < 0f )
{
_widthCounter = (_widthCounter - 1 + LASER_WIDTHS.Length) % LASER_WIDTHS.Length;
_widthTimer = WIDTH_STEP_TIME;
}
for ( int i = 0; i < _beams.Length; i++ )
{
if ( i < _activeBeamCount ) RenderBeam( _beams[i] );
else HideBeam( _beams[i] );
}
}
void RenderBeam( BeamVisual beam )
{
bool deadly = _state == LaserState.Laser;
float length = beam.Line.Length;
Vector2 direction = length > 0.001f ? (beam.Line.B - beam.Line.A) / length : Vector2.Zero;
if ( deadly )
{
float glowWidth = RenderFloat( 9f, 13f ) * DeadlyGlowWidthScale;
PlaceLine( beam.Glow, beam.Line.A, direction, length, GLOW_INCREMENT, glowWidth, _activeGlowColor, BeamGlowLocalZ );
DrawSparks( beam, beam.Line.A, 0 );
DrawSparks( beam, beam.Line.B, NUM_SPARK / 2 );
}
else
{
HidePool( beam.Glow );
HidePool( beam.Sparks );
}
int[] widths = deadly ? LASER_WIDTHS : TRACER_WIDTHS;
for ( int i = 0; i < beam.Core.Count; i++ )
{
float distance = (i + 0.5f) * BEAM_INCREMENT;
if ( distance - BEAM_INCREMENT * 0.5f > length )
{
beam.Core[i].Enabled = false;
continue;
}
int pulseIndex = (_widthCounter + i) % widths.Length;
float width = widths[pulseIndex];
Color color = deadly
? _activeCoreColor
: Color.Lerp( _activeTracerColor, Color.White, TRACER_PULSE_AMOUNTS[pulseIndex] );
if ( deadly ) width *= DeadlyCoreWidthScale;
PlaceSegment( beam.Core[i], beam.Line.A + direction * distance, direction, BEAM_INCREMENT + 1f, width, color, BeamCoreLocalZ );
}
}
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 DrawSparks( BeamVisual beam, Vector2 origin, int offset )
{
for ( int i = 0; i < 6; i++ )
{
Vector2 direction = RenderUnitVector();
float length = RenderFloat( 4f, 12f );
PlaceSegment( beam.Sparks[offset + i], origin + direction * (length * 0.5f), direction, length, RenderFloat( 2f, 3.5f ), _activeGlowColor, BeamSparkLocalZ );
}
for ( int i = 0; i < 6; i++ )
{
Vector2 direction = RenderUnitVector();
float length = RenderFloat( 2f, 8f );
PlaceSegment( beam.Sparks[offset + 6 + i], origin + direction * (length * 0.5f), direction, length, RenderFloat( 1f, 2f ), _activeCoreColor, BeamSparkLocalZ );
}
}
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);
Vector2 RenderUnitVector()
{
float angle = RenderFloat( 0f, MathF.PI * 2f );
return new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
}
static void HidePool( List<SpriteRenderer> pool )
{
foreach ( SpriteRenderer sprite in pool ) sprite.Enabled = false;
}
static void HideBeam( BeamVisual beam )
{
HidePool( beam.Core );
HidePool( beam.Glow );
HidePool( beam.Sparks );
}
void StopLaser()
{
_state = LaserState.Idle;
SuppressEyeBlink = false;
foreach ( BeamVisual beam in _beams ) HideBeam( beam );
RestoreFaceAndMouth();
CloseEyesAndPickNewDirection();
}
public override void Die()
{
base.Die();
_state = LaserState.Idle;
SuppressEyeBlink = false;
foreach ( BeamVisual beam in _beams ) HideBeam( beam );
}
}