Component attached to a player car that tracks an in-game score combo system. It accumulates combo points from sources like drifting and laps, groups repeated sources into entries, times out combos after inactivity, records stats via GameStats, and exposes RPCs for adding score and recording hits.
using Machines.GameModes;
using Machines.Systems;
namespace Machines.Player;
/// <summary>
/// Score combo system for doing things in-game
/// </summary>
public sealed class CarScore : Component
{
[RequireComponent]
public Car Car { get; private set; }
/// <summary>
/// Seconds without a gain before the combo lapses (and the HUD disappears).
/// </summary>
public const float ComboTimeout = 2f;
// Points awarded per source.
private const int LapPoints = 100;
/// <summary>
/// A single grouped source line, e.g. "Drifting +47".
/// </summary>
public sealed class ScoreEntry
{
public string Source;
public int Amount;
public float LastTime;
}
/// <summary>
/// Score accumulated in the current combo. Resets to 0 when the combo lapses.
/// </summary>
public int ComboScore { get; private set; }
/// <summary>
/// Time since the most recent gain; drives the combo lapse and the HUD fade. Starts lapsed so
/// nothing shows before the first gain.
/// </summary>
public TimeSince SinceLastGain { get; private set; } = ComboTimeout;
/// <summary>
/// True while a combo is running (a gain happened within <see cref="ComboTimeout"/>).
/// </summary>
public bool IsComboActive => SinceLastGain < ComboTimeout;
/// <summary>
/// Source lines for the current combo, in arrival order.
/// </summary>
public List<ScoreEntry> Entries { get; } = new();
private int _lastLap = -1;
private bool _comboWasActive;
/// <summary>
/// Award points to the combo, grouping repeated gains into one line per source. Starts a fresh
/// combo if the previous one has already lapsed.
/// </summary>
public void Add( string source, int amount )
{
if ( amount == 0 )
return;
if ( !IsComboActive )
{
ComboScore = 0;
Entries.Clear();
}
ComboScore += amount;
SinceLastGain = 0;
var entry = Entries.FirstOrDefault( e => e.Source == source );
if ( entry is null )
{
entry = new ScoreEntry { Source = source };
Entries.Add( entry );
}
entry.Amount += amount;
entry.LastTime = Time.Now;
}
/// <summary>
/// Award points from another machine (e.g. a projectile crediting its firer). Runs on the
/// attacker car's owner, so it lands on the right player; also runs locally when offline.
/// </summary>
[Rpc.Owner]
public void RpcAdd( string source, int amount ) => Add( source, amount );
/// <summary>
/// Record a weapon hit on another car as a stat. Runs on the attacker's owner like RpcAdd.
/// </summary>
[Rpc.Owner]
public void RpcRecordHit( string statName ) => GameStats.Increment( statName, 1, car: Car );
protected override void OnFixedUpdate()
{
if ( !Car.IsValid() || !Car.IsLocalPlayer )
return;
if ( Car.Drift.IsValid() && Car.Drift.IsDrifting )
Add( "Drifting", 1 );
TrackLaps();
var active = IsComboActive;
if ( _comboWasActive && !active )
EndCombo();
_comboWasActive = active;
}
private void EndCombo()
{
if ( ComboScore > 0 )
GameStats.SetValue( "best-combo", ComboScore, car: Car );
ComboScore = 0;
Entries.Clear();
}
protected override void OnDisabled()
{
// Capture a combo still running when the car is torn down (race end / scene change).
if ( Car.IsValid() && Car.IsLocalPlayer && IsComboActive )
EndCombo();
// Lapse the timer so re-enabling (e.g. respawn) doesn't resurrect a cleared combo.
SinceLastGain = ComboTimeout;
_comboWasActive = false;
}
private void TrackLaps()
{
if ( BaseGameMode.Current is not RaceMode race )
return;
var lap = race.GetPlayerState( Car.Slot ).CurrentLap;
if ( _lastLap < 0 )
{
_lastLap = lap; // Baseline; don't award on first sample.
return;
}
if ( lap > _lastLap )
Add( "Lap", LapPoints );
_lastLap = lap;
}
}