Entities/Abilities/HardenAbility.cs
namespace BlockParty;
/// <summary>Charges Twin2's temporary immovable obstacle form from held crouch (toward-feet) input.</summary>
public sealed class HardenAbility : PlayerAbility
{
private const float HOLD_TIME = 0.55f;
private const float HARDEN_TIME = 4f;
private const float WARNING_TIME = 0.5f;
private const float COOLDOWN_TIME = 9f;
private const float CHARGE_PARTICLE_INTERVAL_MAX = 0.16f;
private const float CHARGE_PARTICLE_INTERVAL_MIN = 0.035f;
// Builds over the hold so the pad tells you how close the stone is; tops out under the slam pulse.
private const float CHARGE_HAPTIC_MIN = 0.05f;
private const float CHARGE_HAPTIC_MAX = 0.5f;
private static readonly Color STONE_TINT = new( 0.18f, 0.21f, 0.23f );
private static readonly Color STONE_ESSENCE_DARK = new( 0.42f, 0.45f, 0.47f, 0.7f );
private static readonly Color STONE_ESSENCE_LIGHT = new( 0.7f, 0.73f, 0.74f, 0.7f );
private static readonly Color COOLDOWN_EYE_TINT = new( 0.86f, 0.18f, 0.18f, 0.82f );
private float _holdTimer;
private float _hardenTimer;
private float _cooldownTimer;
private float _chargeParticleTimer;
private TwinAbilityEyeTint _eyeTint;
public override void OnSpawn( Player player )
{
var tintObject = new GameObject( true, "harden cooldown eye tint" );
tintObject.SetParent( player.GameObject );
tintObject.LocalPosition = Vector3.Zero;
_eyeTint = tintObject.Components.Create<TwinAbilityEyeTint>();
_eyeTint.Setup( player, adjustTwinOneWalk: false );
}
public override void PreTick( Player player, float dt )
{
if ( player.IsHardened )
{
_hardenTimer -= dt;
if ( _hardenTimer <= 0f )
{
EndHarden( player, playEffects: true );
return;
}
if ( _hardenTimer <= WARNING_TIME )
{
float sign = Rng.CosmeticInt( 0, 2 ) == 0 ? -1f : 1f;
player.SetShakeOffset( new Vector2( sign * Rng.CosmeticFloat( 0.65f, 1f ), 0f ) );
}
else
player.ClearShakeOffset();
return;
}
if ( _cooldownTimer > 0f )
{
if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
ResetChargeVisuals( player );
_cooldownTimer -= dt;
if ( _cooldownTimer <= 0f )
{
_cooldownTimer = 0f;
if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
PlayCooldownReadyEffects( player );
}
return;
}
if ( !player.CrouchHeld )
{
ResetChargeVisuals( player );
return;
}
_holdTimer += dt;
float chargeProgress = Math.Clamp( _holdTimer / HOLD_TIME, 0f, 1f );
player.SetAbilityTint( STONE_TINT, chargeProgress );
TickChargeParticles( player, dt, chargeProgress );
if ( player.DrivesHaptics )
Haptics.Sustain( HapticChannel.HardenCharge,
Utils.Map( chargeProgress, 0f, 1f, CHARGE_HAPTIC_MIN, CHARGE_HAPTIC_MAX, true, EasingType.Linear ),
0f, Haptics.TONE_HEAVY );
if ( _holdTimer < HOLD_TIME ) return;
// Hand the build-up straight over to the slam rather than letting it decay across it.
Haptics.ReleaseSustain( HapticChannel.HardenCharge );
ResetChargeVisuals( player );
_hardenTimer = HARDEN_TIME;
player.SetHardened( true );
player.Shake( new Vector2( 0.9f, 0.45f ), 4 );
Audio.PlaySfx( SfxType.SpikesAdd, player.Position, 0.6f, 0.8f );
if ( player.DrivesHaptics ) Haptics.Pulse( 0.75f, 0.14f, 0f, Haptics.TONE_HEAVY );
SpawnTransitionParticles( player, 6, 11f, 22f, 2, 4 );
player.Stage.PlayerHardened( player );
}
public override void OnRemoved( Player player )
{
EndHarden( player, playEffects: false );
_eyeTint?.GameObject?.Destroy();
_eyeTint = null;
}
public override void OnDeath( Player player )
{
EndHarden( player, playEffects: false );
if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
}
private void EndHarden( Player player, bool playEffects )
{
ResetChargeVisuals( player );
_hardenTimer = 0f;
player.ClearShakeOffset();
player.SetHardened( false );
if ( !playEffects ) return;
_cooldownTimer = COOLDOWN_TIME;
if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
Audio.PlaySfx( SfxType.SpikesRetract, player.Position, 0.6f, 1.05f );
SpawnTransitionParticles( player, 8, 16f, 30f, 3, 5 );
}
private void TickChargeParticles( Player player, float dt, float progress )
{
_chargeParticleTimer -= dt;
if ( _chargeParticleTimer > 0f ) return;
_chargeParticleTimer = Utils.Map( progress, 0f, 1f,
CHARGE_PARTICLE_INTERVAL_MAX, CHARGE_PARTICLE_INTERVAL_MIN, true );
float angle = Rng.CosmeticFloat( 0f, MathF.Tau );
Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
float radius = Rng.CosmeticFloat( 9f, 14f );
Vector2 position = player.Position + outward * radius;
float speed = Utils.Map( progress, 0f, 1f, 24f, 42f, true );
Color color = Color.Lerp( STONE_ESSENCE_DARK, STONE_ESSENCE_LIGHT, Rng.CosmeticValue() );
player.Stage.AddColoredParticle( position, -outward * speed, 0.94f, color,
Rng.CosmeticFloat( 0.2f, 0.3f ), progress > 0.7f ? 2 : 1,
Globals.DEPTH_PARTICLE_1, translucent: true );
}
private void ResetChargeVisuals( Player player )
{
_holdTimer = 0f;
_chargeParticleTimer = 0f;
player.SetAbilityTint( null );
}
private static void PlayCooldownReadyEffects( Player player )
{
Audio.PlaySfx( SfxType.Glitter0, player.Position, 0.28f, 1.4f );
for ( int i = 0; i < 8; i++ )
{
float angle = i * MathF.PI * 0.25f + MathF.PI * 0.125f;
Vector2 direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
ParticleKind kind = i % 2 == 0 ? ParticleKind.Glitter0 : ParticleKind.Glitter1;
player.Stage.AddParticle( player.Position + direction * 5f, direction * 18f, 0.9f, 0f,
kind, 0.24f, 3 );
}
}
private static void SpawnTransitionParticles( Player player, int count, float horizontalSpeed,
float verticalSpeed, int minSize, int maxSize )
{
for ( int i = 0; i < count; i++ )
{
Vector2 velocity = new Vector2(
Rng.CosmeticFloat( -horizontalSpeed, horizontalSpeed ),
Rng.CosmeticFloat( verticalSpeed * 0.35f, verticalSpeed ) );
player.Stage.AddParticle( player.Position, velocity, 0.92f, Globals.GRAVITY_STR_DUST,
ParticleKind.Dust, Rng.CosmeticFloat( 0.22f, 0.38f ), Rng.CosmeticInt( minSize, maxSize + 1 ) );
}
}
}