Entities/BackgroundBlock.cs

A decorative background entity that drifts around the playfield. It stores size, color, movement bias and sibling list, accelerates up to a layer-scaled max speed, detects collisions with other background blocks and arena bounds, briefly stops on impact, snaps to pixel grid, and then picks a new biased/random direction.

File Access
using System;
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Cosmetic, faceless block that drifts around the backdrop. Port of the original
/// <c>BackgroundBlock</c>: a solid coloured square that accelerates in a random direction,
/// collides with its same-layer siblings and the arena bounds, stops briefly on impact, then
/// picks a new direction. Purely decorative — it never interacts with gameplay entities and
/// only ever tests collision against the blocks in its own depth layer.
/// </summary>
public sealed class BackgroundBlock : Entity2D
{
	private const float MIN_SPEED = 0.0f;
	private const float BASE_MAX_SPEED = 160.0f;
	private const float REQUIRED_COLLISION_SPEED = 40.0f;
	private const float REQUIRED_COLLISION_TIME = 0.33f;   // collide-time before switching direction

	public Direction MoveDirection { get; private set; }

	private float _moveSpeed;
	private float _maxSpeed;
	private float _acceleration;
	private float _currentCollidingTime;

	private bool _isStopped;
	private float _switchTimer;     // while stopped, counts down to picking a new direction

	private Color _color;
	private List<BackgroundBlock> _layerSiblings;   // the only blocks we collide against
	private BackgroundDriftBias _driftBias;

	/// <summary>Configure the block. <paramref name="layerSiblings"/> is the list of all blocks
	/// in this block's depth layer (used for collision; the original keyed off the layer index).</summary>
	public void Setup( Vector2 size, Color color, int layer, List<BackgroundBlock> layerSiblings, float speedMultiplier, BackgroundDriftBias driftBias )
	{
		Size = size;
		_color = color;
		_layerSiblings = layerSiblings;

		_driftBias = driftBias;
		MoveDirection = GetBiasedDirection();
		_moveSpeed = MIN_SPEED;
		// Deeper layers accelerate more slowly (a subtle parallax tell).
		float speed = Math.Clamp( speedMultiplier, 0f, 3f );
		_acceleration = (25f - ((layer + 1) * 5f)) * speed;
		_maxSpeed = BASE_MAX_SPEED * speed;
	}

	public void CreateVisuals( int childOrder = 0 )
	{
		var sr = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Size, "idle", childOrder );
		sr.Color = _color;
		// The level's background-block opacity rides in the tint's alpha, so these must alpha-blend:
		// opt out of the opaque default and zero the cutoff (which DISCARDS pixels under 0.5), the same
		// pairing every other tint-translucent sprite uses (lane overlays / Portal rings / field motes).
		sr.Opaque = false;
		sr.AlphaCutoff = 0f;
	}

	public override void Tick( float dt )
	{
		if ( _isStopped )
		{
			_switchTimer -= dt;
			if ( _switchTimer <= 0f )
			{
				MoveDirection = GetNewDirection();
				_isStopped = false;
				_moveSpeed = MIN_SPEED;
			}
			return;
		}

		Velocity = Globals.GetVectorForDirection( MoveDirection ) * _moveSpeed;
		Pos += Velocity * dt;

		bool collidingForward = IsColliding( X, Y, MoveDirection, out var other );
		if ( collidingForward )
		{
			if ( other != null )
				Unpenetrate( X, Y, other );

			_currentCollidingTime += dt;
			if ( _moveSpeed > REQUIRED_COLLISION_SPEED || _currentCollidingTime >= REQUIRED_COLLISION_TIME )
				Impact();
		}
		else
		{
			_moveSpeed = Math.Clamp( _moveSpeed + _acceleration * dt, MIN_SPEED, _maxSpeed );
			_currentCollidingTime = 0f;
		}

		ClampToBounds( X, Y );
	}

	private void Impact()
	{
		_isStopped = true;
		// Snap to the integer pixel grid on rest so accumulated sub-pixel travel drift never lands
		// on the cross axis and causes phantom corner collisions when the block next turns 90°.
		// Rendering already rounds the position, so this is visually a no-op. (See Block.ImpactEffects.)
		Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
		_currentCollidingTime = 0f;
		_switchTimer = Rng.BackgroundFloat( 0.33f, 0.70f );
	}

	// ----------------------------------------------------------------------------------------
	private bool IsColliding( float x, float y, Direction dir, out BackgroundBlock other )
	{
		if ( IsCollidingWithSibling( x, y, out other ) )
			return true;

		other = null;
		return !IsInBounds( x, y, dir );
	}

	private bool IsColliding( Direction dir )
	{
		Vector2 offset = Globals.GetVectorForDirection( dir );
		return IsColliding( X + offset.x, Y + offset.y, dir, out _ );
	}

	private bool IsCollidingWithSibling( float x, float y, out BackgroundBlock other )
	{
		other = null;
		foreach ( var b in _layerSiblings )
		{
			if ( b != this && DoesPenetrate( x, y, b ) )
			{
				other = b;
				return true;
			}
		}
		return false;
	}

	private Direction GetNewDirection()
	{
		int tries = 0;
		Direction next = MoveDirection;
		while ( tries < 10 )
		{
			tries++;
			next = GetBiasedDirection();

			if ( next == Globals.GetOppositeDirection( MoveDirection ) && tries < 5 )
				continue;

			if ( next != MoveDirection && !IsColliding( next ) )
				return next;
		}
		return next;
	}

	private Direction GetBiasedDirection()
	{
		if ( _driftBias == BackgroundDriftBias.None )
			return RandomBackgroundDirection();
		if ( Rng.BackgroundValue() >= 0.8f )
			return RandomBackgroundDirection();
		if ( _driftBias == BackgroundDriftBias.Horizontal )
			return Rng.BackgroundInt( 0, 2 ) == 0 ? Direction.Left : Direction.Right;
		if ( _driftBias == BackgroundDriftBias.Vertical )
			return Rng.BackgroundInt( 0, 2 ) == 0 ? Direction.Down : Direction.Up;
		return _driftBias switch
		{
			BackgroundDriftBias.Left => Direction.Left,
			BackgroundDriftBias.Right => Direction.Right,
			BackgroundDriftBias.Down => Direction.Down,
			BackgroundDriftBias.Up => Direction.Up,
			_ => RandomBackgroundDirection(),
		};
	}

	private static Direction RandomBackgroundDirection()
		=> Globals.GetAllDirections()[Rng.BackgroundInt( 0, Globals.GetAllDirections().Length )];
}