Park/Buildings/Building.cs
using HC3.Terrain;
using HC3.UI;
using System;
using System.Text.Json.Nodes;
namespace HC3;
/// <summary>
/// Some events for building types.
/// </summary>
public interface IBuildingEvents : ISceneEvent<IBuildingEvents>
{
/// <summary>
/// Called on the host when a guest enters the building.
/// </summary>
public void OnGuestEnter( Guest guest ) { }
/// <summary>
/// Called on the host when a guest leaves the building.
/// </summary>
public void OnGuestLeave( Guest guest ) { }
/// <summary>
/// Called on the host when the building state changes.
/// </summary>
public void OnStateChanged( OpenState state ) { }
}
public partial class Building : Component, IPathConnector, IInspectable, IPlacementObject
{
/// <summary>
/// The building's <see cref="HC3.GridObject"/>
/// </summary>
[RequireComponent]
public GridObject GridObject { get; private set; }
/// <summary>
/// The size of this building in tiles. Defaults to 1x1.
/// </summary>
[Property, Feature( "Building" )]
public Vector2Int Size { get; set; } = 1;
/// <summary>
/// Should this building satisfy a need?
/// </summary>
[Property, Feature( "Needs" )]
public bool SatisfyNeed { get; set; } = true;
/// <summary>
/// Which need is satisfied by using this building?
/// </summary>
[Property, Feature( "Needs" ), ShowIf( nameof( SatisfyNeed ), true )]
public NeedDefinition Need { get; set; }
/// <summary>
/// How much of this need should be decreased when a guest uses the building.
/// </summary>
[Property, Feature( "Needs" ), ShowIf( nameof( SatisfyNeed ), true )]
public float NeedDecrease { get; set; } = 1f;
/// <summary>
/// How steep of a slope can this be built on? The shallowest slope has gradient <c>1</c>.
/// </summary>
[Property, Feature( "Building" )]
public int MaxGradient { get; set; } = 0;
/// <summary>
/// How much money this building costs for a guest to use it.
/// </summary>
[Property, Feature( "Balance" )]
public int GuestCost { get; set; } = 50;
/// <summary>
/// Cost to place this in the world.
/// </summary>
[Property, Feature( "Balance" )]
public int Cost { get; set; } = 15;
/// <summary>
/// How many times has this building been used?
/// </summary>
[Sync( SyncFlags.FromHost )]
public int Uses { get; set; }
/// <summary>
/// Usage string, used with <see cref="GetUseString()"/> by default.
/// </summary>
[Property]
protected string UseString { get; set; } = "Has been used {0} time(s)";
/// <summary>
/// The inspector locale string for usage of this building.
/// </summary>
/// <returns></returns>
public virtual string GetUseString() => string.Format( UseString, Uses );
/// <summary>
/// The building's name
/// </summary>
[Property, Feature( "Building" ), Group( "Display" )]
public string Title { get; set; } = "Building";
/// <summary>
/// The thumbnail for this building
/// </summary>
[Property, Feature( "Building" ), Group( "Display" ), ImageAssetPath]
public string Thumbnail { get; set; } = "textures/thumbnails/missing_thumbnail.png";
/// <summary>
/// The type of building -- used for categorization in the build menu.
/// </summary>
[Property, Feature( "Building" ), Group( "Display" )]
public BuildingType BuildingType { get; set; } = BuildingType.Shop;
/// <summary>
/// The material to use for this building. This decides what sound to play when placing the building. (Why not just a SoundEvent?)
/// </summary>
[Property, Feature( "Building" ), Group( "Display" )]
public ObjectMaterial ObjectMaterial { get; set; } = ObjectMaterial.Default;
/// <summary>
/// The grouping for this building -- used for the build menu.
/// </summary>
public virtual Group Group => new( "Building", BuildingType.ToString() );
// TODO: Get rid of this please
/// <summary>
/// Tint channels for this building. These are used to tint the building's sections.
/// </summary>
public TintChannelData RedChannel { get; set; } = new( true, Color.White );
public TintChannelData GreenChannel { get; set; } = new( false, Color.White );
public TintChannelData BlueChannel { get; set; } = new( false, Color.White );
public TintChannelData AlphaChannel { get; set; } = new( false, Color.White );
/// <summary>
/// Is this a walkable building?
/// </summary>
[Property, Feature( "Navigation" )]
public bool IsWalkable { get; set; }
/// <summary>
/// For shops and entrances / exits, which tile edges can it connect to paths with?
/// </summary>
[Property, Feature( "Navigation" )]
[ShowIf( nameof( BuildingType ), BuildingType.Shop ), ShowIf( nameof( BuildingType ), BuildingType.Special )]
public PathMask PathMask { get; set; } = PathMask.Down;
/// <summary>
/// How many extra guests does this give us for the park?
/// </summary>
[Property, Feature( "Balance" )]
public int AddedGuests { get; set; } = 0;
[Sync( SyncFlags.FromHost )]
private OpenState _openState { get; set; } = OpenState.Open;
/// <summary>
/// The current open state of the building. <see cref="OpenState.Testing"/> runs the ride without guests.
/// </summary>
public OpenState OpenState
{
get => _openState;
set => ModifyOpenState( value );
}
[Rpc.Host]
private void ModifyOpenState( OpenState value )
{
_openState = value;
OpenStateChanged();
}
[Sync( SyncFlags.FromHost )]
public string CustomTitle { get; set; }
/// <summary>
/// Set the building title. This will be networked to other players.
/// </summary>
[Rpc.Broadcast]
public void SetBuildingTitle( string title )
{
CustomTitle = title;
OnTitleChanged?.Invoke( title );
Stats.Increment( "building.renamed" );
}
/// <summary>
/// Check whether or not this building has been placed or is currently being placed.
/// </summary>
public bool IsPlaced
{
get => _isPlaced;
set
{
_isPlaced = value;
GridObject.Enabled = value;
if ( value )
{
ActiveBuildings.Add( this );
DirtyErrors();
}
else
{
ActiveBuildings.Remove( this );
}
}
}
/// <summary>
/// Does this building snap to the grid?
/// </summary>
[Property]
public bool SnapToGrid { get; set; } = true;
/// <summary>
/// Is this a placed Building -- not being currently placed?
/// </summary>
private bool _isPlaced = false;
/// <summary>
/// Called when the building title is changed
/// </summary>
public Action<string> OnTitleChanged;
/// <summary>
/// Called when the open state is changed
/// </summary>
public Action<OpenState> OnOpenStateChanged;
/// <summary>
/// Right now just a collection of all enabled buildings, we can later what active means, have separate sets for open/closed buildings, etc.
/// Conna: should this be synchronized? Do remote clients need to know which buildings are active?
/// tony: Why does this exist
/// </summary>
public static HashSet<Building> ActiveBuildings = new();
protected override void OnValidate()
{
GridObject ??= GetOrAddComponent<GridObject>();
GridObject.LocalBounds = new RectInt( -Size / 2, Size );
GridObject.GridFlags = GridObjectFlags.BlocksConstruction | (IsWalkable ? GridObjectFlags.IsWalkable : 0);
SetupErrors();
}
/// <summary>
/// Can this guest enter the building? If not, they'll stay queueing
/// </summary>
public virtual bool CanEnter( Guest guest )
{
return IsAvailable() && HasFreeSlot();
}
/// <summary>
/// Calculates how interested a guest is in using this building based on a bunch of information
/// TODO: Expose an interface so systems can hook in
/// </summary>
/// <param name="guest"></param>
/// <returns></returns>
public virtual float GetInterestWeight( Guest guest )
{
var weight = 100.0f;
// Less likely to go on the rides that are more expensive (in relation to the guest's money)
weight -= (GuestCost / guest.Money) * 40f;
// Less likely to go to anything with a longer queue
if ( Components.TryGet<QueueGuestSystem>( out var queueGuestSystem ) )
{
weight -= MathF.Min( queueGuestSystem.GetGuestCount() / 10f, 20 );
}
// Less likely to go on rides they've already been on (only rides since we likely want to use the same toilet/shop multiple times)
if ( this is BasicRide )
{
if ( guest.BuildingsVisited.Contains( this ) )
{
weight -= 20f;
}
}
// Less likely to go to rides that are far away
var distance = (guest.WorldPosition - WorldPosition).Length;
weight -= MathF.Min( distance / 10f, 20f );
// Ever so slightly more likely to go on larger (more impressive) rides
weight += Size.Length * 0.5f;
return weight;
}
/// <summary>
/// Is this building currently open and operational
/// </summary>
public virtual bool IsAvailable()
{
return HasFreeSlot() && OpenState is OpenState.Open;
}
/// <summary>
/// Gets a description for a guest when they're using this building
/// </summary>
/// <param name="guest"></param>
/// <returns></returns>
public virtual string GetInUseDescription( Guest guest )
{
return $"Using {Title}";
}
/// <summary>
/// Status description of what this building is doing
/// </summary>
public virtual string GetStatus()
{
return OpenState.ToString();
}
/// <summary>
/// Called when the OpenState is changed
/// </summary>
protected virtual void OpenStateChanged()
{
OnOpenStateChanged?.Invoke( OpenState );
IBuildingEvents.PostToGameObject( GameObject, x => x.OnStateChanged( OpenState ) );
}
/// <summary>
/// Called when used by a Guest
/// </summary>
/// <param name="guest"></param>
public virtual bool OnUse( Guest guest )
{
var need = guest.Needs.GetNeed( Need );
if ( need != null )
need.Level -= NeedDecrease;
Stats.Increment( "building.used" );
Stats.Increment( $"building.used.{Title.ToIdentifier()}" );
guest.BuildingsVisited.Add( this );
return true;
}
protected override void OnEnabled()
{
if ( IsPlaced )
{
ActiveBuildings.Add( this );
DirtyErrors();
}
GenerateDecorations();
}
protected override void OnStart()
{
UpdateColors();
}
protected override void OnDisabled()
{
if ( IsPlaced ) ActiveBuildings.Remove( this );
}
protected override void DrawGizmos()
{
base.DrawGizmos();
using ( Gizmo.Scope( "building" ) )
{
var gridSize = GridManager.GridSize;
Gizmo.Draw.Color = Color.Green;
Gizmo.Draw.LineThickness = 2;
Gizmo.Draw.LineBBox( new BBox( -Size * gridSize / 2f, Size * gridSize / 2f ) );
if ( BuildingType is BuildingType.Shop or BuildingType.Special )
{
foreach ( var edge in GridManager.AllEdges )
{
if ( (PathMask & edge.ToPathMask()) != 0 )
{
Vector3 dir = (Vector2)edge.GetDirection();
Gizmo.Draw.Arrow( dir * 96f + Vector3.Up * 16f, dir * 32f + Vector3.Up * 16f );
}
}
}
}
}
/// <summary>
/// Returns the tile range occupied by this building, taking into account rotation.
/// </summary>
/// <param name="rot"></param>
/// <returns></returns>
public RectInt GetTileRange( float? rot = null )
{
var min = -Size / 2;
var max = min + Vector2Int.Max( 0, Size );
rot ??= WorldRotation.Yaw();
if ( rot == 90 || rot == 270 )
{
min = new Vector2Int( min.y, min.x );
max = new Vector2Int( max.y, max.x );
}
var worldToGrid = GridManager.WorldToGridPosition( WorldPosition );
min += worldToGrid;
max += worldToGrid;
return new RectInt( min, max - min );
}
public virtual Vector3 GetEntrance()
{
if ( BuildingType is BuildingType.Shop )
{
var gridPos = GridManager.WorldToGridPosition( WorldPosition );
var height = (int)MathF.Round( WorldPosition.z / GridManager.HeightStep );
foreach ( var edge in Enum.GetValues<TileEdge>() )
{
var rotation = (int)MathF.Round( WorldRotation.Yaw() / 90f );
if ( (PathMask & edge.Rotate( rotation ).ToPathMask()) != 0 )
{
var cell = GridManager.Instance.GetCell( gridPos + edge.GetDirection() );
if ( cell is null ) continue;
if ( cell.GetConnection( new Vector3Int( cell.Position.x, cell.Position.y, height ), edge.GetOpposite() ) is not null )
{
return (GridManager.GridToWorldPosition( cell.Position ) + GridManager.CentreOffset).WithZ( WorldPosition.z );
}
}
}
}
return WorldPosition;
}
/// <summary>
/// Can we connect this building to this tile?
/// </summary>
/// <param name="gridPos"></param>
/// <param name="edge"></param>
/// <returns></returns>
public virtual bool CanConnectTile( Vector3Int gridPos, TileEdge edge )
{
// TODO: non-shops
if ( BuildingType is BuildingType.Shop or BuildingType.Special )
{
return PathMask.CanConnectTile( edge.RotateDegrees( WorldRotation.Yaw() ) );
}
return false;
}
/// <summary>
/// Called when we're inspecting this building (clicking it to open UI)
/// </summary>
/// <returns></returns>
Window IInspectable.Select()
{
var building = this;
if ( building is RideEntranceExit entrance )
{
if ( entrance.Ride.IsValid() ) building = entrance.Ride;
else return null;
}
var inspector = new BuildingInspector();
inspector.Building = building;
return inspector;
}
/// <summary>
/// Accessor to get the TintMaskComponent on this building, if it has one.
/// </summary>
/// <returns></returns>
public TintMaskComponent GetTintComponent()
{
return GetComponentsInChildren<TintMaskComponent>().FirstOrDefault();
}
/// <summary>
/// Called when the colour has changed from the TintMaskCopmonent -- we need to update our channel data.
/// </summary>
/// <param name="component"></param>
void ITintMaskEvents.OnColorsChanged( TintMaskComponent component )
{
var tintMask = GetTintComponent();
if ( tintMask != component )
return;
var red = RedChannel;
red.Color = component.PrimaryColor;
RedChannel = red;
var green = GreenChannel;
green.Color = component.SecondaryColor;
GreenChannel = green;
var blue = BlueChannel;
blue.Color = component.AccentColor;
BlueChannel = blue;
var alpha = AlphaChannel;
alpha.Color = component.DetailColor;
AlphaChannel = alpha;
}
/// <summary>
/// Updates the TintMaskComponent with our current channel colors.
/// </summary>
void UpdateColors()
{
if ( !Networking.IsHost )
return;
var tintMask = GetTintComponent();
if ( tintMask is null ) return;
tintMask.PrimaryColor = RedChannel.Color;
tintMask.SecondaryColor = GreenChannel.Color;
tintMask.AccentColor = BlueChannel.Color;
tintMask.DetailColor = AlphaChannel.Color;
}
/// <summary>
/// Some buildings might want to generate decorations by default when placed.
/// </summary>
protected virtual void GenerateDecorations()
{
foreach ( var existing in GameObject.Children.Where( x => x.Tags.Has( "decor_generated" ) ).ToArray() )
{
existing.Destroy();
}
}
/// <summary>
/// Lets us add some extra state for a GUEST when saving them.. This... Kinda fucking sucks.
/// </summary>
/// <param name="guest"></param>
/// <returns></returns>
internal JsonNode GetStateJson( Guest guest )
{
return new JsonObject()
{
// Store grid of the building so we can recall it when we've loaded everything and slot guests back in
["Position"] = GridManager.Instance.Terrain.WorldToGrid( WorldPosition ).ToString()
};
}
}
/// <summary>
/// A type of building, used for categorization in the build menu.
/// </summary>
public enum BuildingType
{
/// <summary>
/// A ride
/// </summary>
[Icon( "world" )]
Ride,
/// <summary>
/// A shop
/// </summary>
[Icon( "money_add" )]
Shop,
/// <summary>
/// Sideshows and other entertainment buildings
/// </summary>
[Icon( "bell" )]
Sideshows,
/// <summary>
/// Reserved for stuff that shouldn't appear in the build menu.
/// </summary>
Special
}