Map instance for the skate game that manages grind path reconstruction and map changes. It captures grind_path and grind_path_node entities on map load, attempts several strategies to rebuild missing path node link data (parentname grouping, hammerUniqueId ordering, and geometric/proximity reconstruction), and exposes a map change RPC/console command.
using Skateboard.Entities;
using System;
namespace Skateboard;
public sealed class SkaterMap : MapInstance
{
public static SkaterMap Current { get; set; }
/// <summary>
/// If a recompiled map lost its grind_path "pathNodesJSON" (so the paths come through
/// empty), rebuild grind rails by connecting the loose grind_path_node entities.
/// </summary>
[Property] public bool GrindAutoConnect { get; set; } = true;
/// <summary>How many nearest nodes each node links to. 2 reconstructs simple rails; raise to fill gaps.</summary>
[Property] public int GrindAutoConnectNeighbors { get; set; } = 2;
/// <summary>Maximum link length - just a safety cap, since only the nearest neighbours are linked.</summary>
[Property] public float GrindAutoConnectDistance { get; set; } = 600f;
[Property] public bool GrindAutoConnectLineOfSight { get; set; } = false;
private bool _grindPathsChecked;
// Captured during map load so we can recover grind paths from the Hammer hierarchy
// (node.parentname -> path.targetname) when the pathNodesJSON keyvalue was lost.
private readonly List<(GrindPathEntity path, string targetName, int uid)> _grindPathInfo = new();
private readonly List<(GameObject node, string parentName, int uid)> _grindNodeInfo = new();
// Raw keyvalue dumps for diagnosing what linkage data survived the recompile.
private readonly List<string> _grindNodeDumps = new();
private readonly List<string> _grindPathDumps = new();
protected override void OnAwake()
{
Current = this;
}
protected override void OnUpdate()
{
base.OnUpdate();
// Wait until the map has fully loaded so every grind_path_node exists.
if ( !_grindPathsChecked && IsLoaded && Game.IsPlaying )
{
_grindPathsChecked = true;
TryReconstructGrindPaths();
}
}
[ConCmd( "map", ConVarFlags.Admin )]
private static void Command_ChangeMap( string mapName )
{
Current?.ChangeMap( mapName );
}
[Rpc.Broadcast]
public void ChangeMap( string mapName )
{
if ( !Networking.IsHost )
return;
MapName = mapName;
foreach ( var pawn in Scene.GetAllComponents<Skateboard.Player.SkatePawn>() )
{
pawn.Respawn();
}
}
protected override void OnCreateObject( GameObject go, MapLoader.ObjectEntry kv )
{
if ( !Game.IsPlaying )
return;
if ( kv.TypeName == "grind_path" )
{
var json = kv.GetValue<string>( "pathNodesJSON" );
var path = go.AddComponent<GrindPathEntity>();
path.SetPathNodesJson( json );
int.TryParse( kv.GetValue<string>( "hammerUniqueId" ), out var pathUid );
_grindPathInfo.Add( (path, kv.TargetName, pathUid) );
if ( _grindPathDumps.Count < 6 )
_grindPathDumps.Add( DumpEntity( kv ) );
}
if ( kv.TypeName == "grind_path_node" )
{
go.AddComponent<GrindPathNodeEntity>();
int.TryParse( kv.GetValue<string>( "hammerUniqueId" ), out var nodeUid );
_grindNodeInfo.Add( (go, kv.ParentName, nodeUid) );
if ( _grindNodeDumps.Count < 10 )
_grindNodeDumps.Add( DumpEntity( kv ) );
}
}
// Probe the keyvalues that path/node entities commonly use to chain together, so we can
// see what linkage (if any) survived the recompile rather than guessing from geometry.
private static string DumpEntity( MapLoader.ObjectEntry kv )
{
string[] keys =
{
"targetname", "parentname", "target", "nextnode", "next", "nextkey",
"node", "node01", "next_node", "prevnode", "path", "pathid", "id",
"index", "order", "hammerUniqueId", "pathNodesJSON"
};
var found = new List<string>();
foreach ( var key in keys )
{
var v = kv.GetValue<string>( key );
if ( !string.IsNullOrWhiteSpace( v ) )
found.Add( $"{key}={v}" );
}
return $"pos={kv.Position} | {(found.Count > 0 ? string.Join( ", ", found ) : "(no recognised keys)")}";
}
private void TryReconstructGrindPaths()
{
if ( !GrindAutoConnect )
return;
// If any grind path already has usable nodes, the map's data is intact - leave it.
foreach ( var path in Scene.GetAllComponents<GrindPathEntity>() )
{
path.RefreshPathNodes();
if ( path.PathNodes is { Count: >= 2 } )
return;
}
// Best case: recover the exact original grouping from the Hammer hierarchy
// (each grind_path_node's parentname points at its grind_path's targetname).
int recovered = ReconstructFromParentNames();
if ( recovered > 0 )
{
Log.Info( $"[Skate] Recovered {recovered} grind path(s) from Hammer parent links." );
return;
}
// Next best: hammerUniqueId is sequential and each path is immediately followed by its
// own nodes, so sorting by id recovers the exact grouping and order.
recovered = ReconstructFromHammerIds();
if ( recovered > 0 )
{
Log.Info( $"[Skate] Recovered {recovered} grind path(s) from sequential hammer ids." );
return;
}
Log.Info( "[Skate] No node linkage found - falling back to proximity reconstruction." );
Log.Info( $"[Skate][diag] grind_path entities: {_grindPathInfo.Count}, grind_path_node entities: {_grindNodeInfo.Count}" );
Log.Info( "[Skate][diag] --- sample grind_path keyvalues ---" );
foreach ( var dump in _grindPathDumps )
Log.Info( $"[Skate][diag] path: {dump}" );
Log.Info( "[Skate][diag] --- sample grind_path_node keyvalues ---" );
foreach ( var dump in _grindNodeDumps )
Log.Info( $"[Skate][diag] node: {dump}" );
var nodes = Scene.GetAllComponents<GrindPathNodeEntity>()
.Select( n => n.GameObject )
.Where( go => go.IsValid() )
.ToList();
if ( nodes.Count < 2 )
return;
var chains = BuildGrindChains( nodes );
if ( chains.Count == 0 )
return;
var container = new GameObject( GameObject, true, "ReconstructedGrindPaths" );
foreach ( var chain in chains )
{
var pathObject = new GameObject( container, true, "GrindPath" );
pathObject.Components.GetOrCreate<GrindPathEntity>().PathNodes = chain;
}
Log.Info( $"[Skate] Grind path data was missing - reconstructed {chains.Count} rail(s) from {nodes.Count} loose nodes." );
}
/// <summary>
/// Recover grind paths from the Hammer parent hierarchy: group each grind_path_node under
/// the grind_path whose targetname matches its parentname, then order the group into a rail.
/// Returns how many paths were rebuilt this way (0 if the nodes weren't parented).
/// </summary>
private int ReconstructFromParentNames()
{
if ( _grindNodeInfo.Count == 0 || _grindPathInfo.Count == 0 )
return 0;
var byParent = new Dictionary<string, List<GameObject>>( StringComparer.OrdinalIgnoreCase );
foreach ( var (node, parentName, _) in _grindNodeInfo )
{
if ( !node.IsValid() || string.IsNullOrWhiteSpace( parentName ) )
continue;
if ( !byParent.TryGetValue( parentName, out var list ) )
{
list = new List<GameObject>();
byParent[parentName] = list;
}
list.Add( node );
}
if ( byParent.Count == 0 )
return 0;
int recovered = 0;
foreach ( var (path, targetName, _) in _grindPathInfo )
{
if ( !path.IsValid() || string.IsNullOrWhiteSpace( targetName ) )
continue;
if ( !byParent.TryGetValue( targetName, out var group ) || group.Count < 2 )
continue;
path.PathNodes = OrderNodes( group );
recovered++;
}
return recovered;
}
/// <summary>
/// Recover paths from sequential hammerUniqueId: entities are created path-then-its-nodes,
/// so sorting by id and assigning each node to the most recent path rebuilds the exact rails
/// in their original order - no geometry guessing.
/// </summary>
private int ReconstructFromHammerIds()
{
var items = new List<(int uid, GrindPathEntity path, GameObject node)>();
foreach ( var (path, _, uid) in _grindPathInfo )
{
if ( uid > 0 && path.IsValid() )
items.Add( (uid, path, null) );
}
foreach ( var (node, _, uid) in _grindNodeInfo )
{
if ( uid > 0 && node.IsValid() )
items.Add( (uid, null, node) );
}
if ( items.Count == 0 )
return 0;
items.Sort( ( a, b ) => a.uid.CompareTo( b.uid ) );
int recovered = 0;
GrindPathEntity current = null;
List<GameObject> currentNodes = null;
void Commit()
{
if ( current.IsValid() && currentNodes is { Count: >= 2 } )
{
current.PathNodes = currentNodes;
recovered++;
}
}
foreach ( var (_, path, node) in items )
{
if ( path is not null )
{
Commit();
current = path;
currentNodes = new List<GameObject>();
}
else if ( currentNodes is not null && node.IsValid() )
{
currentNodes.Add( node );
}
}
Commit();
return recovered;
}
/// <summary>
/// Order a single rail's nodes into a polyline: start at one extreme end (the farthest-apart
/// pair) and walk nearest-neighbour. Unambiguous because the group is already one rail.
/// </summary>
private static List<GameObject> OrderNodes( List<GameObject> group )
{
int n = group.Count;
var pos = group.Select( g => g.WorldPosition ).ToArray();
// Farthest-apart pair => the two rail ends.
int endA = 0, endB = 1;
float best = -1f;
for ( int i = 0; i < n; i++ )
{
for ( int j = i + 1; j < n; j++ )
{
var d = Vector3.DistanceBetween( pos[i], pos[j] );
if ( d > best )
{
best = d;
endA = i;
endB = j;
}
}
}
var used = new bool[n];
var ordered = new List<GameObject>( n );
int current = endA;
used[current] = true;
ordered.Add( group[current] );
for ( int step = 1; step < n; step++ )
{
int next = -1;
float nd = float.MaxValue;
for ( int j = 0; j < n; j++ )
{
if ( used[j] )
continue;
var d = Vector3.DistanceBetween( pos[current], pos[j] );
if ( d < nd )
{
nd = d;
next = j;
}
}
if ( next == -1 )
break;
used[next] = true;
ordered.Add( group[next] );
current = next;
}
return ordered;
}
/// <summary>
/// Reconstruct rails from loose nodes using directional linking: each node connects to its
/// nearest neighbour and its nearest roughly-opposite neighbour (the collinear continuation),
/// which follows rails straight and avoids perpendicular cross-links. Every link is then
/// covered by a trail so no node is left disconnected. Distance is only a cap.
/// </summary>
private List<List<GameObject>> BuildGrindChains( List<GameObject> nodes )
{
int count = nodes.Count;
var positions = nodes.Select( n => n.WorldPosition ).ToArray();
int k = Math.Max( 1, GrindAutoConnectNeighbors );
// Build an undirected adjacency graph from each node's K nearest in-range neighbours.
var adjacency = new List<int>[count];
for ( int i = 0; i < count; i++ )
adjacency[i] = new List<int>();
void AddEdge( int a, int b )
{
if ( !adjacency[a].Contains( b ) ) adjacency[a].Add( b );
if ( !adjacency[b].Contains( a ) ) adjacency[b].Add( a );
}
for ( int i = 0; i < count; i++ )
{
var found = new List<(int idx, float dist)>();
for ( int j = 0; j < count; j++ )
{
if ( i == j )
continue;
var dist = Vector3.DistanceBetween( positions[i], positions[j] );
if ( dist > GrindAutoConnectDistance )
continue;
if ( GrindAutoConnectLineOfSight && IsGrindLinkBlocked( positions[i], positions[j] ) )
continue;
found.Add( (j, dist) );
}
if ( found.Count == 0 )
continue;
found.Sort( ( a, b ) => a.dist.CompareTo( b.dist ) );
// Directional linking. Link 1 is the nearest neighbour. Link 2 is the nearest
// *roughly opposite* neighbour - the collinear continuation down the rail - which
// avoids 90-degree jumps onto parallel rails. Extra links (k > 2) pick up junction
// branches that head off in a distinct direction.
var linkDirs = new List<Vector3>();
int nearest = found[0].idx;
AddEdge( i, nearest );
var dirNearest = (positions[nearest] - positions[i]).Normal;
linkDirs.Add( dirNearest );
for ( int t = 1; t < found.Count; t++ )
{
var dir = (positions[found[t].idx] - positions[i]).Normal;
if ( Vector3.Dot( dirNearest, dir ) < -0.5f )
{
AddEdge( i, found[t].idx );
linkDirs.Add( dir );
break;
}
}
for ( int t = 1; t < found.Count && linkDirs.Count < k; t++ )
{
var dir = (positions[found[t].idx] - positions[i]).Normal;
bool distinct = true;
foreach ( var existing in linkDirs )
{
if ( Vector3.Dot( dir, existing ) > 0.5f )
{
distinct = false;
break;
}
}
if ( distinct )
{
AddEdge( i, found[t].idx );
linkDirs.Add( dir );
}
}
}
// Cover every edge with a trail. A node may appear in several trails (at junctions),
// so unlike a one-pass chain walk this never drops a rail.
var used = new HashSet<long>();
long EdgeKey( int a, int b ) => a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a;
var trails = new List<List<GameObject>>();
for ( int start = 0; start < count; start++ )
{
foreach ( var first in adjacency[start] )
{
if ( used.Contains( EdgeKey( start, first ) ) )
continue;
var trail = new List<int> { start, first };
used.Add( EdgeKey( start, first ) );
Extend( trail, forward: true ); // walk on from 'first'
Extend( trail, forward: false ); // walk back from 'start'
trails.Add( trail.Select( idx => nodes[idx] ).ToList() );
}
}
return trails;
void Extend( List<int> trail, bool forward )
{
int cur = forward ? trail[^1] : trail[0];
int prev = forward ? trail[^2] : trail[1];
while ( true )
{
int next = -1;
foreach ( var cand in adjacency[cur] )
{
if ( cand == prev )
continue;
if ( used.Contains( EdgeKey( cur, cand ) ) )
continue;
next = cand;
break;
}
if ( next == -1 )
break;
used.Add( EdgeKey( cur, next ) );
if ( forward ) trail.Add( next );
else trail.Insert( 0, next );
prev = cur;
cur = next;
}
}
}
private bool IsGrindLinkBlocked( Vector3 a, Vector3 b )
{
return Scene.Trace.Ray( a + Vector3.Up * 4f, b + Vector3.Up * 4f )
.WithAnyTags( "solid", "playerclip", "passbullets", "unskateable" )
.Run()
.Hit;
}
}