Game/GamePlayer.cs

Player component for the game. Manages local player state, camera and GUI creation, input for movement, clicking, dragging upgrades, unit selection and unit/hex interactions, plus some server-only initialization and utility spawn helpers.

NetworkingFile Access
🐞 DoMovement calls GUi.OnUpgradeClicked twice when releasing a dragged upgrade, so the UI click handler runs twice.
using Forp.Object;
using Forp.Object.Building;
using Forp.Object.Unit;
using Sandbox.Diagnostics;
using System;
using System.Linq.Expressions;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using System.Threading;
using static Sandbox.Services.Stats;

namespace Forp.Game;

public enum ECameraMode
{
	Normal,
	Build,
	Combat,
}

public record FPlayerBoardStats
{
	public int Production { get; set; }
	public int TerritoryCount { get; set; }
	public int TerritoryPercentage { get => (int)(((float)TerritoryCount / GameManager.Instance.BoardHexes.Count) * 100f); }
}

public sealed partial class GamePlayer : Component
{
	public static GamePlayer Local { get; private set; } = null;

	[Property] public GameObject PlayerCameraPrefab { get; private set; }
	public CameraComponent Camera { get; private set; }
	public GamePlayerGUi GUi { get; private set; }

	[Sync(SyncFlags.FromHost), Property] public ulong SteamId { get; private set; }
	[Sync(SyncFlags.FromHost), Property] public string SteamName { get; private set; }
	[Sync(SyncFlags.FromHost), Property] public Guid ConnectionId { get; private set; }

	[Sync(SyncFlags.FromHost), Property] public NetList<FUpgrade> Upgrades { get; private set; }
	[Sync(SyncFlags.FromHost), Property] public Color Colour { get; set; }
	[Sync(SyncFlags.FromHost), Property] public int Gold { get; set; }

	public override string ToString()
	{
		return $"GamePlayer : {SteamName} : c={IsConnected}?{(IsConnected ? Connection : string.Empty)} ai={IsAi}";
	}

	public Connection Connection { get; private set; }
	public bool IsConnected => Connection != null && Connection.IsActive;

	private ECameraMode _CameraMode = ECameraMode.Build;
	public ECameraMode CameraMode
	{
		get => _CameraMode;
		set
		{
			if (_CameraMode == value)
			{
				return;
			}

			_CameraMode = value;
			OnCameraModeChange();
		}
	}

	public GameObject DraggedObject { get; private set; }
	public Obj HoveredObject { get; private set; }
	public Hex SelectedHex { get; private set; }

	private ObjectUnit _SelectedUnit = null;
	public ObjectUnit SelectedUnit
	{
		get => _SelectedUnit;
		set
		{
			if (value != null)
			{
				_SelectedUnit = value;
				SelectUnit();
			}
			else
			{
				DeselectUnit();
				_SelectedUnit = value;
			}
		}
	}

	private void OnCameraModeChange()
	{
		foreach (var Hexogon in GameManager.Instance.BoardHexes)
		{
			Hexogon.LocalCameraMode = CameraMode;
		}
	}

	private void SelectUnit()
	{
		if (SelectedUnit.OwnerPlayer?.ConnectionId != Local.ConnectionId)
		{
			return;
		}

		if (GameManager.Instance.HACK_GetHexFromUnit(SelectedUnit) is { } UnitHex)
		{
			SelectedUnit.HighlightOutline.Enabled = true;
			var MoveRange = UnitHex.UnitData.ActionPoints - UnitHex.UnitData.ActionPointsSpent + 1;
			Hex.HighlightHexesRecusrive(UnitHex, true, MoveRange);
		}
	}

	private void DeselectUnit()
	{
		if (GameManager.Instance.HACK_GetHexFromUnit(SelectedUnit) is { } UnitHex)
		{
			SelectedUnit.HighlightOutline.Enabled = false;
			Hex.HighlightHexesRecusrive(UnitHex, false, SelectedUnit.ActionPoints + 1);
		}

		foreach (var Hex in GameManager.Instance.BoardHexes)
		{
			Hex.IsHighlighted = false;
		}
	}

	protected override void OnStart()
	{
		base.OnStart();

		if (!Network.IsOwner)
		{
			return;
		}

		foreach (var Hex in GameManager.Instance.BoardHexes)
		{
			if (Hex.UnitData == null)
			{
				continue;
			}

			if (Hex.UnitData.OwnerGuid == ConnectionId && Hex.UnitData.ObjectId == "unit-settler")
			{
				SelectedUnit = Hex.UnitObject;
				break;
			}
		}
	}

