Aetheria/Assets/Scripts/Gameplay/ShipModShips.cs
Meta Vens b76e212d60 RemovePackage: containment under Mods and RemovedMods, name round-trip, no links; Remove dialog keeps other buttons
Refuses names the filesystem rewrites (dot names, trailing dot or space, device names, stream colon), anything that does not
resolve to a strict child of the mods folder under the same name, and link packages. A fault with no known package names
(all mods), so no Remove button. A removal drops only its own button and the dialog returns to the rest.
Unity compile not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:49:44 +02:00

196 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using UnityEngine;
using Object = UnityEngine.Object;
// Mod ships in the running game. Compose decides which mod hulls exist; Preload builds a prototype for each, keyed by the
// hull's record key, into an inactive assembled copy that Instantiate clones synchronously, so LoadEntity never loads async.
// A composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package.
// The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template.
public static class ShipModShips
{
// Thrown by Loading when composed hulls could not be imported. It names the packages and changes no catalog.
public sealed class ImportFault : Exception
{
public ShipModCatalog.Exclusion[] Faults { get; }
public ImportFault(ShipModCatalog.Exclusion[] faults) :
base("Mod ships the game could not import: " + string.Join("; ", faults.Select(fault => fault.ToString())))
{
Faults = faults;
}
}
private static readonly Dictionary<CultRecordKey, GameObject> Prototypes = new Dictionary<CultRecordKey, GameObject>();
private static CultCache _catalog;
// Set by Preload; complete when every hull has its prototype, and faulted when any could not be built (ImportFault)
// or the preload itself cannot run.
public static Task Loading { get; private set; } = Task.CompletedTask;
public static Task Preload(CultCache catalog, string modsRoot)
{
Loading = PreloadAsync(catalog, modsRoot);
Loading.ContinueWith(task => Debug.LogException(task.Exception), TaskContinuationOptions.OnlyOnFaulted);
return Loading;
}
private static async Task PreloadAsync(CultCache catalog, string modsRoot)
{
_catalog = catalog;
var hulls = catalog.GetAll<HullData>().Where(hull => hull.Visual.IsSet()).ToArray();
if (hulls.Length == 0) return;
var template = EngineAssets.Load<ShipModTemplate>(ShipModTemplate.Key);
if (template == null)
throw new InvalidOperationException($"The ship mod template prefab ({ShipModTemplate.Key}) is not loadable.");
var root = new GameObject("Mod Ship Prototypes");
root.SetActive(false);
// DontDestroyOnLoad is play-mode only; the edit-mode play smoke builds the same prototypes
// without it, and nothing there loads scenes.
if (Application.isPlaying) Object.DontDestroyOnLoad(root);
var faults = new List<ShipModCatalog.Exclusion>();
foreach (var hull in hulls)
{
ShipModVisual.Instance visual = null;
try
{
var package = ShipModCatalog.PackageOf(catalog, hull, modsRoot);
visual = await ShipModVisual.LoadAsync(package, root.transform);
visual.Root.SetActive(false);
Assemble(visual, package.Hull, package.Visual, template);
Prototypes[catalog.RefOf(hull).Key] = visual.Root;
}
catch (Exception error)
{
visual?.Destroy();
faults.Add(new ShipModCatalog.Exclusion { Package = catalog.Get(hull.Visual)?.Id ?? "(all mods)", Reason = $"{hull.Name}: {error.Message}" });
}
}
if (faults.Count > 0) throw new ImportFault(faults.ToArray());
}
// A live ship body for a mod hull. Refuses loudly when the prototype is missing: nothing is loaded here.
public static GameObject Instantiate(HullData hull, Transform parent)
{
if (!Loading.IsCompletedSuccessfully)
throw new InvalidOperationException($"{hull.Name}: mod ships are not preloaded ({Loading.Status}).");
if (!Prototypes.TryGetValue(_catalog.RefOf(hull).Key, out var prototype))
throw new InvalidOperationException($"{hull.Name}: no mod ship prototype for this hull.");
var body = Object.Instantiate(prototype, parent, false);
body.SetActive(true);
return body;
}
// Fills a ShipInstance on the visual's root from its anchors, as ShipModPlan says; the shared effects come from the
// template. Nothing here decides a slot: the plan does. The schematic lines stay in the prototype but are hidden in
// world; the in-world ship is the GLB.
public static ShipInstance Assemble(ShipModVisual.Instance visual, HullData hull, ShipAuthoring ship, ShipModTemplate template)
{
var plan = ShipModPlan.Build(hull, ship);
var minimap = LayerMask.NameToLayer("Minimap");
var combat = LayerMask.NameToLayer("Combat");
if (minimap < 0 || combat < 0) throw new InvalidOperationException("Minimap or Combat layer is missing");
// The name a catalog hardpoint matches at runtime is its anchor id.
Transform Node(string id)
{
var node = visual.Anchors[id];
node.name = id;
return node;
}
MeshRenderer Renderer(string id)
{
var found = Node(id).GetComponentInChildren<MeshRenderer>(true);
if (found == null) throw new InvalidOperationException($"{ship.Id}: anchor {id} needs a mesh in its GLB node.");
return found;
}
var mapIcon = Renderer(plan.MapIcon);
mapIcon.sharedMaterial = template.MapIcon;
mapIcon.gameObject.layer = minimap;
var hullNode = Node(plan.HullCollider);
var hullFilter = hullNode.GetComponent<MeshFilter>();
var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null;
if (hullMesh == null)
throw new InvalidOperationException($"{ship.Id}: hull-collider anchor {plan.HullCollider} needs a mesh in its GLB node.");
if (!hullMesh.isReadable)
throw new InvalidOperationException($"{ship.Id}: the hull-collider mesh imported unreadable, so it cannot back a MeshCollider.");
hullNode.gameObject.layer = combat;
// The collision hull is never drawn. Its renderer takes the invisible material, which is serialized and so survives
// the prototype's clone; forceRenderingOff would not survive Instantiate.
var hullRenderer = hullNode.GetComponent<MeshRenderer>();
if (hullRenderer != null) Hide(hullRenderer, template.Invisible);
var collider = AddOrGet<MeshCollider>(hullNode.gameObject);
collider.sharedMesh = hullMesh;
collider.convex = true;
var surface = AddOrGet<HullCollider>(hullNode.gameObject);
var shieldMarker = Node(plan.Shield);
var scale = shieldMarker.localScale;
if (!(scale.x > 0 && scale.y > 0 && scale.z > 0))
throw new InvalidOperationException($"{ship.Id}: the shield anchor scale must have three positive radii.");
var shield = Object.Instantiate(template.Shield.GetComponent<ShieldManager>(), shieldMarker, false);
Reset(shield.transform);
AddOrGet<ShieldEnvelope>(shield.gameObject);
var tractor = Object.Instantiate(template.TractorBeam.GetComponent<TractorBeam>(), Node(plan.Tractor), false);
Reset(tractor.transform);
var thrusters = plan.Thrusters.Select(id =>
{
// The thruster's mesh is its exhaust's emission surface, never drawn (ruling thrusters-radiators-are-meshes).
// The invisible material is serialized, so it survives the prototype's clone, and EntityInstance keeps it as
// this renderer's visible material; forceRenderingOff would not survive Instantiate.
var hardpoint = AddOrGet<ThrusterHardpoint>(Node(id).gameObject);
hardpoint.Emitter = Renderer(id);
Hide(hardpoint.Emitter, template.Invisible);
return hardpoint;
}).ToArray();
var radiators = plan.Radiators.Select(id =>
{
var hardpoint = AddOrGet<RadiatorHardpoint>(Node(id).gameObject);
hardpoint.Mesh = Renderer(id);
return hardpoint;
}).ToArray();
var weapons = plan.Weapons.Select(weapon =>
{
var hardpoint = AddOrGet<WeaponHardpoint>(Node(weapon.Mount).gameObject);
hardpoint.FiringPoint = weapon.Muzzles.Select(Node).ToArray();
return hardpoint;
}).ToArray();
var lines = visual.Root.transform.Find("Schematic Lines");
if (lines != null) lines.gameObject.SetActive(false);
var instance = AddOrGet<ShipInstance>(visual.Root);
instance.MapIcon = mapIcon;
instance.InfluencePrefab = null;
instance.PingPrefab = template.Ping.transform;
instance.InvisibleMaterial = template.Invisible;
instance.Shield = shield;
instance.HullColliders = new[] { surface };
instance.ThrusterHardpoints = thrusters;
instance.WeaponHardpoints = weapons;
instance.RadiatorHardpoints = radiators;
instance.ArticulationPoints = Array.Empty<ArticulationPoint>();
instance.DestroyEffect = template.DestroyEffect;
instance.TractorBeam = tractor;
return instance;
}
// Every material slot takes the invisible material, so the renderer draws nothing on the prototype and on every clone.
public static void Hide(Renderer renderer, Material invisible) =>
renderer.sharedMaterials = Enumerable.Repeat(invisible, renderer.sharedMaterials.Length).ToArray();
private static void Reset(Transform transform)
{
transform.localPosition = Vector3.zero;
transform.localRotation = Quaternion.identity;
}
private static T AddOrGet<T>(GameObject gameObject) where T : Component =>
gameObject.TryGetComponent<T>(out var found) ? found : gameObject.AddComponent<T>();
}