Park/ParkManager.cs
using HC3;
using HC3.Inventory;
using HC3.Terraforming;
using HC3.Terrain;
using Sandbox.Diagnostics;
using System;
/// <summary>
/// A collection of park related events.
/// </summary>
public interface IParkEvents : ISceneEvent<IParkEvents>
{
/// <summary>
/// Called when an AI agent enters the park -- this could be a Guest, a Staff member.
/// </summary>
/// <param name="agent"></param>
public void OnAgentEnter( Agent agent ) { }
/// <summary>
/// Called when an AI agent leaves the park -- this could be a Guest, a Staff member.
/// </summary>
/// <param name="agent"></param>
public void OnAgentExit( Agent agent ) { }
/// <summary>
/// Called when the park opens.
/// </summary>
public void OnParkOpened() { }
/// <summary>
/// Called when the park closes.
/// </summary>
public void OnParkClosed() { }
/// <summary>
/// Called when the park's name changes.
/// </summary>
/// <param name="name"></param>
public void OnNameChanged( string name ) { }
/// <summary>
/// Called whne the park's rating changes. Max is 5.
/// </summary>
/// <param name="rating"></param>
void OnParkRatingChanged( float rating ) { }
}
/// <summary>
/// A manager class for the park itself. Handles admission, money, rating, and other park-wide systems.
/// </summary>
public sealed partial class ParkManager : Component, ITimeOfDayEvents
{
/// <summary>
/// Singleton instance of <see cref="ParkManager"/>
/// </summary>
public static ParkManager Instance { get; private set; }
/// <summary>
/// The name of the park
/// </summary>
[Sync( SyncFlags.FromHost )]
public string Name
{
get => _name;
set
{
_name = value;
IParkEvents.Post( x => x.OnNameChanged( _name ) );
}
}
/// <summary>
/// Whether or not the park is open
/// </summary>
[Sync( SyncFlags.FromHost )]
public OpenState OpenState { get; set; } = OpenState.Open;
/// <summary>
/// The guest's price of admission to the park
/// </summary>
[Sync( SyncFlags.FromHost )]
public int AdmissionFee { get; set; } = 50;
/// <summary>
/// How much money the player has to spend on their park
/// </summary>
[Sync( SyncFlags.FromHost )]
public int Money { get; private set; } = 100_000;
/// <summary>
/// The current rating of the park, from 0 to 5
/// </summary>
[Sync( SyncFlags.FromHost )]
public float Rating { get; private set; }
/// <summary>
/// How much have we earnt today?
/// </summary>
[Sync( SyncFlags.FromHost )]
public float DailyIncome { get; set; } = 0f;
/// <summary>
/// The cost of keeping the park running each day. Automatically deducted from the player's money at the start of each day.
/// </summary>
public int DailyOperationalCosts
{
get
{
var cost = 0;
foreach ( var building in Scene.GetAllComponents<Building>() )
{
cost += (int)MathF.Floor( building.Cost / 5f );
}
foreach ( var path in Scene.GetAllComponents<Path>() )
{
cost += 2;
}
cost += GuestManager.Instance.GetGuestTarget() * 5;
return cost;
}
}
RealTimeSince _timeSinceLastRatingUpdate = 0;
bool _wasParkOpen;
string _name = "My Park";
/// <summary>
/// A debug command to give money to the player.
/// </summary>
/// <param name="amount"></param>
[ConCmd( "hc3.debug.addmoney", ConVarFlags.Cheat )]
public static void AddMoney( int amount )
{
Assert.True( Networking.IsHost );
if ( amount <= 0 ) return;
ParkManager.Instance.GiveMoney( amount, "Debug" );
}
protected override void OnAwake()
{
Instance = this;
}
/// <summary>
/// Was the park open last frame?
/// </summary>
/// <summary>
/// Is the park open? This takes into account its open state and the time of day.
/// </summary>
public bool IsOpen()
{
return OpenState == OpenState.Open;
}
protected override void OnFixedUpdate()
{
if ( _wasParkOpen && !IsOpen() )
{
_wasParkOpen = false;
IParkEvents.Post( x => x.OnParkClosed() );
}
else if ( !_wasParkOpen && IsOpen() )
{
_wasParkOpen = true;
IParkEvents.Post( x => x.OnParkOpened() );
}
// Only the host should handle this logic.
if ( !Networking.IsHost )
return;
if ( _timeSinceLastRatingUpdate > 4f )
{
_timeSinceLastRatingUpdate = 0f;
UpdateParkRating();
}
}
/// <summary>
/// Destroy a park object. This will be called on the host.
/// </summary>
/// <param name="target"></param>
[Rpc.Host]
public void DestroyObject( Component target )
{
if ( !target.IsValid() ) return;
var pos = target.WorldPosition;
var dust = GameObject.Clone( "prefabs/particles/place_dustcloud.prefab", new Transform( pos + Vector3.Up * 8 ) );
Sound.Play( "sounds/gameplay/building_placed.sound", pos );
if ( target is Building building )
{
building.GameObject.Destroy();
}
else if ( target is TerrainScenery scenery )
{
TakeMoney( scenery.DestructionCost, "Landscaping" );
scenery.GameObject.Destroy();
return;
}
else if ( target is PathFurniture furniture )
{
furniture.GameObject.Destroy();
}
else if ( target is Decoration decoration )
{
decoration.GameObject.Destroy();
}
}
/// <summary>
/// Places a decoration in the park. I don't think this belongs in ParkManager. Why does it have so many fucking arguments.
/// </summary>
/// <param name="resourcePath"></param>
/// <param name="position"></param>
/// <param name="angle"></param>
/// <param name="pitch"></param>
/// <param name="height"></param>
/// <param name="tint"></param>
/// <param name="red"></param>
/// <param name="green"></param>
/// <param name="blue"></param>
/// <param name="alpha"></param>
[Rpc.Host]
public void PlaceDecoration( string resourcePath, Vector3 position, int angle, int pitch, int height, Color tint, Color red, Color green, Color blue, Color alpha )
{
if ( string.IsNullOrEmpty( resourcePath ) )
return;
var prefab = GameObject.GetPrefab( resourcePath );
var resource = prefab.GetComponentInChildren<Decoration>();
if ( !TakeMoney( resource.Cost, "Decorations" ) ) return;
var realDecoration = prefab.Clone( position, new Angles( pitch, angle, 0f ) ).GetComponent<Decoration>();
realDecoration.Tags.Add( "decoration" );
realDecoration.Tags.Add( "selectable" );
realDecoration.RedChannel = new() { Color = red, Enabled = true };
realDecoration.GreenChannel = new() { Color = green, Enabled = true };
realDecoration.BlueChannel = new() { Color = blue, Enabled = true };
realDecoration.AlphaChannel = new() { Color = alpha, Enabled = true };
var tintComponent = realDecoration.GetTintComponent();
if ( tintComponent.IsValid() )
{
tintComponent.PrimaryColor = red;
tintComponent.SecondaryColor = green;
tintComponent.AccentColor = blue;
tintComponent.DetailColor = alpha;
}
MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );
Sound.Play( GetPlaceSound( realDecoration.ObjectMaterial ) );
realDecoration.IsPlaced = true;
realDecoration.GameObject.NetworkSpawn();
}
/// <summary>
/// Places a furniture object at the specified grid position and edge within the park.
/// Same as <see cref="PlaceDecoration(string, Vector3, int, int, int, Color, Color, Color, Color, Color)"/> -- why is it on this class?
/// </summary>
/// <param name="resourcePath"></param>
/// <param name="gridPos"></param>
/// <param name="edge"></param>
[Rpc.Host]
public void PlaceFurniture( string resourcePath, Vector3Int gridPos, TileEdge edge )
{
if ( string.IsNullOrEmpty( resourcePath ) )
return;
var prefab = GameObject.GetPrefab( resourcePath );
var resource = prefab.GetComponentInChildren<PathFurniture>();
if ( !TakeMoney( resource.Cost, "Furniture" ) ) return;
var cell = GridManager.Instance.GetCell( new Vector2Int( gridPos ) );
var path = cell?.GetComponent<Path>( gridPos.z );
if ( path is null )
{
Log.Warning( $"Tried to place furniture {resourcePath} on a path that doesn't exist at {gridPos}." );
return;
}
var direction = edge.GetDirection();
var gameObject = prefab.Clone(
path.WorldPosition + new Vector3( direction * GridManager.CentreOffset * PathFurniture.GetPathOffset( resource.FurnitureType ) ),
Rotation.FromYaw( MathF.Atan2( direction.y, direction.x ).RadianToDegree() ) );
gameObject.SetParent( path.GameObject );
var instance = gameObject.GetComponent<PathFurniture>();
instance.IsPlaced = true;
instance.TileEdge = edge;
instance.Tags.Add( "furniture" );
instance.Tags.Add( "selectable" );
MoneyEffect.Broadcast( gameObject.WorldPosition + Vector3.Up * 32, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );
Sound.Play( GetPlaceSound( ObjectMaterial.Default ) );
gameObject.NetworkSpawn();
}
/// <summary>
/// Places a placeable object in the park. Again, why is this on ParkManager?
/// </summary>
/// <param name="resourcePath"></param>
/// <param name="position"></param>
/// <param name="angle"></param>
/// <param name="height"></param>
[Rpc.Host]
public void PlacePlaceable( string resourcePath, Vector3 position, int angle, int height )
{
if ( string.IsNullOrEmpty( resourcePath ) )
return;
var prefab = GameObject.GetPrefab( resourcePath );
var resource = prefab.GetComponentInChildren<IPlacementObject>();
if ( !resource.IsValid() )
return;
if ( !TakeMoney( resource.Cost, "Objects" ) ) return;
var real = resource.GameObject.Clone( position, new Angles( 0f, angle, 0f ) ).GetComponent<IPlacementObject>();
real.IsPlaced = true;
real.GameObject.Tags.Add( "building" );
real.GameObject.Tags.Add( "selectable" );
MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );
// TODO: IPlacementObject could define this
Sound.Play( "place_object_default_1" );
DoPlacementEffect( position + Vector3.Up * 8f, 32f );
real.GameObject.NetworkSpawn();
}
/// <summary>
/// Places a building in the park. Why is this on ParkManager?
/// </summary>
/// <param name="resourcePath"></param>
/// <param name="position"></param>
/// <param name="angle"></param>
/// <param name="height"></param>
[Rpc.Host]
public void PlaceBuilding( string resourcePath, Vector3 position, int angle, int height )
{
if ( string.IsNullOrEmpty( resourcePath ) )
return;
var prefab = GameObject.GetPrefab( resourcePath );
var resource = prefab.GetComponentInChildren<Building>();
//if ( !CanPlace() ) return;
if ( !TakeMoney( resource.Cost, (resource is BasicRide) ? "Rides" : "Buildings" ) ) return;
var gridManager = GridManager.Instance;
if ( !gridManager.IsValid() ) return;
var realBuilding = resource.GameObject.Clone( position, new Angles( 0f, angle, 0f ) ).GetComponent<Building>();
var range = realBuilding.GetTileRange( angle );
for ( var x = range.Left; x < range.Right; x++ )
for ( var y = range.Top; y < range.Bottom; y++ )
{
var index = new Vector2Int( x, y );
gridManager.Terrain[index] = TerrainTile.LevelGround( height );
}
realBuilding.IsPlaced = true;
realBuilding.Tags.Add( "building" );
realBuilding.Tags.Add( "selectable" );
var tintComponent = realBuilding.GetTintComponent();
if ( tintComponent.IsValid() )
{
realBuilding.RedChannel = new TintChannelData { Color = tintComponent.PrimaryColor, Enabled = tintComponent.PrimaryEnabled };
realBuilding.GreenChannel = new TintChannelData { Color = tintComponent.SecondaryColor, Enabled = tintComponent.SecondaryEnabled };
realBuilding.BlueChannel = new TintChannelData { Color = tintComponent.AccentColor, Enabled = tintComponent.AccentEnabled };
realBuilding.AlphaChannel = new TintChannelData { Color = tintComponent.DetailColor, Enabled = tintComponent.DetailEnabled };
}
MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );
Sound.Play( GetPlaceSound( realBuilding.ObjectMaterial ) );
DoPlacementEffect( position + Vector3.Up * 8f, 32f * Math.Max( realBuilding.Size.x, realBuilding.Size.y ) );
realBuilding.GameObject.NetworkSpawn();
using ( Rpc.FilterInclude( Rpc.Caller ) )
{
PostPlaceBuilding( realBuilding );
}
}
/// <summary>
/// Called after a building has been placed in the park.
/// </summary>
/// <param name="building"></param>
[Rpc.Broadcast]
private void PostPlaceBuilding( Building building )
{
BuildingPlacer.Instance?.OnBuildingPlaced( building );
}
/// <summary>
/// Called after something has been placed in the park to show a dust effect.
/// </summary>
/// <param name="position"></param>
/// <param name="size"></param>
[Rpc.Broadcast]
private void DoPlacementEffect( Vector3 position, float size )
{
var dust = GameObject.Clone( "prefabs/particles/place_dustcloud.prefab", new( position ), null, false );
var ringEmitter = dust.GetComponent<ParticleRingEmitter>( true );
ringEmitter.Radius = size;
dust.Enabled = true;
}
/// <summary>
/// Terraforms a section of terrain in the park. One would say this doesn't make sense to be on ParkManager.
/// </summary>
/// <param name="range"></param>
/// <param name="tiles"></param>
[Rpc.Host]
public void Terraform( RectInt range, TerrainTile[] tiles )
{
if ( GridManager.Instance.Terrain is not { } terrain ) return;
if ( Terraformer.Instance is not { UnitCost: var unitCost } ) return;
var slice = new TileArraySlice( tiles, range.Size );
var (heightDiff, paintDiff) = terrain.GetTotalDifference( range, slice );
if ( heightDiff == 0 && paintDiff == 0 ) return;
var totalCost = unitCost * (heightDiff + paintDiff);
if ( !TakeMoney( totalCost, "Landscaping" ) ) return;
var position = terrain.GridToWorld( new Vector3( range.Center, slice.GetHeightRange().Max ) );
MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{totalCost:N0}", Color.Red );
// TODO: calculate cost, confirm permission etc
PostTerraform( range, tiles );
}
/// <summary>
/// Called via <see cref="Terraform"/> from the host to all clients to update their terrain. Shouldn't exist here.
/// </summary>
/// <param name="range"></param>
/// <param name="tiles"></param>
[Rpc.Broadcast]
private void PostTerraform( RectInt range, TerrainTile[] tiles )
{
if ( GridManager.Instance.Terrain is not { } terrain ) return;
terrain.SetTiles( range, new TileArraySlice( tiles, range.Size ) );
}
public void GiveMoney( int amount, string reason = "Other" )
{
Assert.True( Networking.IsHost );
Stats.Increment( "money_earned", amount );
Stats.Increment( $"money_earned.{reason.ToIdentifier()}", amount );
Money += amount;
// TODO: maybe worth having events for this instead of polluting this class
DailyIncome += amount;
if ( Stats.Get( "park.highest_money" ) < Money )
{
Stats.Set( "park.highest_money", Money );
}
}
/// <summary>
/// Takes money from the player with a reason -- lets us track what they're spending on, and add stats.
/// </summary>
/// <param name="amount"></param>
/// <param name="reason"></param>
/// <returns></returns>
public bool TakeMoney( int amount, string reason = "Other" )
{
Assert.True( Networking.IsHost );
if ( Money < amount ) return false;
Stats.Increment( "money_spent", amount );
Stats.Increment( $"money_spent.{reason.ToIdentifier()}", amount );
Money -= amount;
return true;
}
/// <summary>
/// Calculates a park rating based on various factors. We should add an interface here so systems can hook in.
/// </summary>
/// <param name="guests"></param>
/// <param name="trashPileCount"></param>
/// <param name="totalRides"></param>
/// <param name="brokenRides"></param>
/// <returns></returns>
private float CalculateParkRating( IEnumerable<Guest> guests, int trashPileCount, int totalRides, int brokenRides )
{
// TODO: take into account overcrowding.
if ( totalRides == 0 )
return 1f; // Minimum score if no data
var averageHappiness = GetSmoothedHappiness();
var maxTrashBeforeFilthy = 50f;
var cleanliness = (1f - trashPileCount / maxTrashBeforeFilthy).Clamp( 0f, 1f );
var rideAvailability = (1f - ((float)brokenRides / totalRides)).Clamp( 0f, 1f );
var weightedScore =
averageHappiness * 0.6f +
cleanliness * 0.2f +
rideAvailability * 0.2f;
return MathX.Lerp( 1f, 5f, weightedScore );
}
private readonly Queue<float> recentHappinessSamples = new();
private readonly int maxHappinessSamples = 50;
/// <summary>
/// Updates the recent happiness samples used for smoothing out park rating changes.
/// </summary>
/// <param name="currentGuests"></param>
private void UpdateParkHappinessSample( IEnumerable<Guest> currentGuests )
{
if ( !currentGuests.Any() ) return;
var averageNow = currentGuests.Average( g => g.Needs.Happiness );
recentHappinessSamples.Enqueue( averageNow );
if ( recentHappinessSamples.Count > maxHappinessSamples )
recentHappinessSamples.Dequeue();
}
/// <summary>
/// Returns a smoothed average of recent happiness samples.
/// </summary>
/// <returns></returns>
private float GetSmoothedHappiness()
{
return recentHappinessSamples.Count == 0 ? 0f : recentHappinessSamples.Average();
}
/// <summary>
/// Updates the park rating based on various factors. See <see cref="CalculateParkRating"/>.
/// </summary>
private void UpdateParkRating()
{
var allGuests = Scene.GetAllComponents<Guest>();
var trashPileCount = Scene.GetAllComponents<TrashPile>().Count();
var allRides = Scene.GetAllComponents<BasicRide>();
var brokenRides = allRides.Count( x => x.IsBroken );
var timeOfDay = DayNightController.Instance.TimeOfDay;
// If the park is open, and it's between 10am and 9pm, sample average guest happiness.
if ( IsOpen() && timeOfDay is > 10f and < 21f )
{
UpdateParkHappinessSample( allGuests );
}
Rating = CalculateParkRating( allGuests, trashPileCount, allRides.Count(), brokenRides );
IParkEvents.Post( x => x.OnParkRatingChanged( Rating ) );
if ( Stats.Get( "park.highest_rating" ) < Rating )
{
Stats.Set( "park.highest_rating", Rating );
}
}
/// <summary>
/// Returns a global happiness level for all guests in the park.
/// </summary>
/// <returns></returns>
public float GetGlobalHappinessLevel()
{
var allGuests = Scene.GetAllComponents<Guest>();
if ( !allGuests.Any() ) return 0f;
float totalHappiness = 0f;
foreach ( var guest in allGuests )
{
var needs = guest.GetComponent<NeedSystem>();
totalHappiness += needs.Happiness;
}
return totalHappiness / allGuests.Count();
}
/// <summary>
/// Called when a new day starts -- deduct daily costs, pay staff wages, reset daily income.
/// </summary>
void ITimeOfDayEvents.OnNewDay()
{
if ( Stats.Get( "park.max_daily_income" ) < DailyIncome )
{
Stats.Set( "park.max_daily_income", DailyIncome );
}
DailyIncome = 0;
var costs = DailyOperationalCosts;
var wages = 0;
foreach ( var staff in StaffManager.Instance.StaffList )
{
wages += staff.Definition.Wages;
}
TakeMoney( costs, "Daily Operational Cost" );
TakeMoney( wages, "Staff Wages" );
costs += wages;
NotificationPanel.Instance?.Broadcast( $"You have been charged {GameUtils.Currency}{costs} for daily operational costs.", "paid" );
}
}
/// <summary>
/// Defines an object's material for placement sound effects. This shouldn't exist really.
/// </summary>
public enum ObjectMaterial
{
Default,
Wood,
Metal,
Natural
}