	protected override void OnAwake()
	{
		base.OnAwake();

		if (!Network.IsOwner)
		{
			return; // !
		}

		Log.Info($"player awoke ai={IsAi}");

		Connection = Connection.Local; // TODO : this is only set on server & local client per player... 
										// why dont connections replicate ? [may/26]
		if (IsAi)
		{
			return; // !
		}

		Local = this;
		Mouse.Visibility = MouseVisibility.Visible;
		Assert.True(CreateCamera());

		RefreshGUi();
	}

	private bool CreateCamera()
	{
		Transform CloneTransform = new();
		var Cloneconfig = new CloneConfig();
		Cloneconfig.Parent = GameObject;
		Cloneconfig.StartEnabled = true;
		Cloneconfig.Transform = CloneTransform;
		var CameraObject = PlayerCameraPrefab.Clone(Cloneconfig);

		Camera = CameraObject.GetComponentInChildren<CameraComponent>();
		if (Camera == null)
		{
			Log.Error("failed to create camera for local player!");
			return Camera != null;
		}

		GUi = CameraObject.GetComponentInChildren<GamePlayerGUi>(true);
		if (GUi == null)
		{
			Log.Error("failed to create gui for local player!");
			return GUi != null;
		}

		return true;
	}

	protected override void OnUpdate()
	{
		if (IsProxy || IsAi)
		{
			return;
		}

		DoMovement();
		DoAction();

		var HoverRay = Camera.ScreenPixelToRay(Mouse.Position);
		var HoverTraces = Scene.Trace.Ray(HoverRay.Position, HoverRay.Position + HoverRay.Forward * 10000f).RunAll();

		foreach (var HoverTrace in HoverTraces)
		{
			if (!HoverTrace.Hit)
			{
				continue;
			}

			if (HoverTrace.GameObject.GetComponent<Obj>() is { } HitObject)
			{
				// special case to make upgrades work
				// TODO : this should be a list!
				if (HitObject is Upgrade && DraggedObject != null)
				{
					continue;
				}

				HoveredObject = HitObject;
				break;
			}
		}

		RefreshGUi();
	}

	public bool Initilize_ServerOnly(Connection ConnectionIn, Hex SpawnHex, bool IsAiIn)
	{
		Assert.True(Networking.IsHost);
		Assert.NotNull(ConnectionIn);

		if (IsAiIn)
		{
			IsAi = IsAiIn;

			Connection = ConnectionIn;
			ConnectionId = Guid.NewGuid();
			SteamId = 0;
			SteamName = $"{Random.Shared.Next()}";

			GameManager.Instance.Server_CreateHexBuildingObject("building-city", SpawnHex, false, ConnectionId);
		}
		else
		{
			Connection = ConnectionIn;
			ConnectionId = ConnectionIn.Id;
			SteamId = ConnectionIn.SteamId;
			SteamName = ConnectionIn.DisplayName;

			GameManager.Instance.Server_CreateHexUnitObject("unit-settler", SpawnHex, ConnectionId, false);
		}

		Log.Info($"{this} is initing");

		var ValidBrothers = SpawnHex.AllBrothers.Where(Hex => Hex != null && Hex.ObjectData == null && Hex.CanWalkOn()).OrderBy(Hex => Random.Shared.Next());
		if (!ValidBrothers.Any())
		{
			Log.Warning($"urrr, couldn't spawn a brother unit... this isn't good...");
			return false;
		}

		var Brother = ValidBrothers.First();
		GameManager.Instance.Server_CreateHexUnitObject("unit-combat", Brother, ConnectionId, false);

		return true;
	}

	const float MovementExp = 2f;

