Rides/TrackRides/TrackElementDefinition.cs
using System;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace HC3.Rides;
#nullable enable
/// <summary>
/// Describes the general shape of a track element. Will generate multiple <see cref="TrackElement"/>s for all possible
/// axis flips, and all allowed combinations of start and end banking. These are accessed through <see cref="Elements"/>.
/// </summary>
[AssetType( Name = "Track Element Definition", Extension = "trkelem" )]
public sealed class TrackElementDefinition : GameResource
{
private static TrackElementDefinition? _default;
private static TrackElementDefinition? _station;
protected override Bitmap CreateAssetTypeIcon( int width, int height )
{
return CreateSimpleAssetTypeIcon( "shortcut", width, height );
}
/// <summary>
/// Flat, level piece of normal track.
/// </summary>
public static TrackElementDefinition Default =>
_default ??= ResourceLibrary.Get<TrackElementDefinition>( "resources/tracks/elements/default.trkelem" ) ?? new TrackElementDefinition();
/// <summary>
/// Station platform element.
/// </summary>
public static TrackElementDefinition Station =>
_station ??= ResourceLibrary.Get<TrackElementDefinition>( "resources/tracks/elements/station.trkelem" ) ?? Default;
private ImmutableArray<TrackElement>? _elements;
private TrackBanking _startMinBanking = TrackBanking.VerticalLeft;
private TrackBanking _startMaxBanking = TrackBanking.VerticalRight;
private TrackBanking _endMinBanking = TrackBanking.VerticalLeft;
private TrackBanking _endMaxBanking = TrackBanking.VerticalRight;
private int _minBankingChange = 0;
private int _maxBankingChange = 1;
private TrackIncline _startIncline = TrackIncline.None;
private TrackIncline _endIncline = TrackIncline.None;
private TrackHeading _endHeading = TrackHeading.Forward;
private Vector3Int _endOffset = new( 2, 0, 0 );
private bool _endInverted;
/// <summary>
/// Base player-facing name of elements created from this definition.
/// </summary>
[Group( "Display" )]
public string Title { get; set; } = "Track";
/// <summary>
/// Describes the size of this element, to help categorize it in the <see cref="TrackBuilder"/> UI.
/// </summary>
[Group( "Display" )]
public TrackElementSize Size { get; set; }
/// <summary>
/// Do we need to create variants of this element with the X axis flipped?
/// </summary>
[JsonIgnore]
public bool CanFlipX
{
get
{
// TODO
if ( CustomSpline ) return false;
return StartIncline != TrackIncline.None
|| EndIncline != TrackIncline.None
|| EndInverted;
}
}
/// <summary>
/// Do we need to create variants of this element with the Y axis flipped?
/// </summary>
[JsonIgnore] public bool CanFlipY => Turn is not TurnDirection.Straight;
/// <summary>
/// Do we need to create variants of this element with the Z axis flipped?
/// </summary>
[JsonIgnore] public bool CanFlipZ => CustomSpline ? CustomIncline != TrackIncline.None : StartIncline != EndIncline && !EndInverted;
/// <summary>
/// Descriptive turn direction to help filter track elements in the <see cref="TrackBuilder"/>.
/// </summary>
[JsonIgnore]
public TurnDirection? Turn => TrackElement.GetTurnDirection( EndHeading, EndOffset, false, EndInverted, false );
[Group( "Spline" )]
public bool CustomSpline { get; set; }
[Group( "Spline" ), ShowIf( nameof( CustomSpline ), true )]
public float StartTangentScale { get; set; } = 1f;
[Group( "Spline" ), ShowIf( nameof( CustomSpline ), true )]
public float EndTangentScale { get; set; } = 1f;
[Group( "Spline" ), ShowIf( nameof( CustomSpline ), true )]
public List<TrackSplinePoint> MidPoints { get; set; } = new();
[Group( "Spline" ), Title( "Track Builder Incline" ), ShowIf( nameof( CustomSpline ), true )]
public TrackIncline CustomIncline { get; set; }
public class TrackSplinePoint
{
[KeyProperty]
public Vector3 Position { get; set; }
public Rotation Rotation { get; set; } = Rotation.Identity;
public float TangentScale { get; set; } = 1f;
}
/// <summary>
/// How far left this element can be banking at the start.
/// </summary>
[Group( "Banking" )]
public TrackBanking StartMinBanking
{
get => _startMinBanking;
set
{
_startMinBanking = value;
InvalidateElements();
}
}
/// <summary>
/// How far right this element can be banking at the start.
/// </summary>
[Group( "Banking" )]
public TrackBanking StartMaxBanking
{
get => _startMaxBanking;
set
{
_startMaxBanking = value;
InvalidateElements();
}
}
/// <summary>
/// How far left this element can be banking at the end.
/// </summary>
[Group( "Banking" )]
public TrackBanking EndMinBanking
{
get => _endMinBanking;
set
{
_endMinBanking = value;
InvalidateElements();
}
}
/// <summary>
/// How far right this element can be banking at the end.
/// </summary>
[Group( "Banking" )]
public TrackBanking EndMaxBanking
{
get => _endMaxBanking;
set
{
_endMaxBanking = value;
InvalidateElements();
}
}
/// <summary>
/// How little the banking can change between the start and end of this element.
/// </summary>
[Group( "Banking" )]
public int MinBankingChange
{
get => _minBankingChange;
set
{
_minBankingChange = value;
InvalidateElements();
}
}
/// <summary>
/// How much the banking can change between the start and end of this element.
/// </summary>
[Group( "Banking" )]
public int MaxBankingChange
{
get => _maxBankingChange;
set
{
_maxBankingChange = value;
InvalidateElements();
}
}
/// <summary>
/// Incline of the track at the start of this element.
/// </summary>
[Group( "Incline" )]
public TrackIncline StartIncline
{
get => _startIncline;
set
{
_startIncline = value;
InvalidateElements();
}
}
/// <summary>
/// Incline of the track at the end of this element.
/// </summary>
[Group( "Incline" )]
public TrackIncline EndIncline
{
get => _endIncline;
set
{
_endIncline = value;
InvalidateElements();
}
}
/// <summary>
/// Gets the final heading of this element, relative to <see cref="TrackHeading.Forward"/>.
/// </summary>
[Group( "End" )]
public TrackHeading EndHeading
{
get => _endHeading;
set
{
_endHeading = value;
InvalidateElements();
}
}
/// <summary>
/// Gets how much this element moves the track spline, in track units.
/// Track grid units are at twice the resolution of terrain grid units.
/// </summary>
[Group( "End" )]
public Vector3Int EndOffset
{
get => _endOffset;
set
{
_endOffset = value;
InvalidateElements();
}
}
/// <summary>
/// Does this element end upside down?
/// </summary>
[Group( "End" )]
public bool EndInverted
{
get => _endInverted;
set
{
_endInverted = value;
InvalidateElements();
}
}
/// <summary>
/// Does this element have a special function, like a lift hill or station platform?
/// </summary>
[Group( "Special" )]
public TrackFeature Feature { get; set; }
/// <summary>
/// All valid reflections and banking variations generated from this definition.
/// </summary>
[JsonIgnore]
public ImmutableArray<TrackElement> Elements =>
_elements ??= GeneratePermutations().ToImmutableArray();
private readonly record struct Metrics( float Length, float Length2D, ImmutableArray<float> TileBoundaries );
private Metrics? _metrics;
[JsonIgnore]
public float Length => (_metrics ??= CalculateMetrics()).Length;
[JsonIgnore]
public float Length2D => (_metrics ??= CalculateMetrics()).Length2D;
[JsonIgnore]
public ImmutableArray<float> TileBoundaries => (_metrics ??= CalculateMetrics()).TileBoundaries;
/// <summary>
/// Regenerate <see cref="Elements"/> next time it is accessed.
/// </summary>
private void InvalidateElements()
{
_elements = null;
_metrics = null;
}
/// <summary>
/// Generate all valid reflections and banking variations of this definition.
/// </summary>
private IEnumerable<TrackElement> GeneratePermutations()
{
IReadOnlyList<bool> flip = [false, true];
var bankings = GetBankings()
.OrderBy( x => x.Start.Ordinal() )
.ThenBy( x => x.End.Ordinal() );
return
from flipX in CanFlipX ? flip : [false]
from flipY in CanFlipY ? flip : [false]
from flipZ in CanFlipZ ? flip : [false]
from banking in bankings
select CreateElement( flipX, flipY, flipZ, banking.Start, banking.End );
}
private Metrics CalculateMetrics()
{
if ( Elements.Length == 0 ) return default;
var spline = new Spline();
var element = Elements.First();
var start = new TrackNode( default, new TrackOrientation( default, element.StartIncline, element.StartBanking, element.StartInverted ) );
spline.AddTrack( start, [element] );
var length2D = 0f;
var prev = spline.SampleAtDistance( 0f );
var tileBoundaries = new List<float> { 0f };
var stepCount = Math.Max( 1, (int)MathF.Round( spline.Length ) );
var stepSize = spline.Length / stepCount;
for ( var i = 1; i <= stepCount; ++i )
{
var next = spline.SampleAtDistance( i * stepSize );
length2D += (next.Position - prev.Position).WithZ( 0f ).Length;
prev = next;
}
var tileCount = Math.Max( 1, (int)MathF.Round( length2D / GridManager.GridSize ) );
var tileSize = length2D / tileCount;
var sinceTileBoundary = 0f;
prev = spline.SampleAtDistance( 0f );
for ( var i = 1; i <= stepCount; ++i )
{
var next = spline.SampleAtDistance( i * stepSize );
sinceTileBoundary += (next.Position - prev.Position).WithZ( 0f ).Length;
if ( sinceTileBoundary >= tileSize || i == stepCount )
{
sinceTileBoundary -= tileSize;
tileBoundaries.Add( i * stepSize );
}
prev = next;
}
return new Metrics( spline.Length, length2D, tileBoundaries.ToImmutableArray() );
}
/// <summary>
/// Get all pairs of start and end banking for this element.
/// </summary>
private IEnumerable<(TrackBanking Start, TrackBanking End)> GetBankings()
{
for ( var start = StartMinBanking; start <= StartMaxBanking; ++start )
{
for ( var end = EndMinBanking; end <= EndMaxBanking; ++end )
{
if ( Math.Abs( start - end ) < MinBankingChange ) continue;
if ( Math.Abs( start - end ) > MaxBankingChange ) continue;
yield return (start, end);
}
}
}
/// <summary>
/// Helper to create an element from this definition with the given axis flips
/// and banking values.
/// </summary>
private TrackElement CreateElement(
bool flipX, bool flipY, bool flipZ,
TrackBanking startBanking, TrackBanking endBanking )
{
if ( flipX )
{
// When flipping on the X axis (forward / backward), the end becomes the start
(startBanking, endBanking) = (endBanking.Inverse(), startBanking.Inverse());
}
if ( flipY )
{
// When flipping on the Y axis (left / right), banking gets inverted
(startBanking, endBanking) = (startBanking.Inverse(), endBanking.Inverse());
}
var flags =
(flipX ? TrackElementFlags.FlipX : 0) |
(flipY ? TrackElementFlags.FlipY : 0) |
(flipZ ? TrackElementFlags.FlipZ : 0);
return new TrackElement( this, flags, startBanking, endBanking );
}
/// <summary>
/// Attempts to find a matching valid element with the given axis flips
/// and banking values.
/// </summary>
public TrackElement? GetElement(
TrackElementFlags flags,
TrackBanking startBanking,
TrackBanking endBanking )
{
foreach ( var element in Elements )
{
if ( element.Flags == flags && element.StartBanking == startBanking && element.EndBanking == endBanking )
{
return element;
}
}
return null;
}
}