Entities/Blocks/BlockReverse.cs

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.

File AccessNetworkingNative Interop
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 ) );
		}
	}
}