	private void DoMovement()
	{
		Camera.WorldPosition += Camera.WorldRotation.Forward * Input.MouseWheel.y * 50;

		Vector3 FlatForward = Camera.WorldRotation.Forward.WithZ(0).Normal;
		Vector3 FlatRight = Camera.WorldRotation.Right.WithZ(0).Normal;

		if (Input.Down("forward"))
		{
			Camera.WorldPosition += FlatForward * MovementExp;
		}
		if (Input.Down("backward"))
		{
			Camera.WorldPosition -= FlatForward * MovementExp;
		}
		if (Input.Down("left"))
		{
			Camera.WorldPosition -= FlatRight * MovementExp;
		}
		if (Input.Down("right"))
		{
			Camera.WorldPosition += FlatRight * MovementExp;
		}

		if (Input.Down("mouse2"))
		{
			Camera.WorldPosition += FlatForward * Mouse.Delta.y + FlatRight * -Mouse.Delta.x;
		}

		if (Input.Down("mouse3"))
		{
			float Distance = Camera.WorldPosition.z / -Camera.WorldRotation.Forward.z;
			Vector3 Pivot = Camera.WorldPosition + Camera.WorldRotation.Forward * Distance;

			var Rotation = global::Rotation.FromAxis(Vector3.Up, -Mouse.Delta.x * 0.2f);

			Camera.WorldPosition = Pivot + Rotation * (Camera.WorldPosition - Pivot);
			Camera.WorldRotation = Rotation * Camera.WorldRotation;
		}

		if (DraggedObject != null)
		{
			if (!Input.Down("mouse1"))
			{
				if (HoveredObject is ObjectUnit UpgradeUnit)
				{
					var Upgrade = DraggedObject.GetComponent<Upgrade>();
					GameManager.Instance.Server_UpgradeObject(Upgrade.GetUpgradeData(), UpgradeUnit.OwnerHex, ConnectionId);
					for (int Index = Upgrades.Count - 1; Index >= 0; --Index)
					{
						if (Upgrades[Index].ObjectId == Upgrade.ObjectId)
						{
							Upgrades.RemoveAt(Index);
							break;
						}
					}
					DraggedObject.Destroy();
				}

				GUi.OnUpgradeClicked(this); // 1
				GUi.OnUpgradeClicked(this); // 2
				DraggedObject = null; // this code is poo
				return;
			}

			var Yaw = Scene.Camera.ScreenPixelToRay(Mouse.Position);
			var Hit = DragPlane.Trace(Yaw, true);
			if (Hit.HasValue)
			{
				DraggedObject.WorldPosition = Hit.Value + GrabOffset;
			}
		}
	}

	private Plane DragPlane;
	private Vector3 GrabOffset;

	private void DoAction()
	{
		if (Input.Pressed("mouse1"))
		{
			Mouse.CursorType = "crosshair";

			List<Obj> ClickedObjects = new();
			var ClickRay = Camera.ScreenPixelToRay(Mouse.Position);
			var ClickTraces = Scene.Trace.Ray(ClickRay.Position, ClickRay.Position + ClickRay.Forward * 10000f).RunAll();

			foreach (var ClickTrace in ClickTraces)
			{
				if (!ClickTrace.Hit)
				{
					continue;
				}

				if (ClickTrace.GameObject.GetComponent<Upgrade>() is { })
				{
					DraggedObject = ClickTrace.GameObject;

					DraggedObject.WorldRotation = new();

					DragPlane = new Plane(DraggedObject.WorldPosition, -Scene.Camera.WorldRotation.Forward);

					var Ray = Scene.Camera.ScreenPixelToRay(Mouse.Position);
					var Hit = DragPlane.Trace(Ray, true);
					
					if (Hit.HasValue)
					{
						GrabOffset = DraggedObject.WorldPosition - Hit.Value;
					}
				}
				else if (GUi != null && ClickTrace.GameObject == GUi.Upgrade)
				{
					GUi.OnUpgradeClicked(Local);
				}
				else if (ClickTrace.GameObject.GetComponent<Obj>() is { } HitObject)
				{
					HitObject.OnClick();
					ClickedObjects.Add(HitObject);
				}
			}

			///////////////////////////////
			// TODO : sort these & use virt

			foreach (var ClickedObject in ClickedObjects)
			{
				if (ClickedObject is ObjectUnit ObjectUnit)
				{
					DeselectHex();

					if (SelectedUnit == ObjectUnit)
					{
						SelectedUnit = null;
						return;
					}

					if (SelectedUnit.IsValid())
					{
						if (GameManager.Instance.CanAttack(SelectedUnit.OwnerHex, ObjectUnit.OwnerHex, ObjectUnit.OwnerHex.UnitData, Connection.Local.Id, out var _, out var _))
						{
							GameManager.Instance.Server_UnitAttackUnit(SelectedUnit.OwnerHex, ObjectUnit.OwnerHex, Connection.Local.Id);
						}

						SelectedUnit = null;
						return;
					}

					SelectedUnit = ObjectUnit;
					return;
				}
				
				if (ClickedObject is ObjectBuilding ObjectBuilding)
				{
					if (SelectedUnit.IsValid())
					{
						if (GameManager.Instance.CanAttack(SelectedUnit.OwnerHex, ObjectBuilding.OwnerHex, ObjectBuilding.OwnerHex.BuildingData, Connection.Local.Id, out var _, out var _))
						{
							GameManager.Instance.Server_UnitAttackBuilding(SelectedUnit.OwnerHex, ObjectBuilding.OwnerHex, Connection.Local.Id);
						}

						SelectedUnit = null;
					}
				}
			}

			///////////////////////////////

			var PriorHex = SelectedHex;
			DeselectHex();

			var FoundHex = GetHexFromMousePos(Mouse.Position);
			if (!FoundHex.IsValid() || PriorHex == FoundHex)
			{
				return;
			}

			if (SelectedUnit.IsValid() && SelectedUnit.GetComponent<AiUnit>() == null)
			{
				if (GameManager.Instance.HACK_GetHexFromUnit(SelectedUnit) is var UnitHex && UnitHex.IsValid())
				{ 
					Hex.HighlightHexesRecusrive(UnitHex, false, SelectedUnit.ActionPoints + 1);
					GameManager.Instance.Server_MoveUnitToHex(UnitHex, FoundHex, Connection.Id);
				}
			}
			else
			{
				SelectedHex = FoundHex;
				SelectedHex.OnClick();
			}

			SelectedUnit = null;
		}

		if (Input.Pressed("camera_combat"))
		{
			CameraMode = ECameraMode.Combat;
		}

		if (Input.Pressed("camera_normal"))
		{
			CameraMode = ECameraMode.Normal;
		}

		if (Input.Pressed("camera_build"))
		{
			CameraMode = ECameraMode.Build;
		}
	}

