Game/TwinsController.cs
namespace BlockParty;
/// <summary>Owns deterministic control routing and presentation for a paired player identity.</summary>
public sealed class TwinsController
{
private const int FLASH_FRAMES = 8;
private readonly IReadOnlyList<Player> _players;
private readonly SpriteRenderer _markerBar;
private readonly SpriteRenderer _markerTip;
private bool _hadDirectionalInput;
private int _zeroInputFrames;
private int _flashFrames;
private bool _stepStarted;
private bool _suppressAtNextPreTick;
public int ActiveIndex { get; private set; }
public Player ActivePlayer => ActiveIndex >= 0 && ActiveIndex < _players.Count ? _players[ActiveIndex] : null;
/// <summary>Consecutive neutral input frames required after a fresh release. Values below 1 act as 1.</summary>
public int ZeroInputFramesToSwitch { get; set; } = 12;
public TwinsController( GameStage stage, IReadOnlyList<Player> players )
{
_players = players;
ActiveIndex = 0;
var barObject = stage.CreateChild( "TwinControlMarkerBar" );
_markerBar = SpriteLayer.Add( barObject, "sprites/pixel.sprite", new Vector2( 5f, 1f ), "idle" );
_markerBar.Opaque = false; // marker alpha animates with input state
_markerBar.AlphaCutoff = 0f;
_markerBar.Color = Color.White;
var tipObject = stage.CreateChild( "TwinControlMarkerTip" );
_markerTip = SpriteLayer.Add( tipObject, "sprites/pixel.sprite", new Vector2( 1f, 1f ), "idle" );
_markerTip.Opaque = false;
_markerTip.AlphaCutoff = 0f;
_markerTip.Color = Color.White;
RouteInput( HasDirectionalInput() );
SyncPresentation();
}
/// <summary>Runs once before either twin ticks, so the gesture cannot be observed twice.</summary>
public void PreTick()
{
_stepStarted = true;
EnsureLivingActive();
if ( _flashFrames > 0 ) _flashFrames--;
if ( _suppressAtNextPreTick )
{
_suppressAtNextPreTick = false;
SuppressInputForCurrentTick();
ResetSwitchState();
return;
}
bool hasDirectionalInput = HasDirectionalInput();
RouteInput( hasDirectionalInput );
if ( LivingCount() < 2 )
{
ResetSwitchState();
return;
}
bool switchRequested = UpdateSwitchCountdown( hasDirectionalInput );
if ( !switchRequested ) return;
int next = FindLivingOtherThan( ActiveIndex, allowHardened: false );
if ( next >= 0 )
{
ActiveIndex = next;
ActivePlayer.StartScaleBounce();
PlayControlSwitchSfx();
SuppressInputForCurrentTick();
_flashFrames = FLASH_FRAMES;
}
ResetSwitchState();
}
public void EndStep() => _stepStarted = false;
public void PlayerDied( Player player )
{
if ( !ReferenceEquals( player, ActivePlayer ) ) return;
int next = FindLivingOtherThan( ActiveIndex, allowHardened: true );
if ( next >= 0 )
{
ActiveIndex = next;
ActivePlayer.StartScaleBounce();
PlayControlSwitchSfx();
SuppressInputForCurrentTick();
_suppressAtNextPreTick = !_stepStarted;
_flashFrames = FLASH_FRAMES;
}
}
public void PlayerHardened( Player player )
{
if ( !ReferenceEquals( player, ActivePlayer ) ) return;
int next = FindLivingOtherThan( ActiveIndex, allowHardened: false );
if ( next < 0 ) return;
ActiveIndex = next;
ActivePlayer.StartScaleBounce();
PlayControlSwitchSfx();
SuppressInputForCurrentTick();
_flashFrames = FLASH_FRAMES;
ResetSwitchState();
}
public void SyncPresentation()
{
var player = ActivePlayer;
bool visible = player is { IsDead: false } && LivingCount() > 1;
_markerBar.Enabled = visible;
_markerTip.Enabled = visible;
if ( !visible ) return;
float lift = _flashFrames > 0 ? 1f : 0f;
float z = Globals.DepthToZ( Globals.DEPTH_PARTICLE_1 );
float gravitySign = player.GravityReversed ? -1f : 1f;
float headEdge = player.GravityReversed ? player.Bottom : player.Top;
Vector2 barCenter = SpriteLayer.PixelAlignedCenter(
new Vector2( player.X, headEdge + gravitySign * (4f + lift) ), _markerBar.Size );
_markerBar.GameObject.WorldPosition = new Vector3( barCenter.x, barCenter.y, z );
_markerTip.GameObject.WorldPosition = new Vector3( barCenter.x, barCenter.y - gravitySign, z );
float alpha = _zeroInputFrames > 0
? Math.Clamp( 1f - _zeroInputFrames / (float)Math.Max( 1, ZeroInputFramesToSwitch ), 0f, 1f )
: 1f;
Color color = (_flashFrames > 0 ? Color.Yellow : Color.White).WithAlpha( alpha );
_markerBar.Color = color;
_markerTip.Color = color;
}
private void EnsureLivingActive()
{
if ( ActivePlayer is { IsDead: false } ) return;
int next = FindLivingOtherThan( -1, allowHardened: true );
if ( next >= 0 ) ActiveIndex = next;
}
private void RouteInput( bool hasDirectionalInput )
{
for ( int i = 0; i < _players.Count; i++ )
{
bool active = i == ActiveIndex && !_players[i].IsDead;
_players[i].SetPairedControlActive( active, hasDirectionalInput );
_players[i].SetInputSource( active ? LiveInputSource.Instance : NullPlayerInput.Instance );
}
}
private void SuppressInputForCurrentTick()
{
bool hasDirectionalInput = HasDirectionalInput();
for ( int i = 0; i < _players.Count; i++ )
{
_players[i].SetPairedControlActive( i == ActiveIndex && !_players[i].IsDead, hasDirectionalInput );
_players[i].SetInputSource( NullPlayerInput.Instance );
}
}
private void PlayControlSwitchSfx()
{
// Dry click at the handover, one fixed pitch per twin (never random): the same switch always
// sounds the same, and which twin took over is readable from the tone alone.
Audio.PlaySfx( SfxType.TwinSwitch, ActivePlayer.Position, 0.6f, ActiveIndex == 0 ? 0.9f : 1.15f );
// Quick crisp tick so a handover is felt as well as heard — it changes which body the stick
// drives, which is worth confirming in the hand.
Haptics.Pulse( 0.3f, 0.05f, 0f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
}
private int FindLivingOtherThan( int excluded, bool allowHardened )
{
for ( int i = 0; i < _players.Count; i++ )
if ( i != excluded && !_players[i].IsDead && (allowHardened || !_players[i].IsHardened) )
return i;
return -1;
}
private int LivingCount()
{
int count = 0;
foreach ( var player in _players )
if ( !player.IsDead ) count++;
return count;
}
private static bool HasDirectionalInput()
{
return InputState.Left || InputState.Right || InputState.Up || InputState.Down
|| InputState.LeftJust || InputState.RightJust || InputState.UpJust || InputState.DownJust;
}
private bool UpdateSwitchCountdown( bool hasDirectionalInput )
{
if ( hasDirectionalInput )
{
_hadDirectionalInput = true;
_zeroInputFrames = 0;
return false;
}
if ( !_hadDirectionalInput && _zeroInputFrames == 0 )
return false;
_hadDirectionalInput = false;
_zeroInputFrames++;
return _zeroInputFrames >= Math.Max( 1, ZeroInputFramesToSwitch );
}
private void ResetSwitchState()
{
_hadDirectionalInput = false;
_zeroInputFrames = 0;
}
}