MoneyTable.cs
using Sandbox;
using SWB.Player;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
// A table of cash stacks the crew has to bag before the escape opens.
//
// Put this on the ROOT of MoneyTable.prefab. It finds its own stacks (every
// child whose name starts with StackNamePrefix), so adding or removing
// stacks in the prefab needs no rewiring - the count is whatever's there.
//
// Stand next to it and hold E: stacks vanish one at a time, top of the pile
// first, until the table is bare. Nothing happens until MissionTimer calls
// Unlock() (when the vault gate opens), so it can't be looted early by
// reaching through the bars.
//
// NETWORKING. Same shape as MissionBoard's ready-up:
// - Holding E is detected on whichever machine the player is on, and
// each stack is a REQUEST to the host (RequestBagStack, [Rpc.Host]).
// - The host is the only one that decides - so two players bagging the
// same table at once can't both take "stack 64" - and then tells
// everyone the new count (BroadcastBagged, [Rpc.Broadcast]).
// - Every machine hides stacks from that count, so they all agree on
// what's left on the table.
// Co-op is still untested for this game as a whole; this follows the
// pattern the job board already uses rather than inventing a new one.
//
// Range is a plain distance check rather than a trigger collider. Trigger
// enter/exit fires per COLLIDER, and a player is made of several (body,
// hitboxes), so walking out of range could flip "in range" off while you
// were still standing there. Distance to the local player has no such
// edge cases, and means nothing extra has to be added to the prefab.
public sealed class MoneyTable : Component, IInteractable
{
// Children whose name starts with this are treated as cash. The prefab's
// are "money_stack", "money_stack (1)" ... "money_stack (63)".
[Property]
public string StackNamePrefix { get; set; } = "money_stack";
// How close you have to be, measured flat (ignoring height) from this
// object's origin. The prefab's origin sits in the middle of the pile.
[Property]
public float InteractRange { get; set; } = 90f;
// Hold time per stack. 0.1 is ~6.5 seconds for a 64-stack table: long
// enough that bagging under fire is a real decision, short enough that
// it doesn't feel like a chore.
[Property]
public float SecondsPerStack { get; set; } = 0.1f;
/// <summary>Stacks bagged so far. Owned by the host, mirrored to everyone.</summary>
public int StacksBagged { get; private set; }
/// <summary>How many stacks this table started with.</summary>
public int StackCount => _stacks.Count;
public bool IsEmpty => StacksBagged >= StackCount;
/// <summary>True once MissionTimer has opened the vault.</summary>
public bool IsUnlocked { get; private set; }
// Sorted top of the pile first, so the table empties the way a person
// would grab it rather than cash vanishing from the bottom of the stack.
List<GameObject> _stacks = new();
// Local-player hold state. Never networked - only ever about whoever is
// sitting at this machine.
bool _playerInRange;
float _holdTimer;
ObjectiveMarker _marker;
protected override void OnStart()
{
_stacks = GameObject.Children
.Where( c => c.Name.StartsWith( StackNamePrefix ) )
.OrderByDescending( c => c.WorldPosition.z )
.ToList();
if ( _stacks.Count == 0 )
Log.Warning( $"MoneyTable '{GameObject.Name}': no children named '{StackNamePrefix}...' - nothing to bag. " +
"Is this on the prefab root?" );
// Waypoint for the table. Created here rather than added to the
// prefab by hand, so there's one less thing to wire - but if the
// prefab already has one (someone wanted a custom label), that one
// is used instead.
_marker = Components.GetOrCreate<ObjectiveMarker>();
if ( _marker.Label == "Objective" )
{
_marker.Label = "BAG THE CASH";
_marker.Color = new Color( 0.49f, 0.85f, 0.34f );
_marker.Offset = Vector3.Up * 32f;
}
UpdateMarker();
}
// Called by MissionTimer when the vault gate opens.
public void Unlock()
{
IsUnlocked = true;
UpdateMarker();
}
protected override void OnUpdate()
{
_playerInRange = LocalPlayerCanReach();
if ( !_playerInRange || !IsUnlocked || IsEmpty )
{
_holdTimer = 0f;
return;
}
if ( !Input.Down( InputButtonHelper.Use ) )
{
_holdTimer = 0f;
return;
}
_holdTimer += Time.Delta;
// A loop rather than an if, so a long frame doesn't lose stacks -
// at 0.1s a stack, one 250ms hitch is two stacks, not one.
var step = MathF.Max( SecondsPerStack, 0.01f );
while ( _holdTimer >= step )
{
_holdTimer -= step;
RequestBagStack();
}
}
// Alive, still in the job, and close enough.
bool LocalPlayerCanReach()
{
var player = PlayerBase.Local;
if ( player is null || !player.IsAlive )
return false;
// Out of the run (dead and spectating, or already extracted) - you
// don't get to keep looting from the escape van.
if ( player.Components.Get<MissionPlayerState>()?.IsOut ?? false )
return false;
var offset = player.WorldPosition - WorldPosition;
offset.z = 0f;
return offset.Length <= InteractRange;
}
// Runs on the host whoever called it. The host checks and counts, so
// simultaneous grabs from two players can never over-count.
[Rpc.Host]
void RequestBagStack()
{
if ( !IsUnlocked || IsEmpty )
return;
BroadcastBagged( StacksBagged + 1 );
}
// Everyone - host included - applies the host's count.
[Rpc.Broadcast]
void BroadcastBagged( int bagged )
{
StacksBagged = Math.Clamp( bagged, 0, StackCount );
// Hide from the top down. Disabled rather than destroyed: it's
// cheaper, and a disabled object can't end up half-destroyed on one
// machine and alive on another.
for ( var i = 0; i < _stacks.Count; i++ )
{
if ( _stacks[i].IsValid() )
_stacks[i].Enabled = i >= StacksBagged;
}
if ( IsEmpty )
Log.Info( $"MoneyTable '{GameObject.Name}' cleared." );
UpdateMarker();
}
// Marker only while there's something to do here.
void UpdateMarker()
{
if ( _marker.IsValid() )
_marker.Enabled = IsUnlocked && !IsEmpty;
}
// --- IInteractable ----------------------------------------------------
public bool PlayerInRange => _playerInRange && IsUnlocked;
// The bar shows how much of the TABLE is bagged rather than the hold on
// one stack - one stack is a tenth of a second, so a per-stack bar would
// just flicker. This way the bar is a real progress readout.
public float HoldFraction => StackCount == 0 ? 0f : (float)StacksBagged / StackCount;
public string PromptText => $"Hold E to bag the cash — {StacksBagged}/{StackCount}";
public bool SuppressPrompt => IsEmpty;
}