TerrorTownDoorCompat.cs

Compatibility helper that converts MissingComponent stubs of type TerrorTown.FuncDoorRotating into the engine Door component. It reads serialized JSON from stubs, maps properties (lock, auto-close, pivot, target angle, partner link) onto a new Door component, and wires paired doors by GameObject id.

File Access
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Sandbox.Mapping;

namespace Sandbox;

/// <summary>
/// Converts TerrorTown <c>FuncDoorRotating</c> stubs (loaded as <see cref="MissingComponent"/>)
/// into <see cref="Door"/> so maps work without the TerrorTown package.
/// </summary>
public static class TerrorTownDoorCompat
{
	const string FuncDoorRotatingType = "TerrorTown.FuncDoorRotating";

	public static void ConvertAll( GameObject mapRoot )
	{
		if ( !mapRoot.IsValid() )
			return;

		var pendingPartners = new List<(Door Door, string PartnerId)>();
		var convertedByGoId = new Dictionary<string, Door>();

		var missing = mapRoot.Components
			.GetAll<MissingComponent>( FindMode.EverythingInSelfAndDescendants )
			.ToArray();

		foreach ( var stub in missing )
		{
			if ( !TryConvert( stub, out var door, out var partnerId ) )
				continue;

			convertedByGoId[door.GameObject.Id.ToString()] = door;

			if ( !string.IsNullOrWhiteSpace( partnerId ) )
				pendingPartners.Add( (door, partnerId) );
		}

		foreach ( var (door, partnerId) in pendingPartners )
		{
			if ( convertedByGoId.TryGetValue( partnerId, out var partner ) )
				door.LinkedDoor = partner;
		}
	}

	static bool TryConvert( MissingComponent stub, out Door door, out string partnerId )
	{
		door = null;
		partnerId = null;

		var json = stub.GetJson();
		if ( json is null )
			return false;

		var type = json["__type"]?.GetValue<string>();
		if ( type != FuncDoorRotatingType )
			return false;

		var go = stub.GameObject;
		if ( !go.IsValid() )
			return false;

		if ( go.GetComponent<Door>().IsValid() )
		{
			stub.Destroy();
			return false;
		}

		door = go.AddComponent<Door>();
		door.Mode = Door.DoorMode.Rotating;
		door.IsUsable = GetBool( json, "AllowDirectUse", true );
		door.IsLocked = GetBool( json, "StartsLocked", false );
		door.StartOpen = GetBool( json, "StartsOpen", false );
		door.AutoClose = GetBool( json, "AutoReset", false );
		door.AutoCloseDelay = GetFloat( json, "ResetTime", 1f );
		door.OpenAwayFromPlayer = GetBool( json, "OpenAwayFromActivator", true );

		var hint = json["HintText"]?.GetValue<string>();
		if ( !string.IsNullOrWhiteSpace( hint ) )
			door.OpenTooltipTitle = hint;

		var targetAngle = TargetAngleFromRotation( GetVector3( json, "TargetRotation", new Vector3( 0, 90, 0 ) ) );
		door.TargetAngle = targetAngle;

		var openDuration = GetFloat( json, "OpenDuration", 1f );
		door.Speed = openDuration > 0f ? targetAngle / openDuration : targetAngle;

		var pivotLocal = GetVector3( json, "Pivot", Vector3.Zero );
		if ( !pivotLocal.IsNearlyZero( 0.001f ) )
		{
			var pivotGo = new GameObject( true, "DoorPivot" );
			pivotGo.SetParent( go );
			pivotGo.LocalPosition = pivotLocal;
			door.Pivot = pivotGo;
		}

		partnerId = TryGetIdString( json["PartnerObject"] );

		stub.Destroy();
		return true;
	}

	static float TargetAngleFromRotation( Vector3 targetRotation )
	{
		var absX = MathF.Abs( targetRotation.x );
		var absY = MathF.Abs( targetRotation.y );
		var absZ = MathF.Abs( targetRotation.z );
		var angle = MathF.Max( absX, MathF.Max( absY, absZ ) );
		return angle > 0f ? angle : 90f;
	}

	static bool GetBool( JsonObject json, string key, bool fallback )
	{
		var node = json[key];
		if ( node is null )
			return fallback;

		try
		{
			return node.GetValue<bool>();
		}
		catch
		{
			return fallback;
		}
	}

	static float GetFloat( JsonObject json, string key, float fallback )
	{
		var node = json[key];
		if ( node is null )
			return fallback;

		try
		{
			return node.GetValue<float>();
		}
		catch
		{
			return fallback;
		}
	}

	static Vector3 GetVector3( JsonObject json, string key, Vector3 fallback )
	{
		var node = json[key];
		if ( node is null )
			return fallback;

		if ( node is JsonValue value )
		{
			var text = value.GetValue<string>();
			if ( TryParseVector3( text, out var parsed ) )
				return parsed;
		}

		return fallback;
	}

	static bool TryParseVector3( string text, out Vector3 result )
	{
		result = default;
		if ( string.IsNullOrWhiteSpace( text ) )
			return false;

		var parts = text.Split( ',' );
		if ( parts.Length < 3 )
			return false;

		if ( !float.TryParse( parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var x ) )
			return false;
		if ( !float.TryParse( parts[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var y ) )
			return false;
		if ( !float.TryParse( parts[2].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var z ) )
			return false;

		result = new Vector3( x, y, z );
		return true;
	}

	static string TryGetIdString( JsonNode node )
	{
		if ( node is null )
			return null;

		if ( node is JsonValue value )
		{
			if ( value.GetValueKind() == JsonValueKind.Null )
				return null;

			try
			{
				var text = value.GetValue<string>();
				return string.IsNullOrWhiteSpace( text ) ? null : text;
			}
			catch
			{
				return null;
			}
		}

		return null;
	}
}