diff --git a/Assets/Scripts/Editor/ShipModPlaySmoke.cs b/Assets/Scripts/Editor/ShipModPlaySmoke.cs new file mode 100644 index 000000000..a4fb708b5 --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPlaySmoke.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +// Batch proof that a mod ship survives the game's own path into a zone, without a scene: the derived catalog composed +// with one package, ShipModShips.Preload over it, then ShipModShips.Instantiate, which is what ZoneRenderer.LoadEntity's +// Body() calls for a hull with a Visual. A plain parent transform stands in for ZoneRoot; nothing else in LoadEntity +// touches the body before it takes its ShipInstance. +// +// Unity -batchmode -nographics -projectPath -executeMethod ShipModPlaySmoke.Run +// -shipModPath //ship.cc [-shippedCatalog GameData/Aetheria.cc] -logFile +// +// Do not pass -quit: the method is async and exits the Editor itself, 0 on success and 1 on any failure. +public static class ShipModPlaySmoke +{ + public static async void Run() + { + var args = Environment.GetCommandLineArgs(); + string Option(string name, string fallback = null) + { + var at = Array.IndexOf(args, name); + return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; + } + var packagePath = Option("-shipModPath"); + var derived = Path.Combine(Path.GetTempPath(), $"ship-mod-play-smoke-{Guid.NewGuid():N}.cc"); + GameObject zone = null; + try + { + if (packagePath == null) throw new InvalidOperationException("-shipModPath is required"); + var package = ShipModCatalog.ReadPackage(packagePath); + var modsRoot = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(packagePath))); + var composition = ShipModCatalog.Compose(Path.GetFullPath(Option("-shippedCatalog", "GameData/Aetheria.cc")), derived, modsRoot); + if (!composition.Included.Contains(package.Visual.Id)) + throw new InvalidOperationException($"{package.Visual.Id} was not composed into the catalog: " + + string.Join("; ", composition.Excluded.Select(excluded => $"{excluded.Package}: {excluded.Reason}"))); + + using (var catalog = AetheriaStores.Open(derived)) + { + await ShipModShips.Preload(catalog, modsRoot); + var hull = catalog.GetAll().Single(candidate => candidate.Visual.IsSet() && candidate.Name == package.Hull.Name); + + zone = new GameObject("Play Smoke Zone"); + var ship = ShipModShips.Instantiate(hull, zone.transform).GetComponent(); + if (ship == null) throw new InvalidOperationException("The mod ship body has no ShipInstance."); + var thrusters = ship.ThrusterHardpoints?.Length ?? 0; + var weapons = ship.WeaponHardpoints?.Length ?? 0; + if (thrusters == 0) throw new InvalidOperationException("ThrusterHardpoints is empty."); + if (weapons == 0 || ship.WeaponHardpoints.Any(node => node.FiringPoint == null || node.FiringPoint.Length == 0)) + throw new InvalidOperationException("WeaponHardpoints is empty or a weapon has no muzzle."); + if (ship.HullColliders == null || ship.HullColliders.Length != 1) + throw new InvalidOperationException("The ship needs exactly one hull collider."); + var collider = ship.HullColliders[0].GetComponent(); + if (collider == null || collider.sharedMesh == null || !collider.sharedMesh.isReadable || collider.sharedMesh.vertexCount == 0) + throw new InvalidOperationException("The hull collider is not a MeshCollider over a readable mesh."); + if (ship.MapIcon == null || ship.MapIcon.sharedMaterial == null || ship.MapIcon.gameObject.layer != LayerMask.NameToLayer("Minimap")) + throw new InvalidOperationException("The map icon is missing its renderer, material or Minimap layer."); + Debug.Log($"SHIP_MOD_PLAY_SMOKE thrusters={thrusters} weapons={weapons} collider=ok mapicon=ok"); + } + if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + if (zone != null) UnityEngine.Object.DestroyImmediate(zone); + EditorApplication.Exit(1); + } + } +} diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 315ff22e1..5a69f06de 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -194,6 +194,22 @@ catalog. Continue refuses a run naming a design the catalog no longer holds 5. Spawn, equip, thrust, fire, shield, damage, save/reload, and schematic UI work for the mod ship. Only then is a shipped hull eligible for migration. +### Unity batch smokes + +Both run in batchmode against a package the tests generate: set `AETHERIA_SHIP_FIXTURE_DIR` while running +`ShipModCatalogTests.TheFixturePackageIsCompleteAndItsGlbCarriesAScene` and it leaves `mod.skiff/ship.cc` and +`skiff.glb` there (the GLB carries a tetrahedron mesh on the map, collider and thruster anchors, plus a weapon mount +and muzzle). Neither takes `-quit`: both are `async void` and end the Editor themselves. + +- `-executeMethod ShipModPreview.Smoke -shipModPath /mod.skiff/ship.cc [-assemble]` imports the GLB and checks + the anchors; `-assemble` also builds the `ShipInstance` from the template. +- `-executeMethod ShipModPlaySmoke.Run -shipModPath /mod.skiff/ship.cc [-shippedCatalog GameData/Aetheria.cc]` + composes a derived catalog with the package, runs `ShipModShips.Preload`, instantiates the ship through + `ShipModShips.Instantiate` (the call `ZoneRenderer.LoadEntity` makes) under a bare parent transform, and requires + thruster and weapon hardpoints (each weapon with a muzzle), one `MeshCollider` over a readable mesh, and a map icon. + It prints `SHIP_MOD_PLAY_SMOKE thrusters=N weapons=N collider=ok mapicon=ok` and exits 0, or logs the exception and + exits 1. It uses no scene and no zone. + ## Structural and build budget The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence,