GameManager.cs
using Sandbox.Network;
using System;
using System.Threading.Tasks;

namespace Battlebugs;

public sealed class GameManager : Component, Component.INetworkListener
{
	public static GameManager Instance { get; private set; }

	[RequireComponent] PlacementInput _placementInput { get; set; }
	[RequireComponent] AttackingInput _attackingInput { get; set; }
	[RequireComponent] InspectInput _inspectInput { get; set; }

	// Properties
	[Property, Group( "Prefabs" )] public GameObject BoardPrefab { get; set; }
	[Property, Group( "Prefabs" )] public GameObject CellPrefab { get; set; }
	[Property, Group( "Prefabs" )] public GameObject DamageNumberPrefab { get; set; }
	[Property, Group( "Prefabs" )] public GameObject CoinPrefab { get; set; }

	// Networked Variables
	[Sync( SyncFlags.FromHost )] public GameState State { get; set; }
	[Sync( SyncFlags.FromHost )] public Guid CurrentPlayerId { get; set; }
	[Sync( SyncFlags.FromHost )] public bool IsFiring { get; set; }
	[Sync( SyncFlags.FromHost )] public TimeSince TimeSinceTurnStart { get; set; }

	public static bool CpuMode { get; set; }

	// Local Variables
	public List<BoardManager> Boards;
	public BoardManager CurrentPlayer => Boards.FirstOrDefault( x => x.IsValid() && x.Network.OwnerId == CurrentPlayerId );
	Vector3 LastPebblePosition;
	TimeSince TimeSincePebbleToss;

	protected override void OnAwake()
	{
		Instance = this;
		State = GameState.Waiting;
		Boards = new();
	}

	protected override async Task OnLoad()
	{
		if ( Networking.IsActive ) return;

		LoadingScreen.Title = CpuMode ? "Starting Bot Match" : "Creating Lobby";
		await Task.DelayRealtimeSeconds( 0.1f );

		// CPU mode still needs a networking session for NetworkSpawn, IsHost, and RPCs to function.
		// Make it private with max 1 player so nobody else can join.
		var config = new LobbyConfig();
		if ( CpuMode )
		{
			config.MaxPlayers = 1;
			config.Privacy = LobbyPrivacy.Private;
		}
		Networking.CreateLobby( config );
	}

	protected override void OnStart()
	{
		// This is really just for late-joiners
		Boards = Scene.GetAllComponents<BoardManager>().ToList();
	}

	public void OnActive( Connection channel )
	{
		// TODO: Create a spectator pawn or something
		if ( Boards.Count >= 2 ) return;

		CreateBoard( channel );

		// In CPU mode, also create the CPU board now that the lobby is active.
		// Must happen here (not OnStart) because NetworkSpawn requires an active lobby.
		if ( CpuMode )
		{
			CreateBoard( null );
		}
	}

	public void OnDisconnected( Connection channel )
	{
		if ( !Networking.IsHost ) return;
		if ( CpuMode ) return;

		Log.Info( $"Player '{channel.DisplayName}' disconnected" );
		EndGame();
	}

	void CreateBoard( Connection channel )
	{
		var currentBoardCount = Scene.GetAllComponents<BoardManager>().Count();
		var client = BoardPrefab.Clone( new CloneConfig()
		{
			Transform = new Transform( new Vector3( currentBoardCount * 1000f, 0, 2f ), new Angles( 0, currentBoardCount == 0 ? 0 : 180, 0 ) ),
			Name = channel?.DisplayName ?? "CPU"
		} );
		client.Network.SetOrphanedMode( NetworkOrphaned.ClearOwner );
		client.NetworkSpawn( channel );

		Boards = Scene.GetAllComponents<BoardManager>().ToList();
	}

	[Rpc.Broadcast]
	void StartGame()
	{
		Sound.Play( "player-join" );

		if ( Networking.IsHost )
		{
			State = GameState.Placing;
		}

		Boards = Scene.GetAllComponents<BoardManager>().ToList();
	}

