UI overlay component that displays grappler ammo as a ring of pixel sprites around the player and reflows them to avoid arena walls and reserved slots for active grappling lines. It manages sprite creation, positioning on an oval, smoothly lerps positions, and computes collision-aware arc layouts.
using System.Collections.Generic;
using Sandbox;
namespace BlockParty;
/// <summary>Displays whole Grappler ammo as smoothly re-spacing pixels around the player.</summary>
public sealed class GrapplerAmmoOrbitOverlay : Component
{
const float OVAL_RADIUS_X = 6.5f;
const float OVAL_RADIUS_Y = 7.5f;
const float PIXEL_SIZE = 1f;
const float PIXEL_ALPHA = 0.9f;
const float POSITION_LERP_SPEED = 12f;
const float FULL_CIRCLE = MathF.PI * 2f;
const float SLOT_PHASE = -MathF.PI * 0.5f;
sealed class AmmoPixel
{
public SpriteRenderer Sprite;
public Vector2 Position;
public Vector2 NominalTarget;
public Vector2 Target;
}
sealed class ArenaLayoutPoint
{
public AmmoPixel Pixel;
public float Angle;
}
sealed class LinePoint
{
public Vector2 Direction;
}
readonly List<AmmoPixel> _pixels = new();
readonly List<LinePoint> _lines = new();
readonly List<ArenaLayoutPoint> _arenaLayout = new();
Player _player;
public void Setup( Player player, int ammoCount )
{
_player = player;
for ( int i = 0; i < ammoCount; i++ )
{
float angle = SLOT_PHASE + FULL_CIRCLE * i / ammoCount;
AddPixel( angle );
}
RebuildTargets();
SnapToTargets();
}
/// <summary>Removes the pixel currently nearest the new line's start on the oval.</summary>
public void Spend( Vector2 lineStartOffset )
{
if ( _pixels.Count == 0 ) return;
AmmoPixel nearest = _pixels[0];
float nearestDistance = (nearest.Position - lineStartOffset).LengthSquared;
for ( int i = 1; i < _pixels.Count; i++ )
{
float distance = (_pixels[i].Position - lineStartOffset).LengthSquared;
if ( distance >= nearestDistance ) continue;
nearest = _pixels[i];
nearestDistance = distance;
}
RemovePixel( nearest );
RebuildTargets();
}
/// <summary>Synchronizes whole ammo and reserves one oval slot for every live line direction.</summary>
public void SetState( int ammoCount, IReadOnlyList<Vector2> lineDirections )
{
ammoCount = Math.Max( 0, ammoCount );
bool layoutChanged = false;
while ( _pixels.Count > ammoCount )
{
RemovePixel( _pixels[^1] );
layoutChanged = true;
}
while ( _pixels.Count < ammoCount )
{
AddPixel( Rng.CosmeticFloat( -MathF.PI, MathF.PI ) );
layoutChanged = true;
}
for ( int i = _lines.Count - 1; i >= 0; i-- )
{
if ( ContainsDirection( lineDirections, _lines[i].Direction ) ) continue;
_lines.RemoveAt( i );
layoutChanged = true;
}
foreach ( Vector2 direction in lineDirections )
{
if ( FindLine( direction ) is not null ) continue;
_lines.Add( new LinePoint { Direction = direction } );
layoutChanged = true;
}
if ( layoutChanged ) RebuildTargets();
}
protected override void OnEnabled()
{
SetSpritesEnabled( true );
}
protected override void OnDisabled()
{
SetSpritesEnabled( false );
}
protected override void OnUpdate()
{
bool visible = _player?.BodySpriteRenderer is { Enabled: true } body && body.GameObject.Active;
ApplyArenaLayout( useCurrentPositions: false );
float amount = 1f - MathF.Exp( -POSITION_LERP_SPEED * Time.Delta );
foreach ( AmmoPixel pixel in _pixels )
{
pixel.Sprite.Enabled = visible;
if ( !visible ) continue;
pixel.Position = Vector2.Lerp( pixel.Position, pixel.Target, amount );
}
ApplyArenaLayout( useCurrentPositions: true );
foreach ( AmmoPixel pixel in _pixels )
{
if ( !visible ) continue;
Vector2 center = SpriteLayer.PixelAlignedCenter( pixel.Position, pixel.Sprite.Size );
pixel.Sprite.GameObject.LocalPosition = new Vector3(
center.x, center.y, 2f * SpriteLayer.LAYER_Z_STEP );
}
}
void AddPixel( float angle )
{
Vector2 position = PointOnOval( angle );
SpriteRenderer sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", new Vector2( PIXEL_SIZE ), "idle", childOrder: 2 );
sprite.Opaque = false;
sprite.AlphaCutoff = 0f;
sprite.Color = Color.White.WithAlpha( PIXEL_ALPHA );
sprite.RenderOptions.Game = false;
sprite.RenderOptions.Overlay = true;
sprite.Enabled = Enabled;
_pixels.Add( new AmmoPixel
{
Sprite = sprite,
Position = position,
NominalTarget = position,
Target = position,
} );
}
void RemovePixel( AmmoPixel pixel )
{
if ( !_pixels.Remove( pixel ) ) return;
pixel.Sprite?.GameObject?.Destroy();
}
void RebuildTargets()
{
int slotCount = _pixels.Count + _lines.Count;
if ( slotCount == 0 ) return;
var slots = new List<float>( slotCount );
for ( int i = 0; i < slotCount; i++ )
slots.Add( NormalizeAngle( SLOT_PHASE + FULL_CIRCLE * i / slotCount ) );
slots.Sort();
var reserved = new bool[slotCount];
foreach ( LinePoint line in _lines )
{
float lineAngle = NormalizeAngle( MathF.Atan2( line.Direction.y, line.Direction.x ) );
int nearestSlot = -1;
float nearestDistance = float.MaxValue;
for ( int i = 0; i < slots.Count; i++ )
{
if ( reserved[i] ) continue;
float distance = AngularDistance( lineAngle, slots[i] );
if ( distance >= nearestDistance ) continue;
nearestSlot = i;
nearestDistance = distance;
}
if ( nearestSlot < 0 ) continue;
reserved[nearestSlot] = true;
}
var availableSlots = new List<float>( _pixels.Count );
for ( int i = 0; i < slots.Count; i++ )
if ( !reserved[i] ) availableSlots.Add( slots[i] );
var orderedPixels = new List<AmmoPixel>( _pixels );
orderedPixels.Sort( ( left, right ) => PositionAngle( left.Position ).CompareTo( PositionAngle( right.Position ) ) );
int bestShift = 0;
float bestCost = float.MaxValue;
for ( int shift = 0; shift < availableSlots.Count; shift++ )
{
float cost = 0f;
for ( int i = 0; i < orderedPixels.Count; i++ )
{
float distance = AngularDistance( PositionAngle( orderedPixels[i].Position ), availableSlots[(i + shift) % availableSlots.Count] );
cost += distance * distance;
}
if ( cost >= bestCost ) continue;
bestCost = cost;
bestShift = shift;
}
for ( int i = 0; i < orderedPixels.Count; i++ )
orderedPixels[i].NominalTarget = PointOnOval( availableSlots[(i + bestShift) % availableSlots.Count] );
ApplyArenaLayout( useCurrentPositions: false );
}
void SnapToTargets()
{
foreach ( AmmoPixel pixel in _pixels )
{
pixel.Position = pixel.Target;
Vector2 center = SpriteLayer.PixelAlignedCenter( pixel.Position, pixel.Sprite.Size );
pixel.Sprite.GameObject.LocalPosition = new Vector3(
center.x, center.y, 2f * SpriteLayer.LAYER_Z_STEP );
}
}
void SetSpritesEnabled( bool enabled )
{
foreach ( AmmoPixel pixel in _pixels )
pixel.Sprite.Enabled = enabled;
}
static Vector2 PointOnOval( float angle )
=> new( MathF.Cos( angle ) * OVAL_RADIUS_X, MathF.Sin( angle ) * OVAL_RADIUS_Y );
void ApplyArenaLayout( bool useCurrentPositions )
{
float halfPixel = PIXEL_SIZE * 0.5f;
float minimumX = Arena.WALL_SIZE + halfPixel - _player.Pos.x;
float maximumX = Arena.WIDTH - Arena.WALL_SIZE - halfPixel - _player.Pos.x;
float minimumY = Arena.WALL_SIZE + halfPixel - _player.Pos.y;
float maximumY = Arena.HEIGHT - Arena.WALL_SIZE - halfPixel - _player.Pos.y;
bool leftClipped = minimumX > -OVAL_RADIUS_X;
bool rightClipped = maximumX < OVAL_RADIUS_X;
bool lowerClipped = minimumY > -OVAL_RADIUS_Y;
bool upperClipped = maximumY < OVAL_RADIUS_Y;
if ( !leftClipped && !rightClipped && !lowerClipped && !upperClipped )
{
if ( !useCurrentPositions )
foreach ( AmmoPixel pixel in _pixels )
pixel.Target = pixel.NominalTarget;
return;
}
Vector2 inward = new(
leftClipped ? 1f : rightClipped ? -1f : 0f,
lowerClipped ? 1f : upperClipped ? -1f : 0f );
float referenceAngle = MathF.Atan2( inward.y, inward.x );
float startAngle = referenceAngle - FULL_CIRCLE;
float endAngle = referenceAngle + FULL_CIRCLE;
if ( leftClipped )
{
float halfArc = MathF.Acos( Math.Clamp( minimumX / OVAL_RADIUS_X, -1f, 1f ) );
IntersectArc( ref startAngle, ref endAngle, referenceAngle, 0f, halfArc );
}
else if ( rightClipped )
{
float boundaryAngle = MathF.Acos( Math.Clamp( maximumX / OVAL_RADIUS_X, -1f, 1f ) );
IntersectArc( ref startAngle, ref endAngle, referenceAngle, MathF.PI, MathF.PI - boundaryAngle );
}
if ( lowerClipped )
{
float boundaryAngle = MathF.Asin( Math.Clamp( minimumY / OVAL_RADIUS_Y, -1f, 1f ) );
IntersectArc( ref startAngle, ref endAngle, referenceAngle, MathF.PI * 0.5f, MathF.PI * 0.5f - boundaryAngle );
}
else if ( upperClipped )
{
float boundaryAngle = MathF.Asin( Math.Clamp( maximumY / OVAL_RADIUS_Y, -1f, 1f ) );
IntersectArc( ref startAngle, ref endAngle, referenceAngle, MathF.PI * 1.5f, MathF.PI * 0.5f + boundaryAngle );
}
while ( _arenaLayout.Count < _pixels.Count )
_arenaLayout.Add( new ArenaLayoutPoint() );
if ( _arenaLayout.Count > _pixels.Count )
_arenaLayout.RemoveRange( _pixels.Count, _arenaLayout.Count - _pixels.Count );
for ( int i = 0; i < _pixels.Count; i++ )
{
AmmoPixel pixel = _pixels[i];
Vector2 position = useCurrentPositions ? pixel.Position : pixel.NominalTarget;
_arenaLayout[i].Pixel = pixel;
_arenaLayout[i].Angle = ClosestAngleOnArc( PositionAngle( position ), startAngle, endAngle );
}
_arenaLayout.Sort( static ( left, right ) => left.Angle.CompareTo( right.Angle ) );
float minimumDistance = PIXEL_SIZE * MathF.Sqrt( 2f );
float minimumSpacing = 2f * MathF.Asin( Math.Clamp( minimumDistance / (2f * OVAL_RADIUS_X), 0f, 1f ) );
for ( int i = 1; i < _arenaLayout.Count; i++ )
_arenaLayout[i].Angle = MathF.Max( _arenaLayout[i].Angle, _arenaLayout[i - 1].Angle + minimumSpacing );
if ( _arenaLayout.Count > 0 && _arenaLayout[^1].Angle > endAngle )
{
_arenaLayout[^1].Angle = endAngle;
for ( int i = _arenaLayout.Count - 2; i >= 0; i-- )
_arenaLayout[i].Angle = MathF.Min( _arenaLayout[i].Angle, _arenaLayout[i + 1].Angle - minimumSpacing );
}
if ( _arenaLayout.Count > 1 && _arenaLayout[0].Angle < startAngle )
{
float spacing = (endAngle - startAngle) / (_arenaLayout.Count - 1);
for ( int i = 0; i < _arenaLayout.Count; i++ )
_arenaLayout[i].Angle = startAngle + spacing * i;
}
foreach ( ArenaLayoutPoint point in _arenaLayout )
{
Vector2 position = PointOnOval( point.Angle );
if ( useCurrentPositions ) point.Pixel.Position = position;
else point.Pixel.Target = position;
}
}
static void IntersectArc( ref float startAngle, ref float endAngle, float referenceAngle, float centerAngle, float halfArc )
{
centerAngle += MathF.Round( (referenceAngle - centerAngle) / FULL_CIRCLE ) * FULL_CIRCLE;
startAngle = MathF.Max( startAngle, centerAngle - halfArc );
endAngle = MathF.Min( endAngle, centerAngle + halfArc );
}
static float ClosestAngleOnArc( float angle, float startAngle, float endAngle )
{
float lowerAngle = angle - FULL_CIRCLE;
if ( lowerAngle >= startAngle && lowerAngle <= endAngle ) return lowerAngle;
if ( angle >= startAngle && angle <= endAngle ) return angle;
float upperAngle = angle + FULL_CIRCLE;
if ( upperAngle >= startAngle && upperAngle <= endAngle ) return upperAngle;
return AngularDistance( angle, NormalizeAngle( startAngle ) )
<= AngularDistance( angle, NormalizeAngle( endAngle ) )
? startAngle
: endAngle;
}
LinePoint FindLine( Vector2 direction )
{
foreach ( LinePoint line in _lines )
if ( SameDirection( line.Direction, direction ) ) return line;
return null;
}
static bool ContainsDirection( IReadOnlyList<Vector2> directions, Vector2 direction )
{
foreach ( Vector2 candidate in directions )
if ( SameDirection( candidate, direction ) ) return true;
return false;
}
static bool SameDirection( Vector2 first, Vector2 second )
=> MathF.Sign( first.x ) == MathF.Sign( second.x )
&& MathF.Sign( first.y ) == MathF.Sign( second.y );
static float PositionAngle( Vector2 position )
=> NormalizeAngle( MathF.Atan2( position.y / OVAL_RADIUS_Y, position.x / OVAL_RADIUS_X ) );
static float NormalizeAngle( float angle )
{
angle %= FULL_CIRCLE;
return angle < 0f ? angle + FULL_CIRCLE : angle;
}
static float AngularDistance( float first, float second )
{
float distance = MathF.Abs( first - second );
return MathF.Min( distance, FULL_CIRCLE - distance );
}
}