	private void DeselectHex()
	{
		if (!SelectedHex.IsValid())
		{
			return;
		}

		SelectedHex.IsSelected = false;
		SelectedHex = null;
	}

	private Hex GetHexFromMousePos(Vector2 MousePos)
	{
		var ClickRay = Camera.ScreenPixelToRay(MousePos);
		var ClickTraces = Scene.Trace.Ray(ClickRay.Position, ClickRay.Position + ClickRay.Forward * 10000f).RunAll();

		foreach (var ClickTrace in ClickTraces)
		{
			if (!ClickTrace.Hit)
			{
				continue;
			}

			if (ClickTrace.GameObject.GetComponent<Object.Hex>() is { } HitHex)
			{
				return HitHex;
			}
		}

		return null;
	}

	//////////////////////////////////////////////////////////////////////////////

	// TODO : move me
	// & clean this shi up

	public static T SpawnObject<T>(GameObject SpawnGameObject, Transform SpawnTransform, Connection Connection, GameObject Parent = null, bool NetworkSpawn = false) where T : new()
	{
		var SpawnedGameObject = SpawnObject(SpawnGameObject, SpawnTransform, Connection, Parent, NetworkSpawn);

		var SpawnedComponent = SpawnedGameObject.GetComponent<T>();
		Assert.NotNull(SpawnedComponent, $"FAILED TO SPAWN OBJECT {SpawnObject} FOR CONNECTION {Connection}");

		return SpawnedComponent;
	}

	public static GameObject SpawnObject(GameObject SpawnObject, Transform SpawnTransform, Connection Connection, GameObject Parent = null, bool NetworkSpawn = false)
	{
		Assert.NotNull(Connection, $"SPAWN CONNECTION NULL");
		Assert.NotNull(SpawnObject, $"SPAWN OBJECT NULL");

		SpawnObject.NetworkMode = NetworkSpawn ? NetworkMode.Object : NetworkMode.Never;

		CloneConfig SpawningConfig = new(SpawnTransform);
		SpawningConfig.Parent = Parent;

		var SpawnedObject = SpawnObject.Clone(SpawningConfig);
		Assert.NotNull(SpawnedObject, $"FAILED TO SPAWN OBJECT {SpawnObject} FOR CONNECTION {Connection}");

		if (NetworkSpawn)
		{
			SpawnedObject.Network.SetOrphanedMode(NetworkOrphaned.Destroy);
			SpawnedObject.NetworkSpawn(Connection);
			SpawnedObject.NetworkSpawn();
		}

		return SpawnedObject;
	}
}