A game block that creates a square reverse-gravity field centered on itself. It computes field size by phase, toggles visibility with an eye loop, marks players/impostors inside the square so they apply reverse gravity, and renders a translucent overlay, pulsing outlines, and cosmetic upward particles.
namespace BlockParty;
/// <summary>
/// Reverse-gravity block — projects a square field centred on itself inside which the player's gravity is
/// flipped (they "fall" UP toward the ceiling instead of down). The field switches on once the block
/// reaches phase 1 (3×3 block-sizes) and grows larger at phase 2. The block moves/bounces/phases like any
/// other; the field simply follows its centre.
///
/// The gravity flip itself lives on the player (Player.ApplyReverseGravity / ApplyGravity); this block only
/// reports "the player is inside my field this tick". That flag is reset at frame start by Player.ResetStasis
/// (blocks tick before the player), so there is no lingering state. Deterministic — a pure function of the
/// block + player positions and the fixed step, with no Rng/Time.Delta — so replays reproduce it exactly.
/// The visible field is a translucent overlay quad with a pulsing outline drawn behind the blocks; it
/// never collides.
/// </summary>
public sealed class BlockReverse : Block
{
// Field size per phase (square edge length, centred on the block). A block is 40px, so 3× gives a
// 3×3-block area at phase 1, growing to a 5×5-block area at phase 2. Tuned freely — see the user's
// "we'll tweak the size later" note.
const float BLOCK_SIZE = 40f;
const float FIELD_SIZE_P1 = 3f * BLOCK_SIZE;
const float FIELD_SIZE_P2 = 5f * BLOCK_SIZE;
// Overlay tint: a translucent violet so the anti-gravity zone reads without hiding what's inside it.
const float R = 0.55f, G = 0.35f, B = 0.95f;
const float ALPHA_P1 = 0.3f, ALPHA_P2 = 0.3f;
// Phase-2 tint: shifted a touch hotter/pinker than the phase-1 violet so the bigger, stronger field
// reads as a distinct escalation.
const float R2 = 0.80f, G2 = 0.32f, B2 = 0.92f;
// Subtle "breathing" brightness pulse so the field feels alive (render-only, cosmetic).
const float PULSE_SPEED = 2.2f; // radians/sec
const float PULSE_DEPTH = 0.30f; // fraction of the base alpha the pulse swings
const float OUTLINE_PULSE_SPEED = 9f;
const int OUTLINE_SIDES = 4;
static readonly (float Thickness, float MinAlpha, float MaxAlpha)[] OUTLINE_LAYERS =
{
( 8f, 0.12f, 0.38f ),
( 4f, 0.35f, 0.65f ),
( 1f, 0.65f, 0.95f ),
};
static readonly Color OUTLINE_COLOR_P1 = new( 0.66f, 0.50f, 0.98f );
static readonly Color OUTLINE_COLOR_P2 = new( 0.92f, 0.43f, 0.94f );
// Ambient, mostly-transparent motes drifting UPWARD through the field — a visual cue that gravity is
// inverted here. A touch denser at phase 2.
const float PARTICLE_RATE_P1 = 17f; // motes/sec
const float PARTICLE_RATE_P2 = 29f;
const float PARTICLE_UP_SPEED = 26f; // base upward drift speed
const float PARTICLE_LIFE_MIN = 0.6f, PARTICLE_LIFE_MAX = 1.3f;
SpriteRenderer _overlay;
readonly SpriteRenderer[] _outline = new SpriteRenderer[OUTLINE_LAYERS.Length * OUTLINE_SIDES];
float _pulseTime; // free-running clock for the brightness pulse (fixed-dt = deterministic; cosmetic anyway)
float _emitAccum; // fractional mote-emission accumulator
public override void Tick( float dt )
{
base.Tick( dt ); // move / bounce / phase / eye-cycle first so the field follows the post-move position
ApplyFieldToBodies();
_pulseTime += dt;
RenderField();
EmitFieldParticles( dt );
}
public override void DestroyStageVisuals()
{
base.DestroyStageVisuals();
GameStage.DestroyOverlaySprite( _overlay );
_overlay = null; // RenderField lazily recreates (??=), so a stale reference can't be re-enabled
for ( int i = 0; i < _outline.Length; i++ )
{
GameStage.DestroyOverlaySprite( _outline[i] );
_outline[i] = null;
}
}
/// <summary>Current field extent (square edge length), or 0 while the block hasn't reached phase 1.</summary>
float FieldSize => Phase >= 2 ? FIELD_SIZE_P2 : (Phase >= 1 ? FIELD_SIZE_P1 : 0f);
/// <summary>While boxed in at phase 1+, the authored ability timings loop the field on/off: on
/// (gravity flipped) while the eyes stay open for the Interval, off while they're shut for the
/// ClosedTime. ClosedTime 0 = permanently-on field. Non-enclosed blocks are unchanged (the
/// strength below requires enclosure, so ordinary impact blinks never drop the field).</summary>
protected override bool HasEnclosedEyeLoop => true;
/// <summary>Field strength 0..1: full while free-roaming; while the enclosed loop runs it follows
/// the eye openness, so the visuals fade out over the close and back in over the open. The gravity
/// flip itself stays on until the eyes are FULLY shut (strength 0) — the field never shows without
/// working. Deterministic (eye timers run on the fixed step, no Rng).</summary>
float FieldStrength => EnclosedEyeLoopRunning ? EyesOpenAmount : 1f;
/// <summary>Flip gravity for any body whose centre is inside the field this tick (the real player + any
/// impostors). The block ticks before the player, so the flag is set before the player consumes it.</summary>
void ApplyFieldToBodies()
{
float size = FieldSize;
if ( size <= 0f || IsDead || FieldStrength <= 0f ) return;
foreach ( var player in Stage.LivingPlayers ) ApplyFieldTo( player, size );
foreach ( var imp in Stage.Impostors ) ApplyFieldTo( imp, size );
}
void ApplyFieldTo( Player player, float size )
{
if ( player is null || player.IsDead ) return;
float half = size * 0.5f;
if ( Math.Abs( player.X - X ) <= half && Math.Abs( player.Y - Y ) <= half )
player.ApplyReverseGravity();
}
/// <summary>Draw / update the translucent field quad centred on the block, or hide it when inactive.</summary>
void RenderField()
{
float size = FieldSize;
float strength = FieldStrength;
if ( size <= 0f || IsDead || strength <= 0f )
{
if ( _overlay is not null ) _overlay.Enabled = false;
for ( int i = 0; i < _outline.Length; i++ )
if ( _outline[i] is not null ) _outline[i].Enabled = false;
return;
}
bool p2 = Phase >= 2;
// Subtle brightness "breathing" so the field feels alive (cosmetic — render only). The whole
// quad + outline fade with the enclosed loop's strength so the off-leg reads as a power-down.
float alpha = (p2 ? ALPHA_P2 : ALPHA_P1) * (1f + PULSE_DEPTH * MathF.Sin( _pulseTime * PULSE_SPEED )) * strength;
_overlay ??= Stage.CreateOverlaySprite();
_overlay.Enabled = true;
_overlay.Size = new Vector2( size, size );
_overlay.Color = p2 ? new Color( R2, G2, B2, alpha ) : new Color( R, G, B, alpha );
_overlay.GameObject.WorldPosition = new Vector3( X, Y, Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, StageIndex ) );
float outlinePulse = 0.5f + 0.5f * MathF.Sin( _pulseTime * OUTLINE_PULSE_SPEED );
Color outlineColor = p2 ? OUTLINE_COLOR_P2 : OUTLINE_COLOR_P1;
float half = size * 0.5f;
float outlineZ = Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, StageIndex );
for ( int layerIndex = 0; layerIndex < OUTLINE_LAYERS.Length; layerIndex++ )
{
var layer = OUTLINE_LAYERS[layerIndex];
float layerAlpha = (layer.MinAlpha + (layer.MaxAlpha - layer.MinAlpha) * outlinePulse) * strength;
float layerZ = outlineZ + (layerIndex + 1) * SpriteLayer.LAYER_Z_STEP;
SetOutlineLayer( layerIndex, size, half, layer.Thickness, outlineColor.WithAlpha( layerAlpha ), layerZ );
}
}
void SetOutlineLayer( int layerIndex, float fieldSize, float half, float thickness, Color color, float z )
{
int first = layerIndex * OUTLINE_SIDES;
SetOutlineSide( first, new Vector2( X, Y + half ), new Vector2( fieldSize + thickness, thickness ), color, z );
SetOutlineSide( first + 1, new Vector2( X, Y - half ), new Vector2( fieldSize + thickness, thickness ), color, z );
SetOutlineSide( first + 2, new Vector2( X - half, Y ), new Vector2( thickness, fieldSize - thickness ), color, z );
SetOutlineSide( first + 3, new Vector2( X + half, Y ), new Vector2( thickness, fieldSize - thickness ), color, z );
}
void SetOutlineSide( int index, Vector2 position, Vector2 size, Color color, float z )
{
_outline[index] ??= Stage.CreateOverlaySprite();
_outline[index].Enabled = true;
_outline[index].Size = size;
_outline[index].Color = color;
_outline[index].GameObject.WorldPosition = new Vector3( position.x, position.y, z );
}
/// <summary>Sprinkle subtle, mostly-transparent motes that drift UPWARD through the field — a visual
/// cue that gravity is inverted here. Cosmetic (cosmetic Rng, translucent, non-colliding), so it never
/// advances the authoritative sim Rng; the phase-2 motes use a slightly hotter tint like the overlay.</summary>
void EmitFieldParticles( float dt )
{
float size = FieldSize;
float strength = FieldStrength;
if ( size <= 0f || IsDead || strength <= 0f ) return;
bool p2 = Phase >= 2;
_emitAccum += (p2 ? PARTICLE_RATE_P2 : PARTICLE_RATE_P1) * strength * dt;
float half = size * 0.5f;
while ( _emitAccum >= 1f )
{
_emitAccum -= 1f;
// Anywhere in the square; a mostly-upward drift with a little horizontal wander sells the
// inverted gravity. gravity 0 + high decel so the mote just eases upward and fades.
Vector2 pos = new Vector2( X + Rng.CosmeticFloat( -half, half ), Y + Rng.CosmeticFloat( -half, half ) );
Vector2 vel = new Vector2( Rng.CosmeticFloat( -6f, 6f ), Rng.CosmeticFloat( PARTICLE_UP_SPEED * 0.6f, PARTICLE_UP_SPEED ) );
ParticleKind kind = p2
? (Rng.CosmeticValue() < 0.5f ? ParticleKind.ReverseFieldDeep0 : ParticleKind.ReverseFieldDeep1)
: (Rng.CosmeticValue() < 0.5f ? ParticleKind.ReverseField0 : ParticleKind.ReverseField1);
Stage.AddFieldParticle( pos, vel, decel: 0.98f, gravity: 0f, kind,
Rng.CosmeticFloat( PARTICLE_LIFE_MIN, PARTICLE_LIFE_MAX ), Rng.CosmeticInt( 1, 3 ) );
}
}
}