MissionBoard.cs
using Sandbox;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
// One job on the board. Adding a mission later is just adding an entry
// here and pointing it at that mission's scene - no code change.
public sealed class MissionEntry
{
[Property] public string DisplayName { get; set; } = "Untitled Job";
// Short pitch shown under the name. Flavour, or a difficulty hint.
[Property] public string Description { get; set; }
// The scene this job loads. Each mission scene brings its own
// MissionSpawner / MissionTimer / EscapeZone setup with it.
[Property] public SceneFile Scene { get; set; }
// Read out of the mission scene's own EscapeZone rather than typed in
// here, so the board can't advertise a figure the map doesn't actually
// pay. Only read when the board list is rebuilt (on open), so parsing
// the scene JSON each time is cheaper than keeping a cache correct.
public int BaseReward => SceneRewardReader.ReadEscapeReward( Scene );
/// <summary>What this job pays a player who gets out, difficulty included.</summary>
public int AdvertisedPayout => (int)(BaseReward * PayoutMultiplier);
// Difficulty lives on the entry rather than being picked separately,
// so the same scene can be listed more than once at different
// settings - "Gas Station (Standard)" and "Gas Station (Heavy)" are
// just two entries pointing at one map. The crew already votes on an
// entry, so agreeing on difficulty comes free with that.
[Property] public string DifficultyName { get; set; } = "Standard";
/// <summary>Scales what escaping pays out. The reason to take a harder job.</summary>
[Property] public float PayoutMultiplier { get; set; } = 1f;
/// <summary>Below 1 means cops arrive faster. 0.5 is twice the rate.</summary>
[Property] public float SpawnIntervalScale { get; set; } = 1f;
/// <summary>
/// Multiplies how many cops can be alive at once. 1 is the map's own
/// setting, 2 is double. 0 means no cops at all, which is handy for
/// testing the loop without a fight.
/// </summary>
[Property] public float MaxCopsMultiplier { get; set; } = 1f;
/// <summary>Scales cop weapon damage on top of CopBotAI's own DamageScale.</summary>
[Property] public float CopDamageScale { get; set; } = 1f;
}
// Carries the chosen job's difficulty from the hideout into the mission
// scene. A static because scene loads destroy everything in the old scene,
// exactly like PlayerLoadout's save cache.
//
// Set on every client by MissionBoard.Launch, which runs through a
// broadcast - so each machine has the same values before the map loads.
public static class ActiveMission
{
public static string DifficultyName { get; private set; } = "Standard";
public static float PayoutMultiplier { get; private set; } = 1f;
public static float SpawnIntervalScale { get; private set; } = 1f;
public static float MaxCopsMultiplier { get; private set; } = 1f;
public static float CopDamageScale { get; private set; } = 1f;
public static void SetFrom( MissionEntry entry )
{
if ( entry is null )
{
Reset();
return;
}
DifficultyName = entry.DifficultyName;
// Clamped because a zero or negative multiplier anywhere here
// produces nonsense - free money, or a spawn interval of zero that
// floods the map in a single frame.
PayoutMultiplier = MathF.Max( entry.PayoutMultiplier, 0f );
SpawnIntervalScale = MathF.Max( entry.SpawnIntervalScale, 0.05f );
// Floored at 0 rather than 1: zero is a legitimate "no cops"
// setting for testing the loop, and a negative cap would make
// EffectiveMaxCops negative and stop spawning entirely in a way
// that looks like a bug.
MaxCopsMultiplier = MathF.Max( entry.MaxCopsMultiplier, 0f );
CopDamageScale = MathF.Max( entry.CopDamageScale, 0f );
}
public static void Reset()
{
DifficultyName = "Standard";
PayoutMultiplier = 1f;
SpawnIntervalScale = 1f;
MaxCopsMultiplier = 1f;
CopDamageScale = 1f;
}
}
// The job board in the Hideout. Put this on a GameObject with a trigger
// Collider; walk in and hold Use to open it, hold Use again to close.
// Same interaction shape as the Vendor.
public sealed class MissionBoard : Component, Component.ITriggerListener, IInteractable
{
[Property]
public List<MissionEntry> Missions { get; set; } = new();
[Property]
public float HoldDuration { get; set; } = 0.4f;
bool _playerInRange = false;
float _holdProgress = 0f;
bool _wasHolding = false;
public bool IsOpen { get; private set; }
// Ready-up state, so a job can't start while half the crew is still
// shopping. Everyone has to pick the SAME job before it launches.
//
// Host-authoritative: clients send their pick to the host, the host is
// the only one that decides when the crew is go and calls Launch.
// _readyByConnection only ever holds meaningful data on the host.
readonly Dictionary<Guid, int> _readyByConnection = new();
// Mirrored to every client for display. The local player's own choice
// is tracked separately below, since the host doesn't tell each client
// which specific pick was theirs.
public int ReadyCount { get; private set; }
public int CrewSize { get; private set; } = 1;
public int LocalReadyIndex { get; private set; } = -1;
// The job the crew is currently voting on, -1 for none. Drives the
// prompt shown to players who haven't opened the board, so they don't
// have to walk over to it to find out what's been picked.
public int PendingMissionIndex { get; private set; } = -1;
public string ProposedBy { get; private set; }
public MissionEntry PendingMission =>
PendingMissionIndex >= 0 && PendingMissionIndex < Missions.Count
? Missions[PendingMissionIndex]
: null;
public bool LocalPlayerIsReady => LocalReadyIndex >= 0;
// Drives the on-screen prompt - see IInteractable.
public bool PlayerInRange => _playerInRange;
public float HoldFraction => HoldDuration <= 0f ? 0f : _holdProgress / HoldDuration;
public string PromptText => "Hold E to view jobs";
public bool SuppressPrompt => IsOpen;
public void OnTriggerEnter( Collider other )
{
if ( other.GameObject.Root.Tags.Has( "player" ) )
_playerInRange = true;
}
public void OnTriggerExit( Collider other )
{
if ( !other.GameObject.Root.Tags.Has( "player" ) )
return;
_playerInRange = false;
_holdProgress = 0f;
IsOpen = false;
}
protected override void OnUpdate()
{
// Checked before the in-range guard on purpose: the whole point of
// the ready-up prompt is that you can do it from wherever you are in
// the hideout, without walking back to the board.
HandleReadyUpKey();
if ( !_playerInRange )
return;
var holding = Input.Down( InputButtonHelper.Use );
if ( holding )
{
_holdProgress += Time.Delta;
// Toggle once on the hold completing, then wait for release -
// otherwise holding Use would flap the board open and shut
// every HoldDuration seconds.
if ( _holdProgress >= HoldDuration && !_wasHolding )
{
_wasHolding = true;
IsOpen = !IsOpen;
}
}
else
{
_holdProgress = 0f;
_wasHolding = false;
}
}
public void Close()
{
IsOpen = false;
}
// The ReadyUp key readies you for whatever the crew has already
// proposed. Does nothing until somebody picks a job, since there'd be
// nothing to agree to.
void HandleReadyUpKey()
{
if ( PendingMissionIndex < 0 )
return;
if ( !Input.Pressed( "ReadyUp" ) )
return;
ToggleReady( PendingMissionIndex );
}
// Called by MissionBoardMenu when a job is clicked. Toggles this
// player's readiness for it - clicking the job you're already ready
// for backs you out, clicking a different one switches your vote.
public void ToggleReady( int missionIndex )
{
LocalReadyIndex = LocalReadyIndex == missionIndex ? -1 : missionIndex;
SubmitReady( Connection.Local.Id, LocalReadyIndex );
}
// Runs on the host no matter who called it, so one machine owns the
// decision and there's no chance of two clients both launching.
[Rpc.Host]
public void SubmitReady( Guid connectionId, int missionIndex )
{
if ( missionIndex < 0 )
_readyByConnection.Remove( connectionId );
else
_readyByConnection[connectionId] = missionIndex;
EvaluateCrew();
}
// Host only. Drops anyone who has disconnected, tells everyone where
// the crew stands, and launches once they all agree.
void EvaluateCrew()
{
if ( !Networking.IsHost )
return;
var connections = Connection.All;
var connected = connections.Select( c => c.Id ).ToHashSet();
// Someone readied up and then quit - otherwise the crew could
// never be complete and the job would never start.
foreach ( var stale in _readyByConnection.Keys.Where( k => !connected.Contains( k ) ).ToList() )
_readyByConnection.Remove( stale );
var crewSize = Math.Max( connections.Count, 1 );
// The job everyone has to agree on is whichever the most people
// have picked; ties resolve arbitrarily, which is fine because a
// tie can't be unanimous anyway.
var leading = _readyByConnection
.GroupBy( kv => kv.Value )
.OrderByDescending( g => g.Count() )
.FirstOrDefault();
var readyCount = leading?.Count() ?? 0;
var missionIndex = leading?.Key ?? -1;
// Whoever's voting for the leading job gets the credit in the
// prompt, so it reads as a person suggesting something rather than
// a job appearing out of nowhere.
var proposer = connections
.FirstOrDefault( c => _readyByConnection.TryGetValue( c.Id, out var v ) && v == missionIndex );
BroadcastCrewState( readyCount, crewSize, missionIndex, proposer?.DisplayName ?? "Someone" );
if ( readyCount >= crewSize && missionIndex >= 0 )
LaunchBroadcast( missionIndex );
}
[Rpc.Broadcast]
void BroadcastCrewState( int readyCount, int crewSize, int missionIndex, string proposedBy )
{
ReadyCount = readyCount;
CrewSize = crewSize;
PendingMissionIndex = missionIndex;
ProposedBy = proposedBy;
}
// Broadcast so every client changes scene, not just the host. Whether
// that actually carries connected clients across is UNTESTED - see the
// note in Launch.
[Rpc.Broadcast]
void LaunchBroadcast( int missionIndex )
{
if ( missionIndex < 0 || missionIndex >= Missions.Count )
return;
Launch( Missions[missionIndex] );
}
// Everything the mission needs lives in its own scene, so launching is
// just a scene change - the player's cash and loadout survive it
// because PlayerLoadout keeps them in a static store, not on the
// GameObject.
public void Launch( MissionEntry mission )
{
if ( mission?.Scene is null )
{
Log.Warning( $"MissionBoard: '{mission?.DisplayName}' has no Scene assigned." );
return;
}
IsOpen = false;
_readyByConnection.Clear();
LocalReadyIndex = -1;
PendingMissionIndex = -1;
// Has to happen before the scene change, and on every client -
// Launch is called from a broadcast, so each machine records the
// same difficulty before its map loads.
ActiveMission.SetFrom( mission );
var options = new SceneLoadOptions();
options.SetScene( mission.Scene );
options.ShowLoadingScreen = true;
Log.Info( $"Launching job: {mission.DisplayName}" );
Game.ChangeScene( options );
}
}