	[Rpc.Broadcast]
	void StartPlaying()
	{
		if ( Networking.IsHost )
		{
			State = GameState.Playing;
			StartTurn();

			foreach ( var board in Boards )
			{
				board.SaveBugReferences();
			}
		}

		foreach ( var segment in Scene.GetAllComponents<BugSegment>() )
		{
			segment.SetAlpha( (segment.Network.OwnerId == Connection.Local.Id) ? 0.5f : 0f );
		}
	}

	void StartTurn()
	{
		TimeSinceTurnStart = 0;
		CurrentPlayerId = Boards.FirstOrDefault( x => x.Network.OwnerId != CurrentPlayerId ).Network.OwnerId;
		IsFiring = true;
	}

	[Rpc.Broadcast]
	void EndGame()
	{
		if ( Networking.IsHost )
		{
			State = GameState.Results;
		}

		if ( !(BoardManager.Local?.IsValid() ?? false) ) return;
		var didWin = BoardManager.Local.GetScorePercent() > 0.5f;
		Sandbox.Services.Stats.Increment( "games_played", 1 );
		if ( didWin ) Sandbox.Services.Stats.Increment( "games_won", 1 );
		else Sandbox.Services.Stats.Increment( "games_lost", 1 );
	}

	protected override void OnUpdate()
	{
		switch ( State )
		{
			case GameState.Waiting: UpdateWaiting(); break;
			case GameState.Placing: UpdatePlacing(); break;
			case GameState.Playing: UpdateGame(); break;
			case GameState.Results: UpdateResults(); break;
		}
		InspectInput.Instance.Enabled = State == GameState.Playing;
	}

	void UpdateWaiting()
	{
		if ( BoardManager.Local is not null )
		{
			UpdateCamera( BoardManager.Local );
		}

		if ( Networking.IsHost )
		{
			if ( Boards.Count > 1 )
			{
				StartGame();
			}
		}
	}

	void UpdatePlacing()
	{
		PlacementInput.Instance.Enabled = true;

		if ( BoardManager.Local is not null )
		{
			UpdateCamera( BoardManager.Local );
		}

		if ( Networking.IsHost )
		{
			if ( !Boards.Any( x => !x.IsReady ) )
			{
				StartPlaying();
			}
		}
	}

	void UpdateGame()
	{
		var currentPlayer = CurrentPlayer;
		PlacementInput.Instance.Enabled = false;
		AttackingInput.Instance.Enabled = IsFiring && (currentPlayer == BoardManager.Local);

		if ( currentPlayer is not null )
		{
			var healthPercent = currentPlayer.GetHealthPercent();
			if ( healthPercent == 0 )
			{
				EndGame();
			}

			var otherPlayer = Boards.FirstOrDefault( x => x.IsValid() && x.Network.OwnerId != CurrentPlayerId );
			if ( otherPlayer is null ) return;
			if ( IsFiring )
			{
				UpdateCamera( otherPlayer );
				LastPebblePosition = Scene.Camera.WorldPosition + Scene.Camera.WorldRotation.Forward * 1000f;

				if ( TimeSinceTurnStart >= 15f )
				{
					currentPlayer.AttackRandomly();
				}
			}
			else
			{
				var pebbles = Scene.GetAllComponents<PebbleComponent>();
				var pebble = pebbles.Where( x => x.TimeSinceCreated > 0.6f ).FirstOrDefault();
				if ( pebble is not null )
				{
					LastPebblePosition = pebble.WorldPosition;
				}
				if ( pebbles.Count() > 0 ) TimeSincePebbleToss = 0;
				UpdateCamera( otherPlayer, LastPebblePosition );

				if ( pebbles.Count() == 0 && TimeSincePebbleToss > 3f )
				{
					StartTurn();
				}
			}
		}
	}

	void UpdateResults()
	{

	}

	void UpdateCamera( BoardManager board )
	{
		Scene.Camera.WorldPosition = Scene.Camera.WorldPosition.LerpTo( board.CameraPosition.WorldPosition, Time.Delta * 5f );
		Scene.Camera.WorldRotation = Rotation.Slerp( Scene.Camera.WorldRotation, board.CameraPosition.WorldRotation, Time.Delta * 5f );
	}

