Editor/Layers/ArchLayerAssets.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
public enum ArchAssemblyKind {
Group,
Walkway,
Passage,
SharedCore,
}
public enum ArchAssetKind {
Building,
Network,
Assembly,
}
// A site-level relationship: a walkway between two buildings, a shared core, any group that must
// be authored as one thing without belonging to one owner. Children reference layers by id - the
// referenced layers keep their ownership homes, the assembly is the explicit relationship.
public sealed class ArchSiteAssembly : IArchCollides, IArchNamed {
// A group is a scope for this too: a folder of far-off houses set to None takes every part in them with it, and a
// member with its own answer still keeps it.
public ArchCollisionMode? Collision { get; set; }
public int Id { get; set; }
public string Name { get; set; } = "Assembly";
public ArchAssemblyKind Kind { get; set; }
public List<int> Children { get; set; } = new();
public ArchPalette Palette { get; set; } = new();
// How the affector in this group merges with what it reaches. The group is the relationship,
// so the terms of the join are stated on it once rather than at each end.
public ArchMergeSpec Merge { get; set; } = new();
}
// The terms of a merge, edited from the connector that owns them.
public sealed class ArchMergeSpec {
public bool OpenWalls { get; set; } = true;
// Zero takes the connector's own clear height.
public float Clearance { get; set; }
public bool NearEnd { get; set; } = true;
public bool FarEnd { get; set; } = true;
}
// A placed copy of a reusable authored asset: the asset is the source, this record is where it
// stands. Make Local replaces it with the expanded subtree.
public sealed class ArchAssetInstance : IArchNamed {
public int Id { get; set; }
public string Name { get; set; } = "Instance";
public ArchAssetKind Kind { get; set; }
public string AssetPath { get; set; } = "";
public Vector2 Position { get; set; }
}
// A cross-layer anchor shown in the tree: the assembly's link to a named port on another layer.
public sealed class ArchLayerReference {
public string SourcePort { get; set; } = "";
public int TargetId { get; set; }
public string TargetPort { get; set; } = "";
}
// One authored building, saved whole and placed again. The unit carries every layer and subtool standing in
// it; the three record lists carry what the author SAID about those layers - a rename, a disabled row, a
// piece given a collision mode of its own - which live on the plan rather than on the unit and would be lost
// by copying the unit alone.
public sealed class ArchBuildingAsset {
public string Name { get; set; } = "";
public string Title { get; set; } = "";
public ArchBuilding Unit { get; set; }
public List<ArchLayerRecord> Layers { get; set; } = new();
public List<ArchPartRecord> Parts { get; set; } = new();
public List<ArchLayerLink> Links { get; set; } = new();
[System.Text.Json.Serialization.JsonIgnore]
public bool IsUsable => Unit is { Rooms.Count: > 0 };
}
// Reusable authored-plan assets: a finished building, road network or assembly saved to JSON and
// placed again with fresh ids. The archetype stays a rule template; this is an exact authored plan.
public static class ArchLayerAssets {
public static string BuildingDirectory => ArchDataRoot.For( "buildings" );
public static string NetworkDirectory => ArchDataRoot.For( "infrastructure" );
public static string AssemblyDirectory => ArchDataRoot.For( "assemblies" );
const string BuildingSuffix = ".archbuilding.json";
// Saving is reached from the layer stack as well as from the shelf that lists them, so the shelf cannot
// know it went stale by watching its own buttons. It watches this instead.
public static int Revision { get; private set; }
public static string BuildingPathFor( string name ) => $"{BuildingDirectory}/{Slug( name, "building" )}{BuildingSuffix}";
public static string NetworkPathFor( string name ) => $"{NetworkDirectory}/{Slug( name, "network" )}.archnetwork.json";
public static string AssemblyPathFor( string name ) => $"{AssemblyDirectory}/{Slug( name, "assembly" )}.archassembly.json";
// Authored at its own origin, so a placement decides where it stands rather than inheriting where it
// happened to be drawn.
public static ArchBuildingAsset Capture( ArchPlan plan, ArchBuilding building, string name ) {
if ( building is null || string.IsNullOrWhiteSpace( name ) ) {
return null;
}
var copy = ArchStorage.Deserialize<ArchBuilding>( ArchStorage.Serialize( building ) );
if ( copy is null ) {
return null;
}
ArchCarry.Shift( copy, -Origin( building ) );
var owned = ArchLayerIds.Owned( ArchStorage.Serialize( copy ) );
return new ArchBuildingAsset {
Name = Slug( name, "building" ),
Title = name.Trim(),
Unit = copy,
Layers = Held( plan?.Layers, owned ),
Parts = plan?.Parts.Where( record => owned.Contains( record.ItemId ) ).ToList() ?? new List<ArchPartRecord>(),
Links = plan?.Links.Where( link => owned.Contains( link.SourceId ) && owned.Contains( link.TargetId ) ).ToList() ?? new List<ArchLayerLink>()
};
}
public static string SaveBuilding( ArchPlan plan, ArchBuilding building, string name ) {
if ( Capture( plan, building, name ) is not { } asset ) {
return null;
}
var path = BuildingPathFor( asset.Name );
if ( !ArchStorage.WriteDocument( path, asset ) ) {
return null;
}
Revision++;
return path;
}
// A parent standing outside the copy - a group two buildings share - cannot come along, so the row it
// named is filed at the top of the stack instead of under an id the target plan never had.
static List<ArchLayerRecord> Held( List<ArchLayerRecord> records, IReadOnlySet<int> owned ) {
if ( records is null ) {
return new List<ArchLayerRecord>();
}
return records
.Where( record => owned.Contains( record.ItemId ) )
.Select( record => new ArchLayerRecord {
ItemId = record.ItemId,
ParentId = owned.Contains( record.ParentId ) ? record.ParentId : 0,
Kind = record.Kind,
Stage = record.Stage,
Order = record.Order,
Enabled = record.Enabled,
Locked = record.Locked
} )
.ToList();
}
public static List<ArchBuildingAsset> Buildings() {
var loaded = new List<ArchBuildingAsset>();
foreach ( var file in ArchStorage.DocumentFiles( BuildingDirectory, BuildingSuffix ) ) {
if ( ArchStorage.ReadDocument<ArchBuildingAsset>( $"{BuildingDirectory}/{file}" ) is { IsUsable: true } asset ) {
loaded.Add( asset );
}
}
return loaded.OrderBy( asset => asset.Title ).ToList();
}
// Authored by hand and by hand alone, so removing one is deleting it - there is no shipped building
// underneath that a hidden flag would have to uncover.
public static bool RemoveBuilding( string name ) {
if ( string.IsNullOrWhiteSpace( name ) || !ArchStorage.DeleteDocument( BuildingPathFor( name ) ) ) {
return false;
}
Revision++;
return true;
}
public static string SaveNetwork( IReadOnlyList<ArchRoadPart> roads, string name ) {
if ( roads is null || roads.Count == 0 || string.IsNullOrWhiteSpace( name ) ) {
return null;
}
var origin = roads[0].Nodes.Count > 0 ? new Vector2( roads[0].Nodes[0].Position.x, roads[0].Nodes[0].Position.y ) : Vector2.Zero;
var copy = new List<ArchRoadPart>();
foreach ( var road in roads ) {
var cloned = ArchStorage.Deserialize<ArchRoadPart>( ArchStorage.Serialize( road ) );
Shift( cloned, -origin );
copy.Add( cloned );
}
var path = NetworkPathFor( name );
return ArchStorage.WriteAsset( path, ArchStorage.Serialize( copy ) ) ? path : null;
}
public static string SaveAssembly( ArchSiteAssembly assembly, string name ) {
if ( assembly is null || string.IsNullOrWhiteSpace( name ) ) {
return null;
}
var path = AssemblyPathFor( name );
return ArchStorage.WriteAsset( path, ArchStorage.Serialize( assembly ) ) ? path : null;
}
static Vector2 Origin( ArchBuilding building ) {
return ArchHandles.Bounds( building, out var min, out _ ) ? min : Vector2.Zero;
}
static void Shift( ArchRoadPart road, Vector2 shift ) {
foreach ( var node in road.Nodes ) {
node.Position += new Vector3( shift.x, shift.y, 0f );
}
}
static string Slug( string name, string fallback ) {
var slug = new string( (name ?? "")
.Trim()
.ToLowerInvariant()
.Select( character => char.IsLetterOrDigit( character ) ? character : '_' )
.ToArray() );
while ( slug.Contains( "__" ) ) {
slug = slug.Replace( "__", "_" );
}
slug = slug.Trim( '_' );
return string.IsNullOrWhiteSpace( slug ) ? fallback : slug;
}
}