Entities/Blocks/BlockSticky.cs
namespace BlockParty;
/// <summary>
/// Sticky block — a pink, gooey block that traps the player against whichever face they touch.
///
/// The stick MECHANIC itself lives in the player (see <c>Player.HandleStickyBlocks</c>), keyed off
/// this subtype exactly like the spike/laser interactions key off <see cref="BlockSpikey"/> /
/// <see cref="BlockSquid"/> — the block only advertises "I am sticky right now" via
/// <see cref="IsStickyActive"/>. Keeping the behaviour player-side lets it hook straight into the
/// player's collision flags, glue and jump/gravity gates without threading forces through a channel
/// (unlike Wind/Magnet, which are pure forces and so live in the block).
///
/// While active the player, on first contact with a face, is glued flush to it and can't jump off (a
/// refused jump plays a gooey "nope" sfx). Every sticky face lets them travel along it at reduced
/// speed and escape over an edge. A hard SLAM by the block frees the player for a short
/// window (flinging them off with the block's momentum). The block is sticky only at phase 1+ (like
/// Wind/Magnet gate on phase), so at phase 0 it's an ordinary block whose sides you press to advance it.
///
/// Visual tells (this class): pink goo dribbles off the block once sticky (see <see cref="EmitDrip"/>),
/// and a translucent lumpy-pink goo overlay sits over each side's button (see <see cref="CreateVisuals"/>),
/// its opacity breathing with the phase (see <see cref="UpdateGoo"/>).
/// </summary>
public sealed class BlockSticky : Block
{
/// <summary>Whether the block is currently trapping-capable: alive and advanced past phase 0.</summary>
public bool IsStickyActive => !IsDead && Phase >= 1;
public override int StickyPhase( Direction dir )
=> Math.Max( IsStickyActive ? Phase : 0, base.StickyPhase( dir ) );
const float TRANSFER_STICKY_TIME = 8f;
const float TRANSFER_APPLY_DELAY = 0.6f;
const float TRANSFER_EMIT_TIME = 0.18f;
const int TRANSFER_PARTICLE_COUNT = 10;
Block _stickyTarget;
Direction _stickyTargetSide;
float _stickyApplyTimer;
int _stickyEmitRemaining;
float _stickyEmitTimer;
Vector2 _stickyEmitStart;
Line _stickyTargetLine;
// Translucent lumpy-pink goo overlay per side (baked sprites/blocks/sticky_goo.sprite). Appears with
// the sticky effect (phase 1+). Its opacity BREATHES — oscillates between a dim and a bright alpha —
// as a tell: a noticeable pulse at phase 1, and a much stronger/faster pulse at phase 2 (both faster
// pulsing AND a wider opacity swing). Cosmetic (a time accumulator on a visual-only alpha; not sim state).
readonly Dictionary<Direction, SpriteRenderer> _goo = new();
const float GOO_FADE_SPEED = 30.0f; // per-second lerp toward the target (high, so the fast pulse tracks)
// Per-phase pulse: (dim alpha, bright alpha, rad/s). Phase 2 is far more dramatic.
const float GOO_DIM_1 = 0.28f, GOO_BRIGHT_1 = 0.68f, GOO_PULSE_SPEED_1 = 8.0f;
const float GOO_DIM_2 = 0.10f, GOO_BRIGHT_2 = 0.85f, GOO_PULSE_SPEED_2 = 16.0f;
float _gooPulse;
// Gooey-drip tell: once sticky (phase 1+) the block dribbles pink goo so it reads as sticky at a
// glance, and the effect escalates at phase 2 (a faster ooze). The drips are purely cosmetic —
// spawned through GameStage.AddWindParticle (non-colliding) using the COSMETIC Rng — so emitting
// them never advances the authoritative sim Rng (same discipline as the Wind/Magnet streams).
//
// Emission edge is chosen so the goo is ALWAYS visible: a drip renders behind the block, so while the
// block MOVES it emits off its TRAILING edge (opposite MoveDirection) — the block slides away from
// those drips, leaving a goo trail in open space no matter which way it's headed (the old
// bottom-only version vanished whenever the block outran its own falling drips). While slow/stopped
// it dribbles off a random edge and the drips fall under gravity.
const float DRIP_RATE_PHASE_1 = 6.0f; // drips/sec
const float DRIP_RATE_PHASE_2 = 15.0f;
const float DRIP_MOVING_SPEED = 20.0f; // above this the block trails goo off its back edge
const float DRIP_GRAVITY = Globals.GRAVITY_STR_BLOOD; // negative -> the goo falls downward
float _dripAccum;
public override void CreateVisuals()
{
base.CreateVisuals();
// One goo overlay per side, above the buttons (childOrder 5, over face/mouth/eyes/eyebrows/sides).
// Translucent (Opaque off + AlphaCutoff 0, like the wind/magnet lane overlays) so the button reads
// through it and the runtime alpha tint gives true smooth transparency. Starts fully transparent;
// UpdateGoo fades it in once the block is sticky.
foreach ( var dir in Globals.GetAllDirections() )
{
var sr = SpriteLayer.Add( GameObject, "sprites/blocks/sticky_goo.sprite",
new Vector2( 40, 40 ), $"goo_{Globals.GetStringForDirection( dir )}", childOrder: 5 );
sr.Opaque = false;
sr.AlphaCutoff = 0f;
sr.Color = new Color( 1f, 1f, 1f, 0f );
_goo[dir] = sr;
}
}
public override void Impact( Block otherBlock )
{
ImpactEffects( otherBlock );
if ( IsDead ) return;
if ( Phase >= 2 && otherBlock is not null )
{
Direction side = Globals.GetOppositeDirection( MoveDirection );
if ( otherBlock.StickyPhase( side ) == 0 )
BeginStickySend( otherBlock, side );
}
CloseEyesAndPickNewDirection();
}
void BeginStickySend( Block target, Direction side )
{
FlushPendingSticky();
_stickyTarget = target;
_stickyTargetSide = side;
_stickyApplyTimer = TRANSFER_APPLY_DELAY;
_stickyEmitRemaining = TRANSFER_PARTICLE_COUNT;
_stickyEmitTimer = 0f;
_stickyEmitStart = Position + Globals.GetVectorForDirection( MoveDirection ) * 12f;
_stickyTargetLine = GetSideLineForBlock( side, target );
}
void EmitStickySendParticle()
{
Vector2 along = Utils.Normalized( _stickyTargetLine.B - _stickyTargetLine.A );
Vector2 pointOnLine = _stickyTargetLine.A + along * Rng.CosmeticFloat( 0f, _stickyTargetLine.Length );
Vector2 targetOffset = pointOnLine - _stickyTarget.Position;
ParticleKind kind = Rng.CosmeticValue() < 0.25f ? ParticleKind.StickyGoo1 : ParticleKind.StickyGoo0;
Stage.AddTargetedParticle( _stickyEmitStart, Vector2.Zero, 0.9f, 0f, kind,
Rng.CosmeticFloat( 1.2f, 1.85f ), Rng.CosmeticInt( 3, 5 ), targetOffset,
Rng.CosmeticFloat( 1400f, 1700f ), _stickyTarget );
}
/// <summary>The transfer target got mimic-swapped mid-send: carry the pending goo to the disguise
/// (same spot/side), so the graft lands on the block actually standing there.</summary>
public override void OnBlockReplaced( Block old, Block replacement )
{
if ( !ReferenceEquals( _stickyTarget, old ) ) return;
_stickyTarget = replacement;
_stickyTargetLine = GetSideLineForBlock( _stickyTargetSide, replacement );
}
void FlushPendingSticky()
{
if ( _stickyTarget is null ) return;
if ( !_stickyTarget.Replaced )
{
_stickyTarget.AddStickyToSide( _stickyTargetSide, TRANSFER_STICKY_TIME );
Audio.PlaySfx( SfxType.StickyStick, _stickyTarget.Position );
}
_stickyTarget = null;
_stickyTargetSide = Direction.None;
}
public override void Tick( float dt )
{
base.Tick( dt );
if ( _stickyEmitRemaining > 0 && _stickyTarget is not null && !_stickyTarget.Replaced )
{
_stickyEmitTimer -= dt;
while ( _stickyEmitRemaining > 0 && _stickyEmitTimer <= 0f )
{
EmitStickySendParticle();
_stickyEmitRemaining--;
_stickyEmitTimer += TRANSFER_EMIT_TIME / TRANSFER_PARTICLE_COUNT;
}
}
if ( _stickyTarget is not null )
{
_stickyApplyTimer -= dt;
if ( _stickyApplyTimer <= 0f )
FlushPendingSticky();
}
UpdateGoo( dt );
if ( IsDead || Phase < 1 ) return;
_dripAccum += (Phase >= 2 ? DRIP_RATE_PHASE_2 : DRIP_RATE_PHASE_1) * dt;
while ( _dripAccum >= 1f )
{
_dripAccum -= 1f;
EmitDrip();
}
}
/// <summary>Ease each side's goo overlay toward the breathing opacity (0 when not sticky). The pulse
/// swings wider and faster at phase 2 than phase 1.</summary>
void UpdateGoo( float dt )
{
bool sticky = IsStickyActive;
float step = Math.Clamp( dt * GOO_FADE_SPEED, 0f, 1f );
bool p2 = Phase >= 2;
_gooPulse += dt * (p2 ? GOO_PULSE_SPEED_2 : GOO_PULSE_SPEED_1);
float osc = 0.5f + 0.5f * MathF.Sin( _gooPulse ); // 0..1
float dim = p2 ? GOO_DIM_2 : GOO_DIM_1;
float bright = p2 ? GOO_BRIGHT_2 : GOO_BRIGHT_1;
float target = sticky ? dim + (bright - dim) * osc : 0f;
foreach ( var dir in Globals.GetAllDirections() )
{
var sr = _goo[dir];
if ( sr is null ) continue;
var c = sr.Color;
c.a += (target - c.a) * step;
sr.Color = c;
}
}
/// <summary>Spawn one gooey drip. Off the trailing edge while moving (so the block leaves it behind in
/// open space), else off a random edge. A little outward nudge, then it falls (DRIP_GRAVITY) and
/// shrinks away.</summary>
void EmitDrip()
{
bool moving = !IsStopped && _moveSpeed > DRIP_MOVING_SPEED;
Direction edge = moving
? Globals.GetOppositeDirection( MoveDirection ) // trailing edge -> always left visible behind us
: Globals.GetAllDirections()[Rng.CosmeticInt( 0, 4 )];
Vector2 outward = Globals.GetVectorForDirection( edge );
Vector2 along = new Vector2( -outward.y, outward.x ); // across the face
Vector2 pos = Position + outward * (Width / 2f) + along * Rng.CosmeticFloat( -Width / 2f, Width / 2f );
// A small outward spray; when moving the block itself supplies the separation, so keep it gentle.
Vector2 vel = outward * Rng.CosmeticFloat( 6f, 20f ) + along * Rng.CosmeticFloat( -8f, 8f );
ParticleKind kind = Rng.CosmeticInt( 0, 3 ) == 0 ? ParticleKind.StickyGoo1 : ParticleKind.StickyGoo0;
Stage.AddWindParticle( pos, vel, decel: 0.94f, gravity: DRIP_GRAVITY, kind, Rng.CosmeticFloat( 0.35f, 0.7f ), Rng.CosmeticInt( 2, 5 ) );
}
static Line GetSideLineForBlock( Direction dir, Block block ) => dir switch
{
Direction.Left => new Line( new Vector2( block.Left, block.Bottom ), new Vector2( block.Left, block.Top ) ),
Direction.Right => new Line( new Vector2( block.Right, block.Bottom ), new Vector2( block.Right, block.Top ) ),
Direction.Down => new Line( new Vector2( block.Left, block.Bottom ), new Vector2( block.Right, block.Bottom ) ),
_ => new Line( new Vector2( block.Left, block.Top ), new Vector2( block.Right, block.Top ) ),
};
}