Aetheria/Assets/Scripts/Editor/ShipPrefabAuthoring.cs
Meta Vens e103b90aea Fire on solutions: FireControl.Solution is each gun's one solution; the trigger fires on it without the aim
Bears is extracted from PFire; ArcPermitsFire passes on a fused gun, a solution, or a free aim in arc.
Solve flies along Solution's direction. ArticulationPoint loses its yaw clamp and follows a per-group
aim point placed from the group's solution. Arcs gets its new brief; Broadside is new.
Unverified: first commit of the cut, not yet built.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 13:25:53 +02:00

249 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
// New ship models own their presentation anchors. The catalog still owns the schematic and gameplay rules.
public static class ShipPrefabAuthoring
{
public const string ModelDirectory = "Assets/Models/Ships/";
public const string PrefabDirectory = "Assets/Content/Prefabs/Ships/";
private const string ShieldPath = "Assets/Content/Prefabs/Shield.prefab";
private const string TractorPath = "Assets/Content/Prefabs/Tractor Beam.prefab";
private const string PingPath = "Assets/Content/Prefabs/PingEffect.prefab";
private const string ExplosionPath = "Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab";
private const string InvisiblePath = "Assets/Materials/Invisible.mat";
private const string MapMaterialPath = "Assets/Materials/UI/Map Icons.mat";
[MenuItem("Aetheria/Build Ship Prefab From Selected Model")]
private static void BuildSelected()
{
var path = AssetDatabase.GetAssetPath(Selection.activeObject);
try { Debug.Log($"Ship prefab built: {Build(path)}"); }
catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {path}: {ex.Message}"); }
}
[MenuItem("Aetheria/Build Ship Prefab From Selected Model", true)]
private static bool CanBuildSelected() => IsShipModel(AssetDatabase.GetAssetPath(Selection.activeObject));
public static bool IsShipModel(string path) =>
!string.IsNullOrEmpty(path) && path.StartsWith(ModelDirectory, StringComparison.Ordinal) &&
path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase);
public static string Build(string modelPath)
{
if (!IsShipModel(modelPath)) throw new InvalidOperationException($"Expected an FBX in {ModelDirectory}");
var model = AssetDatabase.LoadAssetAtPath<GameObject>(modelPath);
if (model == null) throw new InvalidOperationException($"Could not load model {modelPath}");
var shipName = System.IO.Path.GetFileNameWithoutExtension(modelPath);
var outputPath = PrefabDirectory + shipName + ".prefab";
var instance = (GameObject)PrefabUtility.InstantiatePrefab(model);
if (instance == null) throw new InvalidOperationException($"Could not instantiate model {modelPath}");
try
{
instance.name = shipName;
instance.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
Configure(instance);
PrefabUtility.SaveAsPrefabAsset(instance, outputPath, out var saved);
if (!saved) throw new InvalidOperationException($"Unity did not save {outputPath}");
return outputPath;
}
finally { Object.DestroyImmediate(instance); }
}
// Takes an imported model instance. Every ship-specific reference comes from its named hierarchy.
public static void Configure(GameObject root)
{
var nodes = root.GetComponentsInChildren<Transform>(true);
var named = new Dictionary<string, Transform>(StringComparer.Ordinal);
foreach (var node in nodes)
{
if (!Reserved(node.name)) continue;
if (!named.TryAdd(node.name, node))
throw new InvalidOperationException($"Duplicate reserved name {node.name}");
}
Transform Required(string name) => named.TryGetValue(name, out var found)
? found : throw new InvalidOperationException($"Missing {name}");
var mapIcon = Required("SHIP.MapIcon").GetComponent<MeshRenderer>();
if (mapIcon == null) throw new InvalidOperationException("SHIP.MapIcon needs a mesh renderer");
var hull = Required("SHIP.HullCollider");
var hullFilter = hull.GetComponent<MeshFilter>();
var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null;
if (hullMesh == null) throw new InvalidOperationException("SHIP.HullCollider needs a mesh");
var shieldMarker = Required("SHIP.Shield");
var tractorMarker = Required("SHIP.TractorBeam");
if (!PositiveScale(shieldMarker.localScale))
throw new InvalidOperationException("SHIP.Shield scale must have three positive radii");
var shieldPrefab = LoadComponent<ShieldManager>(ShieldPath);
var tractorPrefab = LoadComponent<TractorBeam>(TractorPath);
var ping = LoadComponent<Transform>(PingPath);
var explosion = LoadAsset<GameObject>(ExplosionPath);
var invisible = LoadAsset<Material>(InvisiblePath);
var mapMaterial = LoadAsset<Material>(MapMaterialPath);
var minimapLayer = LayerMask.NameToLayer("Minimap");
var combatLayer = LayerMask.NameToLayer("Combat");
if (minimapLayer < 0 || combatLayer < 0) throw new InvalidOperationException("Minimap or Combat layer is missing");
var hardpoints = 0;
var thrusters = new List<ThrusterHardpoint>();
var weapons = new List<WeaponHardpoint>();
var radiators = new List<RadiatorHardpoint>();
var pivots = new List<ArticulationPoint>();
foreach (var pair in named.OrderBy(x => x.Key, StringComparer.Ordinal))
{
var name = pair.Key;
var node = pair.Value;
if (name.StartsWith("HP.", StringComparison.Ordinal))
{
var parts = name.Split('.');
if (parts.Length != 3 || string.IsNullOrWhiteSpace(parts[2]))
throw new InvalidOperationException($"Invalid hardpoint name {name}");
hardpoints++;
switch (parts[1])
{
case "Thruster":
var emitter = DirectChild(node, "Emitter")?.GetComponent<MeshRenderer>();
if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child");
AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter;
thrusters.Add(node.GetComponent<ThrusterHardpoint>());
break;
case "Weapon":
var muzzles = node.Cast<Transform>()
.Where(x => x.name.StartsWith("Muzzle.", StringComparison.Ordinal))
.Select(x => (node: x, number: MuzzleNumber(x.name)))
.OrderBy(x => x.number).ToArray();
if (muzzles.Length == 0 || muzzles.Select(x => x.number).Distinct().Count() != muzzles.Length)
throw new InvalidOperationException($"{name} needs uniquely numbered Muzzle.<n> children");
AddOrGet<WeaponHardpoint>(node.gameObject).FiringPoint = muzzles.Select(x => x.node).ToArray();
weapons.Add(node.GetComponent<WeaponHardpoint>());
break;
case "Radiator":
var mesh = DirectChild(node, "RadiatorMesh")?.GetComponent<MeshRenderer>();
if (mesh == null) throw new InvalidOperationException($"{name} needs a RadiatorMesh child");
AddOrGet<RadiatorHardpoint>(node.gameObject).Mesh = mesh;
radiators.Add(node.GetComponent<RadiatorHardpoint>());
break;
default: throw new InvalidOperationException($"Unknown hardpoint role in {name}");
}
}
else if (name.StartsWith("Pivot.", StringComparison.Ordinal))
{
var values = name.Split('.');
if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}");
var pivot = AddOrGet<ArticulationPoint>(node.gameObject);
pivot.Group = ParseInt(values[1], name);
pivot.PitchMin = ParseInt(values[4], name);
pivot.PitchMax = ParseInt(values[5], name);
pivot.Speed = ParseInt(values[6], name);
if (pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0)
throw new InvalidOperationException($"Invalid articulation range or speed in {name}");
pivots.Add(pivot);
}
else if (name.StartsWith("SHIP.", StringComparison.Ordinal) &&
name != "SHIP.MapIcon" && name != "SHIP.HullCollider" &&
name != "SHIP.Shield" && name != "SHIP.TractorBeam")
throw new InvalidOperationException($"Unknown ship anchor {name}");
}
if (hardpoints == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints");
mapIcon.sharedMaterial = mapMaterial;
mapIcon.gameObject.layer = minimapLayer;
hull.gameObject.layer = combatLayer;
var hullRenderer = hull.GetComponent<MeshRenderer>();
// Serialized, so it survives SaveAsPrefabAsset and every clone; forceRenderingOff survives neither.
if (hullRenderer != null) ShipModShips.Hide(hullRenderer, invisible);
var collider = AddOrGet<MeshCollider>(hull.gameObject);
collider.sharedMesh = hullMesh;
collider.convex = true;
var hullSurface = AddOrGet<HullCollider>(hull.gameObject);
var shield = (ShieldManager)PrefabUtility.InstantiatePrefab(shieldPrefab, shieldMarker);
shield.transform.localPosition = Vector3.zero;
shield.transform.localRotation = Quaternion.identity;
if (shield.GetComponent<ShieldEnvelope>() == null) shield.gameObject.AddComponent<ShieldEnvelope>();
var tractor = (TractorBeam)PrefabUtility.InstantiatePrefab(tractorPrefab, tractorMarker);
tractor.transform.localPosition = Vector3.zero;
tractor.transform.localRotation = Quaternion.identity;
var ship = AddOrGet<ShipInstance>(root);
ship.MapIcon = mapIcon;
ship.InfluencePrefab = null;
ship.PingPrefab = ping;
ship.InvisibleMaterial = invisible;
ship.Shield = shield;
ship.HullColliders = new[] { hullSurface };
ship.ThrusterHardpoints = thrusters.ToArray();
ship.WeaponHardpoints = weapons.ToArray();
ship.RadiatorHardpoints = radiators.ToArray();
ship.ArticulationPoints = pivots.ToArray();
ship.DestroyEffect = explosion;
ship.TractorBeam = tractor;
}
private static bool Reserved(string name) => name.StartsWith("HP.", StringComparison.Ordinal) ||
name.StartsWith("Pivot.", StringComparison.Ordinal) || name.StartsWith("SHIP.", StringComparison.Ordinal);
private static bool PositiveScale(Vector3 scale) => scale.x > 0 && scale.y > 0 && scale.z > 0;
private static Transform DirectChild(Transform parent, string name) =>
parent.Cast<Transform>().FirstOrDefault(x => x.name == name);
private static int MuzzleNumber(string name)
{
if (!int.TryParse(name.Substring("Muzzle.".Length), NumberStyles.None, CultureInfo.InvariantCulture, out var n))
throw new InvalidOperationException($"Invalid muzzle name {name}");
return n;
}
private static int ParseInt(string part, string name)
{
if (!int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
throw new InvalidOperationException($"Invalid numeric field in {name}");
return value;
}
private static T AddOrGet<T>(GameObject gameObject) where T : Component
{
var component = gameObject.GetComponent<T>();
return component != null ? component : gameObject.AddComponent<T>();
}
private static T LoadAsset<T>(string path) where T : Object
{
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
return asset != null ? asset : throw new InvalidOperationException($"Missing shared asset {path}");
}
private static T LoadComponent<T>(string path) where T : Component
{
var prefab = LoadAsset<GameObject>(path);
var component = prefab.GetComponent<T>();
return component != null ? component : throw new InvalidOperationException($"{path} needs {typeof(T).Name}");
}
}
// Model import finishes before generated prefabs read its hierarchy. Invalid exports leave the last valid prefab.
public sealed class ShipModelImport : AssetPostprocessor
{
private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom)
{
var models = imported.Concat(moved).Where(ShipPrefabAuthoring.IsShipModel).Distinct().ToArray();
if (models.Length == 0) return;
EditorApplication.delayCall += () =>
{
foreach (var model in models)
{
try { Debug.Log($"Ship prefab built: {ShipPrefabAuthoring.Build(model)}"); }
catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {model}: {ex.Message}"); }
}
};
}
}