Aetheria/Assets/Scripts/Gameplay/ShipModShips.cs
Meta Vens 6709b06e2e Ship preload: a mod ship that fails to load or assemble is skipped, not fatal
PreloadAsync rethrew on the first failing hull, so one bad package faulted
Loading and no mod ship could spawn. Each hull now loads in its own try: a
failure destroys its partial visual, is logged with the hull's name, and the
rest still preload, as Compose skips a bad package. The play smoke installs the
package beside a broken sibling that composes but cannot assemble (its map icon
moved onto a meshless muzzle node) and proves the sibling has no prototype while
the good ship spawns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:38:35 +02:00

182 lines
9 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. Preload imports each catalog hull's GLB once, at boot, into an inactive assembled
// prototype keyed by the hull's record key; Instantiate then clones it synchronously, so LoadEntity never loads async.
// The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template.
public static class ShipModShips
{
private static readonly Dictionary<CultRecordKey, GameObject> Prototypes = new Dictionary<CultRecordKey, GameObject>();
private static CultCache _catalog;
// Set by Preload; complete when every hull that could be built has its prototype. A mod ship that fails to import or
// assemble is skipped and logged, as Compose skips a bad package, so it never stops the rest; Loading faults only
// when 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);
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();
Debug.LogError($"Mod ship {hull.Name} skipped: {error.Message}");
}
}
}
// 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>();
}