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 Prototypes = new Dictionary(); 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().Where(hull => hull.Visual.IsSet()).ToArray(); if (hulls.Length == 0) return; var template = EngineAssets.Load(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(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(); 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(); if (hullRenderer != null) Hide(hullRenderer, template.Invisible); var collider = AddOrGet(hullNode.gameObject); collider.sharedMesh = hullMesh; collider.convex = true; var surface = AddOrGet(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(), shieldMarker, false); Reset(shield.transform); AddOrGet(shield.gameObject); var tractor = Object.Instantiate(template.TractorBeam.GetComponent(), 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(Node(id).gameObject); hardpoint.Emitter = Renderer(id); Hide(hardpoint.Emitter, template.Invisible); return hardpoint; }).ToArray(); var radiators = plan.Radiators.Select(id => { var hardpoint = AddOrGet(Node(id).gameObject); hardpoint.Mesh = Renderer(id); return hardpoint; }).ToArray(); var weapons = plan.Weapons.Select(weapon => { var hardpoint = AddOrGet(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(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(); 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(GameObject gameObject) where T : Component => gameObject.TryGetComponent(out var found) ? found : gameObject.AddComponent(); }