	void UpdateCamera( BoardManager board, Vector3 lookAt )
	{
		Scene.Camera.WorldPosition = Scene.Camera.WorldPosition.LerpTo( board.CameraPosition.WorldPosition, Time.Delta * 5f );

		var rotation = Rotation.LookAt( lookAt - Scene.Camera.WorldPosition, Vector3.Up );
		Scene.Camera.WorldRotation = Rotation.Slerp( Scene.Camera.WorldRotation, rotation, Time.Delta * 5f );
	}

	public void CreateBug( BoardManager board, List<PlacementInput.PlacementData> cells, bool isCpu = false )
	{
		// Use the board's own inventory rather than BoardManager.Local, since the
		// local board may not be initialised yet when the CPU places its bugs.
		var bug = board.BugInventory.FirstOrDefault( x => x.Key.SegmentCount == cells.Count );
		if ( bug.Value <= 0 ) return;
		var bugId = Guid.NewGuid();

		for ( int i = 0; i < cells.Count; i++ )
		{
			var segment = cells[i].Prefab.Clone( new Transform(
				cells[i].Cell.WorldPosition,
				cells[i].Rotation
			) );
			segment.Name = bugId.ToString();
			var component = segment.Components.Get<BugSegment>();
			component.Init( bug.Key, i );
			component.Cell = cells[i].Cell;
			segment.Network.SetOrphanedMode( NetworkOrphaned.ClearOwner );
			segment.NetworkSpawn( isCpu ? null : Connection.Local );

			cells[i].Cell.IsOccupied = true;
		}

		board.BugInventory[bug.Key]--;
	}

	public void SpawnCoins( Vector3 position, int amount = 1 )
	{
		for ( int i = 0; i < amount; i++ )
		{
			CoinPrefab.Clone( position + Vector3.Up * 2f );
		}
	}

	[Rpc.Owner]
	public void BroadcastFire( Guid boardId, string weaponPath, Vector3 position )
	{
		if ( !CpuMode && Rpc.CallerId != CurrentPlayerId ) return;
		if ( IsFiring == false ) return;

		var weapon = ResourceLibrary.Get<WeaponResource>( weaponPath );
		if ( weapon is null ) return;

		var board = Boards.FirstOrDefault( x => x.Id != boardId );
		if ( board is null ) return;

		int count = (int)MathF.Round( weapon.AmountFired.GetValue() );

		for ( int i = 0; i < count; i++ )
		{
			var offset = Vector3.Random.WithZ( 0 ) * weapon.Spray;
			var pos = board.CameraPosition.WorldPosition.WithZ( 32f ) + (board.WorldRotation.Forward * 200f) + offset;
			var target = position + offset;
			var pebbleObj = weapon.Prefab.Clone( pos );
			var pebble = pebbleObj.Components.Get<PebbleComponent>();
			pebble.Damage = weapon.Damage.GetValue();
			pebble.LaunchAt( target );
			pebbleObj.NetworkSpawn( null );
		}
		BroadcastFireSound();
		IsFiring = false;
	}

	[Rpc.Broadcast]
	void BroadcastFireSound()
	{
		Sound.Play( "fling-rocks" );
	}

	[Rpc.Broadcast]
	public void BroadcastDamageNumber( Vector3 position, float damage )
	{
		if ( DamageNumberPrefab is not null )
		{
			var damageNumber = DamageNumberPrefab.Clone( position );
			var text = damageNumber.Components.Get<TextRenderer>();
			text.Text = "-" + damage.ToString();
			text.Color = Color.Red;
		}
	}

	[Rpc.Broadcast]
	public void SendChatMessage( string message )
	{
		var playerHud = PlayerHud.Instances.FirstOrDefault( x => x.Board.Network.OwnerId == Rpc.CallerId );
		if ( playerHud is null ) return;

		playerHud.AddChatMessage( message );
	}

}