Entities/Abilities/SolarAbility.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Projects sunlight down from the arena ceiling and converts exposure into movement energy.</summary>
public sealed class SolarAbility : PlayerAbility
{
internal const float AIR_JUMP_ENERGY_COST = 0.5f;
const float CHARGE_PER_SECOND = 0.42f;
const float DRAIN_PER_SECOND = 0.55f;
const float EMPTY_MOVEMENT_FACTOR = 0.34f;
const float FULL_MOVEMENT_FACTOR = 1.15f;
const float EMPTY_JUMP_FACTOR = 0.72f;
const float FULL_JUMP_FACTOR = 1.15f;
const float EMPTY_AIR_CONTROL_FACTOR = 0.6f;
// Strength at zero energy. Each ability reaches its normal authored strength at full energy.
const float EMPTY_DASH_FACTOR = 0.35f;
const float EMPTY_WALL_DIVE_FACTOR = 0.5f;
const float EMPTY_AIR_JUMP_FACTOR = 0.8f;
const float EMPTY_WALL_CLING_FACTOR = 0.25f;
const float EMPTY_BOUNCE_FACTOR = 0.7f;
const float EDGE_EPSILON = 0.05f;
const float PARTICLE_INTERVAL = 0.12f;
const float FULL_CHARGE_PARTICLE_INTERVAL = 0.09f;
internal const float FULL_ENERGY_THRESHOLD = 0.999f;
const float BLOCK_STUN_TIME = 2.2f;
// Low, heavy hum while out of the light — the drain is what hurts, so it reads as a dull labouring
// rumble rather than an alarm.
const float SHADE_HAPTIC_STRENGTH = 0.1f;
const float GLOW_PULSE_SPEED = 5f;
const float ENERGY_BAR_EMPTY_PULSE_SPEED = 8.5f;
const float ENERGY_BAR_FULL_PULSE_SPEED = 14f;
const float ENERGY_BAR_WIDTH = 10f;
const float ENERGY_BAR_INNER_WIDTH = 8f;
const float ENERGY_BAR_HEIGHT_ABOVE_PLAYER = 4f;
static readonly Color SUNLIGHT_OUTLINE_COLOR = new( 1f, 244f / 255f, 156f / 255f );
static readonly Color ENERGY_BAR_BACK_COLOR = new( 0.12f, 0.16f, 0.18f );
static readonly Color ENERGY_BAR_FILL_COLOR = new( 1f, 0.75f, 0.18f );
static readonly Color ENERGY_BAR_SHADE_FILL_COLOR = new( 1f, 0.38f, 0.08f );
static readonly Color ENERGY_BAR_LOW_FILL_COLOR = new( 0.72f, 0.22f, 0.06f );
static readonly Color ENERGY_BAR_LOW_SHADE_FILL_COLOR = new( 0.58f, 0.12f, 0.04f );
static readonly Color ROCKET_RED = new( 0.95f, 0.06f, 0.01f );
static readonly Color ROCKET_ORANGE = new( 1f, 0.38f, 0.02f );
static readonly Color ROCKET_YELLOW = new( 1f, 0.9f, 0.16f );
static readonly Color SMOKE_DARK = new( 0.05f, 0.06f, 0.07f );
static readonly Color SMOKE_LIGHT = new( 0.22f, 0.24f, 0.25f );
readonly List<RectF> _beams = new();
readonly List<SpriteRenderer> _overlay = new();
PlayerSpriteOutline _glow;
GameObject _energyBarBackRoot;
GameObject _energyBarFillRoot;
SpriteRenderer _energyBarBack;
SpriteRenderer _energyBarFill;
float _particleTimer;
float _fullChargeParticleTimer;
float _glowPhase;
public override void OnSpawn( Player player )
{
player.Energy = Math.Clamp( player.Energy, 0f, 1f );
UpdateSunlight( player );
player.SolarInSunlight = IsLit( player.GetRect() );
var glowObject = new GameObject( true, "solar sunlight outline" );
glowObject.SetParent( player.GameObject );
glowObject.LocalPosition = Vector3.Zero;
_glow = glowObject.Components.Create<PlayerSpriteOutline>();
_glow.Setup( player );
UpdateGlow( player );
CreateEnergyBar( player );
UpdateEnergyBar( player );
}
public override void OnRemoved( Player player )
{
player.SolarMovementFactor = 1f;
player.SolarJumpFactor = 1f;
player.SolarAirControlFactor = 1f;
player.SolarDashFactor = 1f;
player.SolarWallDiveFactor = 1f;
player.SolarAirJumpFactor = 1f;
player.SolarWallClingFactor = 1f;
player.SolarBounceFactor = 1f;
player.SolarInSunlight = false;
foreach ( var sprite in _overlay )
GameStage.DestroyOverlaySprite( sprite );
_overlay.Clear();
_beams.Clear();
_glow?.GameObject?.Destroy();
_energyBarBackRoot?.Destroy();
_energyBarFillRoot?.Destroy();
_glow = null;
_energyBarBackRoot = null;
_energyBarFillRoot = null;
_energyBarBack = null;
_energyBarFill = null;
}
public override void OnDeath( Player player )
{
// The sun goes out with the robot: a dead player never ticks, so the beams would otherwise
// freeze at their last shape while blocks keep moving through them until the run resets.
foreach ( var sprite in _overlay )
sprite.Enabled = false;
_beams.Clear();
if ( _glow is not null )
_glow.Enabled = false;
HideEnergyBar();
}
public override void PreTick( Player player, float dt )
{
UpdateSunlight( player );
bool lit = IsLit( player.GetRect() );
player.SolarInSunlight = lit;
player.Energy = Math.Clamp( player.Energy + (lit ? CHARGE_PER_SECOND : -DRAIN_PER_SECOND) * dt, 0f, 1f );
player.SolarMovementFactor = EMPTY_MOVEMENT_FACTOR
+ (FULL_MOVEMENT_FACTOR - EMPTY_MOVEMENT_FACTOR) * player.Energy;
player.SolarJumpFactor = EMPTY_JUMP_FACTOR
+ (FULL_JUMP_FACTOR - EMPTY_JUMP_FACTOR) * player.Energy;
player.SolarAirControlFactor = EMPTY_AIR_CONTROL_FACTOR
+ (1f - EMPTY_AIR_CONTROL_FACTOR) * player.Energy;
player.SolarDashFactor = EnergyFactor( EMPTY_DASH_FACTOR, player.Energy );
player.SolarWallDiveFactor = EnergyFactor( EMPTY_WALL_DIVE_FACTOR, player.Energy );
player.SolarAirJumpFactor = EMPTY_AIR_JUMP_FACTOR
+ (FULL_JUMP_FACTOR - EMPTY_AIR_JUMP_FACTOR) * player.Energy;
player.SolarWallClingFactor = EnergyFactor( EMPTY_WALL_CLING_FACTOR, player.Energy );
player.SolarBounceFactor = EnergyFactor( EMPTY_BOUNCE_FACTOR, player.Energy );
if ( !lit && player.DrivesHaptics )
Haptics.Sustain( HapticChannel.SolarShade, SHADE_HAPTIC_STRENGTH, 0f, Haptics.TONE_HEAVY );
_glowPhase += dt;
UpdateGlow( player );
TickFullChargeTrail( player, dt );
bool changing = lit ? player.Energy < 0.999f : player.Energy > 0.001f;
_particleTimer -= dt;
if ( !changing )
{
// Pin at 0 while steady (fully charged in light / empty in dark) — letting the timer run
// negative banks a backlog that the += interval below can't absorb, so the next transition
// would emit a particle EVERY tick until the debt is repaid (a machine-gun spray).
_particleTimer = Math.Max( _particleTimer, 0f );
}
else if ( _particleTimer <= 0f )
{
_particleTimer += PARTICLE_INTERVAL;
EmitEnergyParticle( player, lit );
}
}
public override void PostTick( Player player, float dt )
{
UpdateEnergyBar( player );
}
public override void OnBouncedOnBlock( Player player, Block block )
{
if ( player.Energy < FULL_ENERGY_THRESHOLD ) return;
player.Energy = 0f;
block.Stun( BLOCK_STUN_TIME );
_fullChargeParticleTimer = 0f;
}
static float EnergyFactor( float emptyFactor, float energy )
{
return emptyFactor + (1f - emptyFactor) * energy;
}
internal static void EmitAirJumpParticles( Player player )
{
float gravitySign = player.GravityReversed ? -1f : 1f;
Vector2 origin = new( player.X, player.Y - 5f * gravitySign );
int rocketCount = Rng.CosmeticInt( 9, 14 );
for ( int particleIndex = 0; particleIndex < rocketCount; particleIndex++ )
{
float speed = Rng.CosmeticFloat( 170f, 275f );
Vector2 velocity = new(
Rng.CosmeticFloat( -0.22f, 0.22f ) * speed,
-gravitySign * speed
);
Vector2 position = origin + new Vector2(
Rng.CosmeticFloat( -2f, 2f ),
Rng.CosmeticFloat( -1f, 1f )
);
Particle rocket = player.Stage.AddPhysicsParticle( position, velocity,
Rng.CosmeticFloat( 0.988f, 0.995f ), 0f, RocketColor(),
Rng.CosmeticFloat( 0.75f, 1.15f ), Rng.CosmeticInt( 1, 4 ) );
rocket.SetCollision( true, ParticleSolidResponse.Bounce, restitution: 0.4f );
}
int smokeCount = Rng.CosmeticInt( 5, 9 );
for ( int particleIndex = 0; particleIndex < smokeCount; particleIndex++ )
{
float downwardSpeed = Rng.CosmeticFloat( 18f, 62f );
var lateralSpread = Rng.CosmeticFloat( -1.5f, 1.5f );
Vector2 velocity = new(
lateralSpread * downwardSpeed,
-gravitySign * downwardSpeed
);
Color color = Color.Lerp( SMOKE_DARK, SMOKE_LIGHT, Rng.CosmeticValue() );
var riseSpeed = Rng.CosmeticFloat( 250f, 1250f );
Particle smoke = player.Stage.AddPhysicsParticle( origin, velocity,
Rng.CosmeticFloat( 0.95f, 0.97f ), gravitySign * riseSpeed, color,
Rng.CosmeticFloat( 0.65f, 0.95f ), Rng.CosmeticInt( 3, 7 ) );
smoke.SetCollision( false, ParticleSolidResponse.Ignore );
}
}
static Color RocketColor()
{
float heat = Rng.CosmeticValue();
return heat < 0.5f
? Color.Lerp( ROCKET_RED, ROCKET_ORANGE, heat * 2f )
: Color.Lerp( ROCKET_ORANGE, ROCKET_YELLOW, (heat - 0.5f) * 2f );
}
void UpdateGlow( Player player )
{
if ( _glow is null ) return;
if ( !player.SolarInSunlight || player.IsDead )
{
_glow.Tint = Color.Transparent;
return;
}
float pulse = 0.5f + 0.5f * MathF.Sin( _glowPhase * GLOW_PULSE_SPEED );
float alpha = 0.28f + 0.14f * pulse;
_glow.Tint = SUNLIGHT_OUTLINE_COLOR.WithAlpha( alpha );
}
void CreateEnergyBar( Player player )
{
_energyBarBack = CreateEnergyBarPart( player, "SolarEnergyBarBack",
new Vector2( ENERGY_BAR_WIDTH, 3f ), out _energyBarBackRoot );
_energyBarFill = CreateEnergyBarPart( player, "SolarEnergyBarFill",
new Vector2( ENERGY_BAR_INNER_WIDTH, 1f ), out _energyBarFillRoot );
}
void UpdateEnergyBar( Player player )
{
if ( _energyBarBack is null || _energyBarFill is null ) return;
bool visible = !player.IsDead;
_energyBarBack.Enabled = visible;
_energyBarFill.Enabled = visible;
if ( !visible ) return;
float gravitySign = player.GravityReversed ? -1f : 1f;
float headEdge = player.GravityReversed ? player.Bottom : player.Top;
float y = headEdge + gravitySign * ENERGY_BAR_HEIGHT_ABOVE_PLAYER;
Vector2 backCenter = SpriteLayer.PixelAlignedCenter( new Vector2( player.X, y ), _energyBarBack.Size );
_energyBarBackRoot.WorldPosition = new Vector3( backCenter.x, backCenter.y,
Globals.DepthToZ( Globals.DEPTH_PARTICLE_1 ) );
bool empty = player.Energy <= 0.001f;
bool full = player.Energy >= FULL_ENERGY_THRESHOLD;
float pulseSpeed = full
? ENERGY_BAR_FULL_PULSE_SPEED
: empty ? ENERGY_BAR_EMPTY_PULSE_SPEED : GLOW_PULSE_SPEED;
float pulse = 0.5f + 0.5f * MathF.Sin( _glowPhase * pulseSpeed );
if ( empty )
{
_energyBarBack.Color = ENERGY_BAR_BACK_COLOR;
_energyBarFill.Size = new Vector2( ENERGY_BAR_INNER_WIDTH, 1f );
_energyBarFillRoot.WorldPosition = new Vector3( backCenter.x, backCenter.y,
Globals.DepthToZ( Globals.DEPTH_PARTICLE_1, childOrder: 1 ) );
_energyBarFill.Color = new Color( pulse, 0f, 0f );
return;
}
int fillWidth = Math.Clamp( (int)MathF.Ceiling( player.Energy * ENERGY_BAR_INNER_WIDTH ), 1, (int)ENERGY_BAR_INNER_WIDTH );
float fillX = backCenter.x - ENERGY_BAR_INNER_WIDTH * 0.5f + fillWidth * 0.5f;
_energyBarFill.Size = new Vector2( fillWidth, 1f );
_energyBarFillRoot.WorldPosition = new Vector3( fillX, backCenter.y,
Globals.DepthToZ( Globals.DEPTH_PARTICLE_1, childOrder: 1 ) );
if ( full )
{
_energyBarBack.Color = new Color( 0.04f, 0.22f + 0.12f * pulse, 0.56f + 0.2f * pulse );
_energyBarFill.Color = new Color( 0.35f + 0.65f * pulse, 0.62f + 0.38f * pulse, 1f );
}
else
{
_energyBarBack.Color = ENERGY_BAR_BACK_COLOR;
_energyBarFill.Color = player.Energy < AIR_JUMP_ENERGY_COST
? player.SolarInSunlight ? ENERGY_BAR_LOW_FILL_COLOR : ENERGY_BAR_LOW_SHADE_FILL_COLOR
: player.SolarInSunlight ? ENERGY_BAR_FILL_COLOR : ENERGY_BAR_SHADE_FILL_COLOR;
}
}
void HideEnergyBar()
{
if ( _energyBarBack is not null ) _energyBarBack.Enabled = false;
if ( _energyBarFill is not null ) _energyBarFill.Enabled = false;
}
static SpriteRenderer CreateEnergyBarPart( Player player, string name, Vector2 size, out GameObject root )
{
root = player.Stage.CreateChild( name );
return SpriteLayer.Add( root, "sprites/pixel.sprite", size, "idle" );
}
// Scratch for UpdateSunlight (rebuilt every tick; reused so the rebuild doesn't allocate).
readonly List<float> _edges = new();
void UpdateSunlight( Player player )
{
_beams.Clear();
List<float> edges = _edges;
edges.Clear();
edges.Add( Arena.WALL_SIZE );
edges.Add( Arena.WIDTH - Arena.WALL_SIZE );
foreach ( var block in player.Stage.GetBlocks() )
AddEdges( edges, block.GetRect() );
foreach ( var obstacle in player.Stage.GetSolidObstacles() )
AddEdges( edges, obstacle.GetRect() );
edges.Sort();
for ( int i = edges.Count - 1; i > 0; i-- )
if ( MathF.Abs( edges[i] - edges[i - 1] ) <= EDGE_EPSILON )
edges.RemoveAt( i );
for ( int i = 0; i + 1 < edges.Count; i++ )
{
float left = edges[i];
float right = edges[i + 1];
if ( right - left <= EDGE_EPSILON ) continue;
float sampleX = (left + right) * 0.5f;
float sourceY = Arena.HEIGHT - Arena.WALL_SIZE;
MoveSourceBelowFlushObstacles( player, sampleX, ref sourceY );
float bottom = FindFirstBlockerTop( player, sampleX, sourceY );
if ( sourceY - bottom <= EDGE_EPSILON ) continue;
var beam = new RectF( left, bottom, right, sourceY );
if ( _beams.Count > 0 )
{
var previous = _beams[^1];
if ( MathF.Abs( previous.Right - beam.Left ) <= EDGE_EPSILON
&& MathF.Abs( previous.Bottom - beam.Bottom ) <= EDGE_EPSILON
&& MathF.Abs( previous.Top - beam.Top ) <= EDGE_EPSILON )
{
previous.Right = beam.Right;
_beams[^1] = previous;
continue;
}
}
_beams.Add( beam );
}
Render( player.Stage );
}
static void AddEdges( List<float> edges, RectF rect )
{
float left = Math.Clamp( rect.Left, Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE );
float right = Math.Clamp( rect.Right, Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE );
if ( right - left > EDGE_EPSILON )
{
edges.Add( left );
edges.Add( right );
}
}
static void MoveSourceBelowFlushObstacles( Player player, float x, ref float sourceY )
{
bool moved;
do
{
moved = false;
foreach ( var obstacle in player.Stage.GetSolidObstacles() )
{
var rect = obstacle.GetRect();
if ( x <= rect.Left || x >= rect.Right
|| rect.Bottom >= sourceY
|| rect.Top < sourceY - EDGE_EPSILON ) continue;
sourceY = rect.Bottom;
moved = true;
break;
}
}
while ( moved );
}
static float FindFirstBlockerTop( Player player, float x, float sourceY )
{
float top = Arena.WALL_SIZE;
foreach ( var block in player.Stage.GetBlocks() )
{
if ( block.PhasingIn ) continue;
ConsiderBlocker( block.GetRect(), x, sourceY, ref top );
}
foreach ( var obstacle in player.Stage.GetSolidObstacles() )
ConsiderBlocker( obstacle.GetRect(), x, sourceY, ref top );
return top;
}
static void ConsiderBlocker( RectF rect, float x, float sourceY, ref float top )
{
if ( x <= rect.Left || x >= rect.Right || rect.Bottom >= sourceY || rect.Top <= top ) return;
top = rect.Top >= sourceY - EDGE_EPSILON ? sourceY : rect.Top;
}
bool IsLit( RectF playerRect )
{
foreach ( var beam in _beams )
if ( beam.Intersects( playerRect ) )
return true;
return false;
}
void Render( GameStage stage )
{
while ( _overlay.Count < _beams.Count )
_overlay.Add( stage.CreateOverlaySprite() );
for ( int i = 0; i < _beams.Count; i++ )
{
var beam = _beams[i];
var sprite = _overlay[i];
float left = MathF.Round( beam.Left );
float right = MathF.Round( beam.Right );
float bottom = MathF.Round( beam.Bottom );
float top = MathF.Round( beam.Top );
sprite.Enabled = true;
sprite.Size = new Vector2( right - left, top - bottom );
sprite.Color = new Color( 1f, 0.86f, 0.28f, 0.22f );
// childOrder -1 tucks the beams just below every block lane tint (those sit at
// childOrder = StageIndex, which is never negative) — sharing the block scheme's
// childOrder 0 exactly would z-fight the first block's overlay. A 0.01 step is enough
// here: overlay quads are translucent + IsSorted (CPU Z-sort), so only an exact tie
// fights; the 0.1 LAYER_Z_STEP rule is for the depth-buffered opaque layers.
sprite.GameObject.WorldPosition = new Vector3(
(left + right) * 0.5f,
(bottom + top) * 0.5f,
Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, childOrder: -1 ) );
}
for ( int i = _beams.Count; i < _overlay.Count; i++ )
_overlay[i].Enabled = false;
}
void EmitEnergyParticle( Player player, bool charging )
{
var offset = new Vector2(
Rng.CosmeticFloat( -Player.COLLISION_SIZE.x * 0.5f, Player.COLLISION_SIZE.x * 0.5f ),
Rng.CosmeticFloat( -Player.COLLISION_SIZE.y * 0.35f, Player.COLLISION_SIZE.y * 0.35f ) );
var velocity = charging
? new Vector2( Rng.CosmeticFloat( -3f, 3f ), Rng.CosmeticFloat( 8f, 14f ) )
: new Vector2( Rng.CosmeticFloat( -3f, 3f ), Rng.CosmeticFloat( -14f, -8f ) );
player.Stage.AddFieldParticle(
player.Position + offset,
velocity,
0.96f,
0f,
charging ? ParticleKind.SolarCharge : ParticleKind.SolarDrain,
0.35f,
1 );
}
void TickFullChargeTrail( Player player, float dt )
{
if ( player.Energy < FULL_ENERGY_THRESHOLD )
{
_fullChargeParticleTimer = Math.Max( _fullChargeParticleTimer, 0f );
return;
}
_fullChargeParticleTimer -= dt;
if ( _fullChargeParticleTimer > 0f ) return;
_fullChargeParticleTimer += FULL_CHARGE_PARTICLE_INTERVAL;
Vector2 direction = player.Velocity.Length > 1f
? Utils.Normalized( player.Velocity )
: new Vector2( 0f, 1f );
Vector2 offset = new(
Rng.CosmeticFloat( -Player.COLLISION_SIZE.x * 0.35f, Player.COLLISION_SIZE.x * 0.35f ),
Rng.CosmeticFloat( -Player.COLLISION_SIZE.y * 0.35f, Player.COLLISION_SIZE.y * 0.1f ) );
Vector2 velocity = -direction * Rng.CosmeticFloat( 3f, 7f )
+ new Vector2( Rng.CosmeticFloat( -2f, 2f ), Rng.CosmeticFloat( -1f, 3f ) );
player.Stage.AddFieldParticle(
player.Position - direction * 2f + offset,
velocity,
0.92f,
0f,
ParticleKind.SolarCharge,
0.2f,
1 );
}
}