Ship S2: the game composes and loads mod ships

Boot opens the derived catalog when GameData/Mods holds packages
(ShipModCatalog.ResolveCatalog), preloads each hull's GLB into an assembled
inactive prototype (ShipModShips), and LoadEntity clones it when the hull names
a Visual. ShipModPlan decides which anchor fills which ShipInstance slot; a
ShipModTemplate prefab carries the shared effects. Continue refuses a run that
names a design the catalog no longer holds (RunSave.RequireDesigns).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 06:43:52 +02:00
commit 460bb85ed0
16 changed files with 702 additions and 13 deletions

View file

@ -1,10 +1,16 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
public static class ShipModPreview
{
// Batch proof: -executeMethod ShipModPreview.Smoke -shipModPath <package/ship.cc>
private const string TemplatePath = "Assets/Content/Prefabs/ShipModTemplate.prefab";
// Batch proof: -executeMethod ShipModPreview.Smoke -shipModPath <package/ship.cc> [-assemble]
// -assemble also builds the game's ShipInstance from the package (ShipModShips.Assemble) and checks it against the
// plan and against a clone, the way Instantiate makes one. That needs a GLB with real meshes on the map-icon,
// hull-collider and emitter anchors; a nodes-only fixture fails it on purpose.
public static async void Smoke()
{
var args = Environment.GetCommandLineArgs();
@ -18,10 +24,12 @@ public static class ShipModPreview
ShipModVisual.Instance visual = null;
try
{
visual = await ShipModVisual.LoadAsync(args[option + 1], null);
var package = ShipModCatalog.ReadPackage(args[option + 1]);
visual = await ShipModVisual.LoadAsync(package, null);
if (visual.Anchors.Count == 0 || visual.LineMesh == null || visual.LineMesh.vertexCount == 0)
throw new InvalidOperationException("Mod visual did not import anchors and line points.");
Debug.Log($"SHIP_MOD_VISUAL_SMOKE anchors={visual.Anchors.Count} points={visual.LineMesh.vertexCount} segments={visual.LineMesh.GetIndexCount(0) / 2}");
if (Array.IndexOf(args, "-assemble") >= 0) CheckAssembly(visual, package);
visual.Destroy();
EditorApplication.Exit(0);
}
@ -33,6 +41,41 @@ public static class ShipModPreview
}
}
private static void CheckAssembly(ShipModVisual.Instance visual, ShipModCatalog.Package package)
{
var template = AssetDatabase.LoadAssetAtPath<ShipModTemplate>(TemplatePath);
if (template == null) throw new InvalidOperationException($"{TemplatePath} is missing or has no ShipModTemplate.");
var plan = ShipModPlan.Build(package.Hull, package.Visual);
var ship = ShipModShips.Assemble(visual, package.Hull, package.Visual, template);
CheckShip(ship, plan, "assembled");
// Instantiate clones the assembled root, and Unity must remap every array reference into the clone.
var clone = UnityEngine.Object.Instantiate(ship.gameObject).GetComponent<ShipInstance>();
try { CheckShip(clone, plan, "clone"); }
finally { UnityEngine.Object.DestroyImmediate(clone.gameObject); }
Debug.Log($"SHIP_MOD_ASSEMBLY_SMOKE equipment={plan.Equipment.Length} thrusters={plan.Thrusters.Length} " +
$"weapons={plan.Weapons.Length} radiators={plan.Radiators.Length}");
}
private static void CheckShip(ShipInstance ship, ShipModPlan plan, string what)
{
void Require(bool ok, string claim) { if (!ok) throw new InvalidOperationException($"{what}: {claim}"); }
bool Inside(Component part) => part != null && part.transform.IsChildOf(ship.transform);
Require(ship.HullColliders.Length == 1 && Inside(ship.HullColliders[0]) &&
ship.HullColliders[0].GetComponent<MeshCollider>() != null, "one hull collider with a MeshCollider");
Require(Inside(ship.MapIcon) && Inside(ship.Shield) && Inside(ship.TractorBeam), "map icon, shield and tractor inside the ship");
Require(ship.EquipmentHardpoints.Select(node => node.name).SequenceEqual(plan.Equipment) &&
ship.EquipmentHardpoints.All(Inside), "equipment hardpoints named by the plan");
Require(ship.ThrusterHardpoints.Select(node => node.name).SequenceEqual(plan.Thrusters) &&
ship.ThrusterHardpoints.All(node => Inside(node) && Inside(node.Emitter)), "thruster hardpoints with emitters");
Require(ship.RadiatorHardpoints.Select(node => node.name).SequenceEqual(plan.Radiators) &&
ship.RadiatorHardpoints.All(node => Inside(node) && Inside(node.Mesh)), "radiator hardpoints with meshes");
Require(ship.WeaponHardpoints.Select(node => node.name).SequenceEqual(plan.Weapons.Select(weapon => weapon.Mount)) &&
ship.WeaponHardpoints.Zip(plan.Weapons, (node, weapon) =>
Inside(node) && node.FiringPoint.Select(point => point.name).SequenceEqual(weapon.Muzzles) &&
node.FiringPoint.All(Inside)).All(ok => ok), "weapon hardpoints with their muzzles in order");
}
[MenuItem("Aetheria/Preview Mod Ship Package")]
private static async void Open()
{
@ -40,7 +83,7 @@ public static class ShipModPreview
if (string.IsNullOrEmpty(path)) return;
try
{
var visual = await ShipModVisual.LoadAsync(path, null);
var visual = await ShipModVisual.LoadAsync(ShipModCatalog.ReadPackage(path), null);
Undo.RegisterCreatedObjectUndo(visual.Root, "Preview mod ship");
Selection.activeGameObject = visual.Root;
SceneView.FrameLastActiveSceneView();