Component that implements a King-of-the-Hill game objective. It tracks players inside a Zone, manages capture progress, ownership, per-team remaining win timers, broadcasts events (capture, capping, decay, win) and updates visual tint of child models.
using KOTH.UI;
using Sandbox;
using Sandbox.Events;
namespace KOTH;
public sealed class Hill : Component, Component.ITriggerListener,
IGameEventHandler<ResetScoresEvent>
{
[RequireComponent] public Zone Zone { get; private set; }
[Property] public float CaptureTime { get; set; } = 5f;
[Property] public float WinTime { get; set; } = 10f;
[Sync(SyncFlags.FromHost)] public bool IsActive { get; private set; } = false;
[Sync(SyncFlags.FromHost)] public Team OwningTeam { get; private set; } = Team.Unassigned;
[Sync(SyncFlags.FromHost)] private NetDictionary<Team, float> TeamsTimer { get; set; } = new();
[Sync(SyncFlags.FromHost)] private NetList<PlayerPawn> CurrentHillPlayers { get; set; } = new();
////////////////////////////////////////////////////////////////////////
protected override void OnValidate()
{
base.OnValidate();
Zone.Color = Color.Yellow;
}
protected override void OnStart()
{
base.OnStart();
IsActive = true;
HillCaptureTime = 0;
using (Rpc.FilterInclude(Connection.Host))
{
ResetHill();
}
}
public float GetTeamsTimeLeft(Team Team)
{
return TeamsTimer[Team];
}
[Rpc.Broadcast( NetFlags.HostOnly )]
public void SetHillActive(bool Active)
{
IsActive = Active;
ResetHill();
}
[Rpc.Broadcast( NetFlags.HostOnly )]
public void ResetHill()
{
TeamsTimer = new()
{
{ Team.CounterTerrorist, WinTime },
{ Team.Terrorist, WinTime },
};
OwningTeam = Team.Unassigned;
IsFirstPassOfRound = true;
SetChildGeometryColour(Color.White);
}
[Sync(SyncFlags.FromHost)] public RealTimeSince HillCaptureTime { get; set; } = new();
bool IsCapping = false;
bool IsFirstPassOfRound = true;
protected override void OnUpdate()
{
if (!Networking.IsHost || !IsActive)
{
return;
}
if (IsFirstPassOfRound)
{
HillCaptureTime = 0;
IsFirstPassOfRound = false;
}
if (CurrentHillPlayers.Count == 0)
{
if (HillCaptureTime > 0)
{
BroadcastHillDecay();
HillCaptureTime = Math.Max(0, HillCaptureTime - (Time.Delta * 2f));
}
IsCapping = false;
}
bool IsContested = false;
List<Team> TeamsOnHill = new();
// evil hack to remove hill players
foreach (var PlayerPawn in CurrentHillPlayers.Reverse())
{
if (!PlayerPawn.IsValid())
{
CurrentHillPlayers.Remove(PlayerPawn);
continue;
}
var PlayerTeam = TeamExtensions.GetTeam(PlayerPawn.GameObject);
if (!TeamsOnHill.Contains(PlayerTeam))
{
TeamsOnHill.Add(PlayerTeam);
}
}
if (TeamsOnHill.Count == 1)
{
var CurrentTeamOnHill = TeamsOnHill[0];
if (CurrentTeamOnHill != OwningTeam)
{
// start capping
if (!IsCapping)
{
// CaptureTimeSince = 0;
IsCapping = true;
}
// cap
if (HillCaptureTime >= CaptureTime)
{
OwningTeam = CurrentTeamOnHill;
HillCaptureTime = 0;
IsCapping = false;
BroadcastHillCapture();
}
BroadcastHillCapping(CurrentTeamOnHill);
}
}
else if (TeamsOnHill.Count == 2)
{
IsContested = true;
}
// if someone owns the hill countdown
if (OwningTeam != Team.Unassigned)
{
if (TeamsTimer[OwningTeam] <= 0)
{
if (!IsContested)
{
BroadcastHillWin();
ResetHill();
}
}
else
{
TeamsTimer[OwningTeam] -= Time.Delta;
}
}
}
private void SetChildGeometryColour(Color Colour)
{
foreach (var Child in GameObject.Root.Children)
{
var ChildModel = Child.Components.Get<ModelRenderer>();
if (ChildModel.IsValid())
{
ChildModel.Tint = Colour;
}
}
}
[Rpc.Broadcast( NetFlags.HostOnly )]
public void BroadcastHillDecay()
{
Scene.Dispatch(new HillDecayCapEvent(HillCaptureTime / CaptureTime, this)); ;
}
[Rpc.Broadcast(NetFlags.HostOnly)]
public void BroadcastHillCapping(Team Team)
{
Scene.Dispatch(new HillCappingEvent(Team, HillCaptureTime / CaptureTime, this)); ;
}
[Rpc.Broadcast(NetFlags.HostOnly)]
public void BroadcastHillCapture()
{
Scene.Dispatch(new HillCapturedEvent(CurrentHillPlayers.ToList(), OwningTeam, this));
SetChildGeometryColour(TeamExtensions.GetColor(OwningTeam, true));
}
[Rpc.Broadcast(NetFlags.HostOnly)]
public void BroadcastHillWin()
{
Scene.Dispatch(new HillWinEvent(OwningTeam, this));
}
void ITriggerListener.OnTriggerEnter(Collider Collider)
{
var PlayerPawn = Collider.GameObject.Root.Components.Get<PlayerPawn>();
if (PlayerPawn.IsValid())
{
CurrentHillPlayers.Add(PlayerPawn);
}
}
void ITriggerListener.OnTriggerExit(Collider Collider)
{
var PlayerPawn = Collider.GameObject.Root.Components.Get<PlayerPawn>();
if (PlayerPawn.IsValid())
{
CurrentHillPlayers.Remove(PlayerPawn);
}
}
public void OnGameEvent(ResetScoresEvent eventArgs)
{
// TODO : is this just a verbosity thing?
using (Rpc.FilterInclude(Connection.Host))
{
ResetHill();
}
}
}