GameMode/Rules/Payload/PayloadGamemode.cs

Game mode component for a payload (escort) rule. It manages the payload cart and path, handles setup and progression, plays push sound while players capture the cart, advances the cart along path segments, opens/locks spawn doors, broadcasts capture events and provides UI data about segment distances.

NetworkingFile AccessHttp Calls
🐞 SetCTSpawn uses Count < SpawnIndex so SpawnIndex == Count passes the check then indexes CTSpawns[Count], which can occur on the last capture (Index + 1) and throws out of range.
using KOTH.World;
using Sandbox;
using Sandbox.Diagnostics;
using Sandbox.Events;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace KOTH;

public record OnPayloadCapturePointEvent(int PointIndex) : IGameEvent;

public sealed class PayloadGamemode : Component,
	ITeamSpawnTime,
	IGameEventHandler<UpdateStateEvent>,
	IGameEventHandler<EnterStateEvent>
{
	[Property] public GameObject PayloadGameobject { get; set; } = null;
	[Property] public GameObject PayloadPathGameobject { get; set; } = null;

	[Property] public List<GameObject> CTSpawns { get; set; } = null;

	[Property] public GameObject TActiveSpawn { get; set; } = null;
	[Property] public GameObject CTActiveSpawn { get; set; } = null;

	[Property] public SoundEvent PayloadMoveSound { get; set; }
	[Property] public float SetupTime { get; set; } = 30f;

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

		if (PayloadGameobject == null || PayloadPathGameobject == null)
		{
			Log.Warning("tried to start payload gameobject without payload/payloadpath gameobject set");
			Enabled = false;
		}
	}

	public PayloadCart PayloadCartComponent { get => PayloadGameobject.GetComponent<PayloadCart>(); }
	public PayloadPath PayloadPathComponent { get => PayloadPathGameobject.GetComponent<PayloadPath>(); }

	public float TSpawnTime => TActiveSpawn.GetComponent<SpawnZone>().SpawnTime;
	public float CTSpawnTime => CTActiveSpawn.GetComponent<SpawnZone>().SpawnTime;

	public List<SpawnDoor> SpawnDoors => TActiveSpawn.GetComponent<SpawnZone>().SpawnDoors;

	private RealTimeSince TimeSinceStart = 0;
	private bool IsSetupTime = true;
	private bool HasCartFinished = false;
	private int CurrentPathSegmentIndex = 0;
	private FSound PayloadPushSound;

	[Sync] private int CurrentSegmentIndex { get; set; } = 0;
	[Sync] private float TargetTransitionFactor { get; set; } = 0;

	protected override void OnEnabled()
	{
		PayloadPushSound = new FSound(PayloadMoveSound, PayloadCartComponent.WorldPosition, PayloadCartComponent, true);
	}

	void IGameEventHandler<EnterStateEvent>.OnGameEvent(EnterStateEvent eventArgs)
	{
		Assert.IsValid(PayloadCartComponent);
		Assert.IsValid(PayloadPathGameobject);
		Assert.True(CTSpawns.Count > 0);

		var StartLocationRotation = PayloadPathComponent.GetStartPositionRotation();
		PayloadGameobject.WorldPosition = StartLocationRotation.Position;
		PayloadGameobject.WorldRotation = StartLocationRotation.Rotation;

		TimeSinceStart = 0;
		IsSetupTime = true;
		HasCartFinished = false;
		CurrentSegmentIndex = 0;
		TargetTransitionFactor = 0;
		CurrentPathSegmentIndex = 0;

		foreach (var Door in SpawnDoors)
		{
			Door.Open = false;
		}

		SetCTSpawn(0);
	}

	void IGameEventHandler<UpdateStateEvent>.OnGameEvent(UpdateStateEvent eventArgs)
	{
		Assert.True(Networking.IsHost);

		if (IsSetupTime)
		{
			if (TimeSinceStart < SetupTime)
			{
				return;
			}

			IsSetupTime = false;
			foreach (var Door in SpawnDoors)
			{
				Door.Open = true;
			}
		}

		if (HasCartFinished)
		{
			Log.Info("the cart has stopped");
			if (GameObject.GetComponent<StateComponent>() is { } ParentState)
			{
				Assert.IsValid(ParentState.DefaultNextState);
				GameMode.Instance.StateMachine.Transition(ParentState.DefaultNextState);
			}

			return;
		}

		var (IsCapturing, CaptureFactor) = PayloadCartComponent.GetCaptureData();

		if (!IsCapturing)
		{
			if (AudioComponent.Instance.IsPlayingSound(PayloadPushSound))
			{
				AudioComponent.Instance.StopSound(PayloadPushSound);
			}

			return;
		}

		AudioComponent.Instance.PlaySound(PayloadPushSound);

		var AllSegmentLocations = PayloadPathComponent.AllSegmentPoints;
		var CurrentNodePos = AllSegmentLocations[CurrentSegmentIndex];

		var TargetNodePos = AllSegmentLocations[CurrentSegmentIndex + 1];

		var TotalSegmentDistance = CurrentNodePos.Distance(TargetNodePos);
		var FactorMoved = (PayloadCartComponent.BaseSpeed * CaptureFactor) / TotalSegmentDistance;

		TargetTransitionFactor += FactorMoved;
		if (TargetTransitionFactor > 1)
		{
			TargetTransitionFactor = 0;
			++CurrentSegmentIndex;

			var PriorPathsCovered = 0;
			for (int Index = CurrentPathSegmentIndex; Index >= 0; --Index)
			{
				PriorPathsCovered += PayloadPathComponent.PathSegments[Index].SegmentPoints.Count;
			}

			if (CurrentSegmentIndex >= PriorPathsCovered)
			{
				OnCapture(CurrentPathSegmentIndex);
				++CurrentPathSegmentIndex;
			}

			if (AllSegmentLocations.Count == CurrentSegmentIndex + 1)
			{
				// CART STOPPED
				BroadcastOnCapture(-1);
				HasCartFinished = true;
				return;
			}

			PayloadGameobject.WorldRotation = PayloadPathComponent.GetRotationFromNodeIndexes(CurrentSegmentIndex, CurrentSegmentIndex + 1);
			return;
		}

		var LerpedVector = Vector3.Lerp(CurrentNodePos, TargetNodePos, TargetTransitionFactor);
		PayloadGameobject.WorldPosition = LerpedVector;
	}

	private void SetCTSpawn(int SpawnIndex)
	{
		if (CTSpawns.Count < SpawnIndex)
		{
			return;
		}

		foreach (var Spawn in CTSpawns)
		{
			Spawn.Enabled = false;
		}

		CTSpawns[SpawnIndex].Enabled = true;
		CTActiveSpawn = CTSpawns[SpawnIndex].GetComponentInChildren<SpawnZone>().GameObject;
	}

	private void OnCapture(int Index)
	{
		BroadcastOnCapture(Index);

		var SpawnIndex = Index + 1;
		SetCTSpawn(SpawnIndex);

		GameMode.Instance.StateMachine.NextStateTime += 120;
	}

	[Rpc.Broadcast]
	private void BroadcastOnCapture(int Index)
	{
		Game.TakeScreenshot();

		// HACK : shitty screenshot hack TODO : remove
		if (Index == -1)
		{
			return;
		}

		Scene.Dispatch(new OnPayloadCapturePointEvent(Index));
	}

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

	public float GetUIData(out List<(float Distance, float CaptureAmount)> SegmentDistances)
	{
		SegmentDistances = new();

		var AllSegmentLocations = PayloadPathComponent.AllSegmentPoints;
		var TotalSegmentsEvaluated = 0;

		foreach (var Path in PayloadPathComponent.PathSegments)
		{
			var SegmentPointsInPath = Path.SegmentPoints.Count;

			if (CurrentSegmentIndex + 1 >= TotalSegmentsEvaluated + SegmentPointsInPath)
			{
				SegmentDistances.Add((Path.GetDistance(), 1f));
				TotalSegmentsEvaluated += SegmentPointsInPath;
				continue;
			}

			if (TotalSegmentsEvaluated <= CurrentSegmentIndex && TotalSegmentsEvaluated + SegmentPointsInPath > CurrentSegmentIndex)
			{
				var TotalPathDistance = Path.GetDistance();
				var CoveredDistance = 0f;

				var PathIndex = CurrentSegmentIndex - TotalSegmentsEvaluated;
				for (int NodeIndex = 0; NodeIndex < Path.SegmentPoints.Count; ++NodeIndex)
				{
					if (PathIndex == NodeIndex)
					{
						break;
					}

					CoveredDistance += Path.SegmentPoints[NodeIndex].Distance(Path.SegmentPoints[NodeIndex + 1]);
				}

				var CoveredFactor = CoveredDistance / TotalPathDistance;
				CoveredFactor += (TargetTransitionFactor * AllSegmentLocations[CurrentSegmentIndex].Distance(AllSegmentLocations[CurrentSegmentIndex + 1])) / TotalPathDistance;
				
				SegmentDistances.Add((TotalPathDistance, CoveredFactor));

				TotalSegmentsEvaluated += SegmentPointsInPath;
				continue;
			}

			SegmentDistances.Add((Path.GetDistance(), 0));
			TotalSegmentsEvaluated += SegmentPointsInPath;
		}

		return 0;
	}
}