Park/BuildingZone.cs
using HC3;
using HC3.Persistence;
using HC3.Terrain;
using System;
using System.Collections.Immutable;
public enum ZoneExpansionSide
{
Top,
Bottom,
Left,
Right
}
/// <summary>
/// The building zone. This defines chunks of tiles that we may or may not own. This lets players buy more land.
/// TODO: I think this code needs to be cleaned big time.
/// </summary>
public sealed class BuildingZone : Component, ITerrainEvent,
ISaveDataProperty<ImmutableHashSet<Vector2Int>>, Component.INetworkSnapshot
{
private List<Vector2Int> previewCandidates = new();
public static BuildingZone Instance { get; private set; }
[Property] public LineRenderer Line { get; set; }
[Property] public Vector2Int EntranceGridPos { get; set; }
[Property] public Vector2Int StartingZonePosition { get; set; }
[Property] public Vector2Int StartingZoneSize { get; set; } = new Vector2Int( 32, 32 );
[Property] public ZoneExpansionSide PreviewEdge { get; set; } = ZoneExpansionSide.Top;
[Property] GameObject ZoneWallObject { get; set; }
[Property] GameObject ZoneWallPillarObject { get; set; }
List<GameObject> zoneWalls { get; set; } = new();
public HashSet<Vector2Int> OwnedChunks { get; private set; } = new();
public List<ZoneTile> Tiles { get; private set; } = new();
public bool PurchaseMode { get; set; } = false;
public HashSet<Vector2Int> OwnedTiles { get; private set; } = new();
void INetworkSnapshot.ReadSnapshot( ref ByteStream reader )
{
var chunkCount = reader.Read<int>();
for ( var i = 0; i < chunkCount; i++ )
{
var chunk = reader.Read<Vector2Int>();
AddChunkLocal( chunk );
}
}
void INetworkSnapshot.WriteSnapshot( ref ByteStream writer )
{
writer.Write( OwnedChunks.Count );
foreach ( var chunk in OwnedChunks )
{
writer.Write( chunk );
}
}
public List<(Vector2Int, Vector3)> GetAllPreview()
{
var preview = new List<(Vector2Int, Vector3)>();
foreach ( var chunk in previewCandidates )
{
var worldOrigin = GridManager.GridToWorldPosition( chunk );
var worldSize = new Vector3(
GridManager.GridSize * StartingZoneSize.x,
GridManager.GridSize * StartingZoneSize.y,
840f
);
preview.Add( (chunk, worldOrigin + worldSize / 2) );
}
return preview;
}
void ITerrainEvent.EntranceMoved( Vector3Int gridPos3d )
{
var gridPos = new Vector2Int( gridPos3d.x, gridPos3d.y );
EntranceGridPos = gridPos;
StartingZonePosition = gridPos + new Vector2Int( 1, -StartingZoneSize.y / 2 );
Clear();
AddChunkAt( StartingZonePosition );
}
protected override void OnAwake()
{
Instance = this;
if ( GridManager.Instance.IsValid() )
{
foreach ( var tilePos in OwnedTiles )
{
GridManager.Instance?.Add( tilePos );
}
}
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void Clear()
{
OwnedChunks.Clear();
Tiles.Clear();
OwnedTiles.Clear();
}
private void AddChunkLocal( Vector2Int chunkOrigin )
{
if ( !OwnedChunks.Add( chunkOrigin ) ) return;
for ( int x = 0; x < StartingZoneSize.x; x++ )
for ( int y = 0; y < StartingZoneSize.y; y++ )
{
var tilePos = chunkOrigin + new Vector2Int( x, y );
if ( !OwnedTiles.Contains( tilePos ) )
{
Tiles.Add( new ZoneTile( tilePos ) );
OwnedTiles.Add( tilePos );
GridManager.Instance?.Add( tilePos );
}
}
UpdateZoneOutline();
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void AddChunkAt( Vector2Int chunkOrigin )
{
AddChunkLocal( chunkOrigin );
}
[Rpc.Host]
private void TryPurchase( Vector2Int chunkOrigin )
{
var chunkSize = StartingZoneSize;
var cost = GetChunkCost( chunkOrigin );
if ( ParkManager.Instance.Money < cost )
return;
Log.Info( $"Adding chunk at {chunkOrigin} to {chunkOrigin + chunkSize}" );
AddChunkAt( chunkOrigin );
ParkManager.Instance.TakeMoney( cost, "Building Zones" );
}
public int GetChunkCost( Vector2Int chunkOrigin )
{
var dist = Vector2.Distance( chunkOrigin, StartingZonePosition );
return 10_000 * (int)Math.Round( dist / StartingZoneSize.Length );
}
[Button( "Toggle Purchase Mode" )]
public void TogglePurchaseMode()
{
PurchaseMode = !PurchaseMode;
}
protected override void OnUpdate()
{
if ( Tiles.Count == 0 ) return;
if ( !PurchaseMode ) return;
previewCandidates = new List<Vector2Int>();
var chunkSize = StartingZoneSize;
var bounds = GridManager.Instance.Terrain.Bounds;
bounds.Left = StartingZonePosition.x;
// TODO: RectInt.IsInside( fullyInside: true ) seems off by 1?
bounds = bounds.Grow( 1 );
foreach ( var chunk in OwnedChunks )
{
var directions = new[]
{
(Vector2Int.Up, ZoneExpansionSide.Top),
(Vector2Int.Down, ZoneExpansionSide.Bottom),
(Vector2Int.Left, ZoneExpansionSide.Left),
(Vector2Int.Right, ZoneExpansionSide.Right),
};
foreach ( var (dir, side) in directions )
{
var neighborChunk = chunk + dir * chunkSize;
var chunkBounds = new RectInt( neighborChunk, StartingZoneSize );
if ( !bounds.IsInside( chunkBounds, true ) )
continue;
if ( !OwnedChunks.Contains( neighborChunk ) )
previewCandidates.Add( neighborChunk );
}
}
var tr = Scene.Trace.Ray( Scene.Camera.ScreenPixelToRay( Mouse.Position ), 40000f )
.UsePhysicsWorld()
.WithAnyTags( "ground" )
.Run();
foreach ( var chunk in previewCandidates )
{
var worldOrigin = GridManager.GridToWorldPosition( chunk );
var worldSize = new Vector3(
GridManager.GridSize * chunkSize.x,
GridManager.GridSize * chunkSize.y,
840f
);
var color = TraceWithinBounds( tr.HitPosition, chunk, chunk + chunkSize ) ? Color.Green : Color.Red;
DebugOverlay.Box(
worldOrigin + worldSize / 2,
worldSize.WithZ( 64 ),
color,
0.1f,
overlay: true
);
if ( Input.Pressed( "attack1" ) && TraceWithinBounds( tr.HitPosition, chunk, chunk + chunkSize ) )
{
TryPurchase( chunk );
PurchaseMode = false;
}
}
}
bool TraceWithinBounds( Vector3 worldPos, Vector2Int min, Vector2Int max )
{
var gridPos = GridManager.WorldToGridPosition( worldPos );
return gridPos.x >= min.x && gridPos.x <= max.x && gridPos.y >= min.y && gridPos.y <= max.y;
}
[ConCmd( "hc3.debug.updatezone", ConVarFlags.Cheat )]
public static void UpdateZone()
{
BuildingZone.Instance?.UpdateZoneOutline( debug: true );
}
public void UpdateZoneOutline( bool debug = false )
{
if ( ZoneWallObject == null || Tiles.Count == 0 ) return;
var terrain = GridManager.Instance?.Terrain;
if ( terrain == null ) return;
foreach ( var wall in zoneWalls )
{
wall.Destroy();
}
if ( debug )
{
Log.Info( $"Clearing {zoneWalls.Count} walls" );
}
zoneWalls.Clear();
var outline = GenerateOutlineFromTiles( Tiles );
if ( outline.Count == 0 ) return;
// Update LineRenderer points
if ( Line.IsValid() )
{
var linePoints = new List<Vector3>();
foreach ( var point in outline )
{
// Convert grid position to world position and add some height offset
var height = terrain[point].MaxHeight;
var worldPoint = terrain.GridToWorld( new Vector3( point.x, point.y, height ) );
// Add small offset to keep line visible above ground
worldPoint.z += 10f;
linePoints.Add( worldPoint );
}
// Close the loop by adding first point again
if ( linePoints.Count > 0 )
{
linePoints.Add( linePoints[0] );
}
Line.VectorPoints = linePoints;
}
const int pillarSpacing = 5;
int wallCount = 0;
for ( int i = 0; i < outline.Count; i++ )
{
var startGrid = outline[i];
var endGrid = outline[(i + 1) % outline.Count];
// Yucky
if ( startGrid == new Vector2Int( -47, -1 ) || startGrid == new Vector2Int( -47, -2 ) || startGrid == new Vector2Int( -47, 0 ) ||
endGrid == new Vector2Int( -47, 2 ) || endGrid == new Vector2Int( -47, 3 ) )
continue;
var edgeMidpoint = (startGrid + endGrid) * 0.5f;
var edgeHeight = terrain[new Vector2Int( (int)edgeMidpoint.x, (int)edgeMidpoint.y )].MaxHeight;
var worldPos = terrain.GridToWorld( new Vector3( edgeMidpoint.x, edgeMidpoint.y, edgeHeight ) );
var startWorld = terrain.GridToWorld( new Vector3( startGrid.x, startGrid.y, edgeHeight ) );
var endWorld = terrain.GridToWorld( new Vector3( endGrid.x, endGrid.y, edgeHeight ) );
var direction = (endWorld - startWorld).Normal;
var length = (endWorld - startWorld).Length;
var wall = ZoneWallObject.Clone( worldPos );
wall.Name = $"Zone Wall {startGrid} to {endGrid}";
wall.WorldTransform = new Transform(
worldPos,
Rotation.LookAt( direction ),
new Vector3( 1f, 1f, 1f )
);
zoneWalls.Add( wall );
wallCount++;
if ( wallCount % pillarSpacing == 0 && ZoneWallPillarObject != null )
{
var pillar = ZoneWallPillarObject.Clone( worldPos );
pillar.Name = $"Pillar {startGrid}";
pillar.WorldTransform = new Transform(
worldPos,
Rotation.LookAt( direction ),
Vector3.One
);
zoneWalls.Add( pillar );
}
}
if ( debug )
{
Log.Info( $"Loaded {zoneWalls.Count} zone walls" );
}
}
private List<Vector2Int> GenerateOutlineFromTiles( List<ZoneTile> tiles )
{
if ( tiles.Count == 0 ) return new List<Vector2Int>();
var tileSet = new HashSet<Vector2Int>( tiles.Select( t => t.GridPos ) );
// Find the bounds of our tile set
var minX = tileSet.Min( p => p.x );
var maxX = tileSet.Max( p => p.x );
var minY = tileSet.Min( p => p.y );
var maxY = tileSet.Max( p => p.y );
var outline = new List<Vector2Int>();
var current = new Vector2Int( minX, minY );
var direction = Vector2Int.Right;
// Walk around the perimeter in a clockwise direction
do
{
outline.Add( current );
// Try to turn right first, then straight, then left
var turnDirections = new[]
{
new { Dir = TurnRight( direction ), Pos = current + TurnRight( direction ) },
new { Dir = direction, Pos = current + direction },
new { Dir = TurnLeft( direction ), Pos = current + TurnLeft( direction ) }
};
bool found = false;
foreach ( var turn in turnDirections )
{
// Check if the position we're looking at is on the edge of our owned tiles
if ( IsEdgePosition( turn.Pos, tileSet, minX, maxX, minY, maxY ) )
{
current = turn.Pos;
direction = turn.Dir;
found = true;
break;
}
}
if ( !found ) break; // Shouldn't happen in a well-formed outline
} while ( current != outline[0] && outline.Count < (maxX - minX + maxY - minY) * 2 );
return outline;
}
private bool IsEdgePosition( Vector2Int pos, HashSet<Vector2Int> tileSet, int minX, int maxX, int minY, int maxY )
{
// Position must be on the bounding box
if ( pos.x < minX || pos.x > maxX || pos.y < minY || pos.y > maxY )
return false;
// Must have at least one adjacent tile in our set and one adjacent empty space
bool hasAdjacent = false;
bool hasEmpty = false;
var adjacent = new[]
{
pos + Vector2Int.Up,
pos + Vector2Int.Right,
pos + Vector2Int.Down,
pos + Vector2Int.Left
};
foreach ( var adj in adjacent )
{
if ( tileSet.Contains( adj ) )
hasAdjacent = true;
else
hasEmpty = true;
if ( hasAdjacent && hasEmpty )
return true;
}
return false;
}
private Vector2Int TurnRight( Vector2Int dir )
{
return new Vector2Int( -dir.y, dir.x );
}
private Vector2Int TurnLeft( Vector2Int dir )
{
return new Vector2Int( dir.y, -dir.x );
}
public bool IsOwned( Vector3 worldPos )
{
var gridPos = GridManager.WorldToGridPosition( worldPos );
return IsOwned( gridPos );
}
// otherwise the implicit Vector3 conversion will fuck ME or YOU
public bool IsOwned( Vector3Int worldPos ) => IsOwned( new Vector2Int( worldPos ) );
public bool IsOwned( Vector2Int gridPos )
{
return OwnedTiles.Contains( gridPos );
}
protected override void DrawGizmos()
{
Gizmo.Transform = new Transform( 0 );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = Color.Green;
if ( !Gizmo.IsSelected ) return;
var startingWorldPosition = GridManager.GridToWorldPosition( StartingZonePosition );
var bbox = new BBox( startingWorldPosition.WithZ( 420 ), startingWorldPosition + new Vector3( StartingZoneSize.x * GridManager.GridSize, StartingZoneSize.y * GridManager.GridSize, 420f ) );
Gizmo.Draw.LineBBox( bbox );
Gizmo.Draw.Color = Color.Green.WithAlpha( 0.5f );
Gizmo.Draw.SolidBox( bbox );
}
ImmutableHashSet<Vector2Int> ISaveDataProperty<ImmutableHashSet<Vector2Int>>.WriteValue( Scene scene ) =>
OwnedChunks.ToImmutableHashSet();
void ISaveDataProperty<ImmutableHashSet<Vector2Int>>.ReadValue( Scene scene, ImmutableHashSet<Vector2Int> chunks )
{
Clear();
foreach ( var chunk in chunks )
{
AddChunkAt( chunk );
}
}
}
public struct ZoneTile
{
public Vector2Int GridPos;
public ZoneTile( Vector2Int pos ) => GridPos = pos;
public Vector3 WorldCenter => GridManager.GridToWorldPosition( GridPos ) + new Vector3( GridManager.GridSize / 2, GridManager.GridSize / 2, 0 );
}