Park/Paths/PathBuilder.cs
using HC3.Terrain;
using HC3.UI;
using Sandbox.Diagnostics;
using System;
namespace HC3;
internal class PathBuilder : Component, Component.ExecuteInEditor, IBuilder
{
public PathType PathType { get; set; }
public static PathBuilder Instance { get; private set; }
public Vector3Int? CursorPosition;
public TileEdge Direction;
public PathElevation Elevation;
public bool CursorMode;
[Property] public PathTileMap PathTileMap { get; set; }
[Property] public PathTileMap QueueTileMap { get; set; }
public GameObject CursorPath { get; set; }
public Path GhostPath { get; set; }
public void Activate()
{
(this as IBuilder).DeactivateAll();
Enabled = true;
CursorMode = false;
}
public void Deactivate()
{
PathWindow.Current?.Close();
WindowManager.Instance.DeactivateAll();
Enabled = false;
}
protected override void OnAwake()
{
Instance = this;
}
protected override void OnEnabled()
{
UpdateGhostPath();
Activate();
}
public void UpdateGhostPath()
{
if ( GhostPath.IsValid() )
{
GhostPath?.GameObject.DestroyImmediate();
}
var obj = new GameObject( false, "Ghost Path" );
obj.Flags |= GameObjectFlags.NotNetworked;
GhostPath = PathType == PathType.Queue ? obj.AddComponent<Queue>() : obj.AddComponent<Path>();
var gridObject = GhostPath.GetOrAddComponent<GridObject>();
gridObject.IsWalkable = false;
gridObject.BlocksConstruction = false;
GhostPath.GhostPreview = true;
GhostPath.PathType = PathType;
GhostPath.GameObject.Enabled = true;
}
protected override void OnDisabled()
{
if ( GhostPath.IsValid() )
GhostPath.DestroyGameObject();
}
bool startedDragging = false;
Vector3 dragStartPos;
Vector3 dragEndPos;
List<GameObject> Ghosts = new();
void UpdateGhosts()
{
foreach ( var ghost in Ghosts )
{
ghost.DestroyImmediate();
}
Ghosts.Clear();
// DebugOverlay.Line( new Line( dragStartPos + Vector3.Up * 16, dragEndPos + Vector3.Up * 16 ) );
var normal = (dragEndPos - dragStartPos).Normal;
var length = (dragEndPos - dragStartPos).Length;
var blocks = Math.Ceiling( length / 64.0f );
for ( int i = 0; i < blocks; i++ )
{
// DebugOverlay.Sphere( new Sphere( dragStartPos + normal * 64 * i, 16.0f ), Color.Red );
var ghostPath = new GameObject( false, "Ghost Path" );
ghostPath.Flags |= GameObjectFlags.NotNetworked;
var path = PathType == PathType.Queue ? ghostPath.AddComponent<Queue>() : ghostPath.AddComponent<Path>();
path.GhostPreview = true;
path.PathType = PathType;
var gridObject = GhostPath.GetOrAddComponent<GridObject>();
gridObject.IsWalkable = false;
gridObject.BlocksConstruction = false;
ghostPath.WorldPosition = dragStartPos + normal * 64 * i;
// prevent z-fighting with existing paths
ghostPath.WorldPosition += Vector3.Up * 0.1f;
ghostPath.Enabled = true;
Ghosts.Add( ghostPath );
}
}
Ray CursorRay => Scene.Camera.ScreenPixelToRay( Mouse.Position );
SceneTrace PathTrace => Scene.Trace
.Ray( CursorRay, 65536f )
.WithTag( "path" );
protected override void OnUpdate()
{
if ( Scene.Camera is not { } camera ) return;
if ( !Mouse.Active ) return;
if ( Input.EscapePressed )
{
Deactivate();
return;
}
var ray = camera.ScreenPixelToRay( Mouse.Position );
{
var result = PathTrace.Run();
if ( CursorMode && CursorPosition.HasValue )
{
// ghost from cursor path when the cursor controls are hovered
Vector3Int tilePosition = CursorPosition.Value + Direction.GetDirection();
GhostPath.WorldPosition = GridManager.GridToWorldPosition( tilePosition ) + GridManager.CentreOffset;
GhostPath.GameObject.Enabled = true;
var path = GhostPath.GetComponent<Path>();
path.SetStair( Direction, Elevation );
}
else if ( result.Hit && !startedDragging )
{
// path selection/hover ghosts
var hoverPath = result.GameObject.GetComponentInParent<Path>();
if ( Input.Pressed( "Attack1" ) )
{
CursorPosition = GridManager.WorldToGridPosition3D( hoverPath.WorldPosition );
CursorPosition += Vector3Int.Up * hoverPath.GetHeightOffset( Direction );
var offset = (result.HitPosition - hoverPath.WorldPosition).WithZ( 0 );
Direction = offset.GetTileEdge();
if ( !CursorPath.IsValid() )
{
CursorPath = new GameObject();
CursorPath.AddComponent<CursorPath>();
}
}
// no ghost for stairs
GhostPath.GameObject.Enabled = !hoverPath.IsStairs;
if ( GhostPath.GameObject.Enabled )
{
GhostPath.WorldPosition = GridManager.AlignWorldToGrid( result.HitPosition ) + GridManager.CentreOffset;
// prevent z-fighting with existing paths
GhostPath.WorldPosition += Vector3.Up * 0.1f;
}
}
else
{
// terrain-surface paths and dragging
// TODO: 3d paths drag?
result = Scene.Trace
.Ray( ray, 65536f )
.UsePhysicsWorld()
.WithTag( "ground" )
.Run();
if ( !result.Hit || result.Component is not TerrainMesh { Terrain: var terrain } ) return;
var gridPos = GridManager.WorldToGridPosition( result.HitPosition );
var snappedPos = GridManager.GridToWorldPosition( gridPos );
// finish current dragging
if ( !Input.Down( "Attack1" ) && startedDragging )
{
startedDragging = false;
List<Vector3> pathsToPlace = new();
foreach ( var ghost in Ghosts )
{
pathsToPlace.Add( ghost.WorldPosition );
ghost.DestroyImmediate();
}
Ghosts.Clear();
pathsToPlace.Add( GhostPath.WorldPosition );
PlacePaths( pathsToPlace );
// select last path as the cursor
CursorPosition = GridManager.WorldToGridPosition3D( result.HitPosition );
// face the right way if we can work that out
if ( pathsToPlace.Count == 1 )
{
var offset = (result.HitPosition - (GridManager.AlignWorldToGrid( result.HitPosition ) + GridManager.CentreOffset)).WithZ( 0 );
Direction = offset.GetTileEdge();
}
else if ( pathsToPlace.FirstOrDefault() is { } firstGhost )
{
Direction = (GhostPath.WorldPosition - firstGhost).GetTileEdge();
}
if ( !CursorPath.IsValid() )
{
CursorPath = new GameObject();
CursorPath.AddComponent<CursorPath>();
}
}
var height = terrain[gridPos].MinHeight;
// Dragging
if ( Input.Down( "Attack1" ) )
{
if ( !startedDragging )
{
startedDragging = true;
dragStartPos = snappedPos.WithZ( terrain.TileHeight * height ) + new Vector3( 32, 32, 0 );
}
// keep drag end pos in line with start pos
var endPos = snappedPos.WithZ( terrain.TileHeight * height ) + new Vector3( 32, 32, 0 );
var diff = endPos - dragStartPos;
if ( MathF.Abs( diff.x ) > MathF.Abs( diff.y ) )
{
dragEndPos = endPos.WithY( dragStartPos.y );
}
else
{
dragEndPos = endPos.WithX( dragStartPos.x );
}
GhostPath.WorldPosition = dragEndPos;
// prevent z-fighting with existing paths
GhostPath.WorldPosition += Vector3.Up * 0.1f;
UpdateGhosts();
}
else
{
GhostPath.WorldPosition = snappedPos.WithZ( terrain.TileHeight * height ) + new Vector3( 32, 32, 0 );
GhostPath.AlignToTerrain();
}
GhostPath.GameObject.Enabled = true;
}
}
if ( Input.Pressed( "Attack2" ) )
{
var result = PathTrace.Run();
if ( result.Hit )
{
var hoverPath = result.GameObject.GetComponentInParent<Path>();
DeletePath( hoverPath.TilePosition );
}
}
}
public async void PlacePaths( List<Vector3> paths )
{
foreach ( var pos in paths )
{
PlacePath( pos, PathType );
await Task.DelayRealtime( 50 );
}
if ( PathType == PathType.Path )
Stats.Increment( "path.placed", paths.Count );
else if ( PathType == PathType.Queue )
Stats.Increment( "queue.placed", paths.Count );
}
public void Clear()
{
// Why do we have no scene
if ( Scene is null ) return;
foreach ( var path in Scene.GetAllComponents<Path>().ToArray() )
{
path.GameObject.DestroyImmediate();
}
}
public void RotateCursor( int direction )
{
Direction = Direction.Rotate( direction );
}
public void BuildCursor()
{
Assert.NotNull( CursorPosition );
Vector3Int tilePosition = CursorPosition.Value + Direction.GetDirection();
PlacePath( tilePosition, PathType, Direction, Elevation );
}
public Path AddPath( Vector2Int tilePosition, PathType type = PathType.Path )
{
if ( GridManager.Instance is null ) return null;
var terrain = GridManager.Instance.Terrain;
if ( terrain is null ) return null;
var groundHeight = terrain[tilePosition].MinHeight;
var path = AddPath( new Vector3Int( tilePosition.x, tilePosition.y, groundHeight ), type );
if ( path.IsValid() )
{
path.AlignToTerrain();
}
return path;
}
public Path AddPath( Vector3Int tilePosition, PathType type = PathType.Path, TileEdge direction = TileEdge.Down, PathElevation elevation = PathElevation.Flat )
{
var grid = Scene.GetAllComponents<GridManager>()
.FirstOrDefault() ?? throw new Exception( "Grid doesn't exist!" );
if ( grid.IsConstructionBlocked( tilePosition, Path.PATH_HEIGHT ) ) return null;
var terrain = grid.Terrain;
var go = new GameObject( true, "Path" );
go.WorldPosition = GridManager.GridToWorldPosition( tilePosition ) + GridManager.CentreOffset;
go.Tags.Add( "path" );
var path = type == PathType.Queue ? go.AddComponent<Queue>() : go.AddComponent<Path>();
path.PathType = type;
if ( elevation is not PathElevation.Flat )
{
path.SetStair( direction, elevation );
}
var collider = go.GetOrAddComponent<PlaneCollider>();
collider.Tags.Add( "path" );
collider.Scale = GridManager.GridSize;
var gridObject = go.GetOrAddComponent<GridObject>();
gridObject.IsWalkable = true;
gridObject.Height = Path.PATH_HEIGHT;
gridObject.BlocksConstruction = true;
if ( tilePosition.x == terrain.Bounds.Left )
{
// spawn guests from here!
gridObject.AddComponent<SpawnPoint>();
}
if ( Scene.IsEditor )
{
go.Flags |= GameObjectFlags.NotSaved;
}
else if ( Networking.IsHost )
{
// Network spawn this path object if we're the host
go.NetworkSpawn();
}
return path;
}
[Rpc.Host]
public void DeletePath( Path path )
{
var pathCost = 10;
MoneyEffect.Broadcast( path.WorldPosition + Vector3.Up * 10f, $"-{GameUtils.Currency}{pathCost}", Color.Green );
path.GameObject.DestroyImmediate();
ParkManager.Instance?.GiveMoney( pathCost, "Building Refunds" );
}
public void DeletePath( Vector3Int gridPos )
{
var gridPos2d = new Vector2Int( gridPos );
if ( !BuildingZone.Instance.IsOwned( gridPos2d ) ) return;
var grid = GridManager.Instance;
if ( grid.GetCell( new Vector2Int( gridPos2d ) )?.GetComponents<Path>( gridPos.z )?.Where( x => !x.GhostPreview ).FirstOrDefault() is not { } path )
return;
DeletePath( path );
}
public static bool IsPathableSlope( TileSlope slope )
{
var gradients = slope.GetGradients();
if ( gradients.x == 0 && gradients.y == 0 )
return true;
return Math.Min( Math.Abs( gradients.x ), Math.Abs( gradients.y ) ) == 0
&& Math.Max( Math.Abs( gradients.x ), Math.Abs( gradients.y ) ) == 1;
}
/// <summary>
/// Place a new path. This is called on the host.
/// </summary>
[Rpc.Host]
public void PlacePath( Vector3 pos, PathType type )
{
var gridPos = GridManager.WorldToGridPosition( pos );
if ( GridManager.Instance?.Terrain[gridPos] is not { } tile )
return;
if ( !BuildingZone.Instance.IsOwned( pos ) )
return;
// Can we afford to place the path?
if ( !ParkManager.Instance?.TakeMoney( Path.PATH_COST, "Paths" ) ?? false )
return;
if ( !IsPathableSlope( tile.Slope ) )
{
GridManager.Instance.Terrain[gridPos] = TerrainTile.LevelGround( tile.MaxHeight );
}
if ( AddPath( gridPos, type ) is null )
return;
GameObject.Clone( "prefabs/particles/place_dustcloud.prefab", new Transform( pos + Vector3.Up * 8 ) );
Sound.Play( "sounds/gameplay/building_placed.sound", pos );
MoneyEffect.Broadcast( pos + Vector3.Up * 10f, $"-{GameUtils.Currency}{Path.PATH_COST}", Color.Red );
}
/// <summary>
/// Place a new path. This is called on the host.
/// </summary>
[Rpc.Host]
public void PlacePath( Vector3Int gridPos, PathType type, TileEdge direction, PathElevation elevation )
{
var caller = Rpc.Caller;
// Can we afford to place the path?
if ( !ParkManager.Instance?.TakeMoney( Path.PATH_COST, "Paths" ) ?? false )
return;
if ( !BuildingZone.Instance.IsOwned( gridPos ) )
return;
if ( AddPath( gridPos, type, direction, elevation ) is not { } path )
return;
GameObject.Clone( "prefabs/particles/place_dustcloud.prefab", new Transform( path.WorldPosition + Vector3.Up * 8 ) );
Sound.Play( "sounds/gameplay/building_placed.sound", path.WorldPosition );
MoneyEffect.Broadcast( path.WorldPosition + Vector3.Up * 10f, $"-{GameUtils.Currency}{Path.PATH_COST}", Color.Red );
var nextPosition = gridPos + Vector3Int.Up * path.GetHeightOffset( direction );
if ( elevation is PathElevation.StairDown )
{
nextPosition += Vector3Int.Down;
}
using ( Rpc.FilterInclude( caller ) )
{
// tell the client where they should go next
SetCursorPosition( nextPosition );
}
}
[Rpc.Broadcast( NetFlags.HostOnly )]
public void SetCursorPosition( Vector3Int gridPos )
{
CursorPosition = gridPos;
}
public bool CanPlace( Vector3Int pos )
{
var grid = GridManager.Instance;
if ( grid.IsConstructionBlocked( pos, Path.PATH_HEIGHT ) ) return false;
if ( !BuildingZone.Instance.IsOwned( pos ) ) return false;
var tile = grid.Terrain[new Vector2Int( pos )];
if ( pos.z < tile.MaxHeight )
return false; // tunnels??
if ( pos.z == tile.MaxHeight )
{
// Check steepness of hill
return tile.MaxHeight - tile.MinHeight <= 1;
}
return true;
}
}