diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs new file mode 100644 index 000000000..988fe15e7 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// New ship models own their presentation anchors. The catalog still owns the schematic and gameplay rules. +public static class ShipPrefabAuthoring +{ + public const string ModelDirectory = "Assets/Models/Ships/"; + public const string PrefabDirectory = "Assets/Content/Prefabs/Ships/"; + + private const string ShieldPath = "Assets/Content/Prefabs/Shield.prefab"; + private const string TractorPath = "Assets/Content/Prefabs/Tractor Beam.prefab"; + private const string PingPath = "Assets/Content/Prefabs/PingEffect.prefab"; + private const string ExplosionPath = "Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab"; + private const string InvisiblePath = "Assets/Materials/Invisible.mat"; + private const string MapMaterialPath = "Assets/Materials/UI/Map Icons.mat"; + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model")] + private static void BuildSelected() + { + var path = AssetDatabase.GetAssetPath(Selection.activeObject); + try { Debug.Log($"Ship prefab built: {Build(path)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {path}: {ex.Message}"); } + } + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model", true)] + private static bool CanBuildSelected() => IsShipModel(AssetDatabase.GetAssetPath(Selection.activeObject)); + + public static bool IsShipModel(string path) => + !string.IsNullOrEmpty(path) && path.StartsWith(ModelDirectory, StringComparison.Ordinal) && + path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase); + + public static string Build(string modelPath) + { + if (!IsShipModel(modelPath)) throw new InvalidOperationException($"Expected an FBX in {ModelDirectory}"); + var model = AssetDatabase.LoadAssetAtPath(modelPath); + if (model == null) throw new InvalidOperationException($"Could not load model {modelPath}"); + + var shipName = System.IO.Path.GetFileNameWithoutExtension(modelPath); + var outputPath = PrefabDirectory + shipName + ".prefab"; + var instance = (GameObject)PrefabUtility.InstantiatePrefab(model); + if (instance == null) throw new InvalidOperationException($"Could not instantiate model {modelPath}"); + try + { + instance.name = shipName; + instance.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + Configure(instance); + PrefabUtility.SaveAsPrefabAsset(instance, outputPath, out var saved); + if (!saved) throw new InvalidOperationException($"Unity did not save {outputPath}"); + return outputPath; + } + finally { Object.DestroyImmediate(instance); } + } + + // Takes an imported model instance. Every ship-specific reference comes from its named hierarchy. + public static void Configure(GameObject root) + { + var nodes = root.GetComponentsInChildren(true); + var named = new Dictionary(StringComparer.Ordinal); + foreach (var node in nodes) + { + if (!Reserved(node.name)) continue; + if (!named.TryAdd(node.name, node)) + throw new InvalidOperationException($"Duplicate reserved name {node.name}"); + } + + Transform Required(string name) => named.TryGetValue(name, out var found) + ? found : throw new InvalidOperationException($"Missing {name}"); + + var mapIcon = Required("SHIP.MapIcon").GetComponent(); + if (mapIcon == null) throw new InvalidOperationException("SHIP.MapIcon needs a mesh renderer"); + var hull = Required("SHIP.HullCollider"); + var hullFilter = hull.GetComponent(); + var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null; + if (hullMesh == null) throw new InvalidOperationException("SHIP.HullCollider needs a mesh"); + var shieldMarker = Required("SHIP.Shield"); + var tractorMarker = Required("SHIP.TractorBeam"); + if (!PositiveScale(shieldMarker.localScale)) + throw new InvalidOperationException("SHIP.Shield scale must have three positive radii"); + + var shieldPrefab = LoadComponent(ShieldPath); + var tractorPrefab = LoadComponent(TractorPath); + var ping = LoadComponent(PingPath); + var explosion = LoadAsset(ExplosionPath); + var invisible = LoadAsset(InvisiblePath); + var mapMaterial = LoadAsset(MapMaterialPath); + var minimapLayer = LayerMask.NameToLayer("Minimap"); + var combatLayer = LayerMask.NameToLayer("Combat"); + if (minimapLayer < 0 || combatLayer < 0) throw new InvalidOperationException("Minimap or Combat layer is missing"); + + var equipment = new List(); + var thrusters = new List(); + var weapons = new List(); + var radiators = new List(); + var pivots = new List(); + foreach (var pair in named.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = pair.Key; + var node = pair.Value; + if (name.StartsWith("HP.", StringComparison.Ordinal)) + { + var parts = name.Split('.'); + if (parts.Length != 3 || string.IsNullOrWhiteSpace(parts[2])) + throw new InvalidOperationException($"Invalid hardpoint name {name}"); + equipment.Add(node); + switch (parts[1]) + { + case "Equipment": break; + case "Thruster": + var emitter = DirectChild(node, "Emitter")?.GetComponent(); + if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); + AddOrGet(node.gameObject).Emitter = emitter; + thrusters.Add(node.GetComponent()); + break; + case "Weapon": + var muzzles = node.Cast() + .Where(x => x.name.StartsWith("Muzzle.", StringComparison.Ordinal)) + .Select(x => (node: x, number: MuzzleNumber(x.name))) + .OrderBy(x => x.number).ToArray(); + if (muzzles.Length == 0 || muzzles.Select(x => x.number).Distinct().Count() != muzzles.Length) + throw new InvalidOperationException($"{name} needs uniquely numbered Muzzle. children"); + AddOrGet(node.gameObject).FiringPoint = muzzles.Select(x => x.node).ToArray(); + weapons.Add(node.GetComponent()); + break; + case "Radiator": + var mesh = DirectChild(node, "RadiatorMesh")?.GetComponent(); + if (mesh == null) throw new InvalidOperationException($"{name} needs a RadiatorMesh child"); + AddOrGet(node.gameObject).Mesh = mesh; + radiators.Add(node.GetComponent()); + break; + default: throw new InvalidOperationException($"Unknown hardpoint role in {name}"); + } + } + else if (name.StartsWith("Pivot.", StringComparison.Ordinal)) + { + var values = name.Split('.'); + if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}"); + var pivot = AddOrGet(node.gameObject); + pivot.Group = ParseInt(values[1], name); + pivot.YawMin = ParseInt(values[2], name); + pivot.YawMax = ParseInt(values[3], name); + pivot.PitchMin = ParseInt(values[4], name); + pivot.PitchMax = ParseInt(values[5], name); + pivot.Speed = ParseInt(values[6], name); + if (pivot.YawMin > pivot.YawMax || pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0) + throw new InvalidOperationException($"Invalid articulation range or speed in {name}"); + pivots.Add(pivot); + } + else if (name.StartsWith("SHIP.", StringComparison.Ordinal) && + name != "SHIP.MapIcon" && name != "SHIP.HullCollider" && + name != "SHIP.Shield" && name != "SHIP.TractorBeam") + throw new InvalidOperationException($"Unknown ship anchor {name}"); + } + if (equipment.Count == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints"); + + mapIcon.sharedMaterial = mapMaterial; + mapIcon.gameObject.layer = minimapLayer; + hull.gameObject.layer = combatLayer; + var hullRenderer = hull.GetComponent(); + if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + var collider = AddOrGet(hull.gameObject); + collider.sharedMesh = hullMesh; + collider.convex = true; + var hullSurface = AddOrGet(hull.gameObject); + + var shield = (ShieldManager)PrefabUtility.InstantiatePrefab(shieldPrefab, shieldMarker); + shield.transform.localPosition = Vector3.zero; + shield.transform.localRotation = Quaternion.identity; + if (shield.GetComponent() == null) shield.gameObject.AddComponent(); + var tractor = (TractorBeam)PrefabUtility.InstantiatePrefab(tractorPrefab, tractorMarker); + tractor.transform.localPosition = Vector3.zero; + tractor.transform.localRotation = Quaternion.identity; + + var ship = AddOrGet(root); + ship.MapIcon = mapIcon; + ship.InfluencePrefab = null; + ship.PingPrefab = ping; + ship.InvisibleMaterial = invisible; + ship.Shield = shield; + ship.HullColliders = new[] { hullSurface }; + ship.EquipmentHardpoints = equipment.ToArray(); + ship.ThrusterHardpoints = thrusters.ToArray(); + ship.WeaponHardpoints = weapons.ToArray(); + ship.RadiatorHardpoints = radiators.ToArray(); + ship.ArticulationPoints = pivots.ToArray(); + ship.DestroyEffect = explosion; + ship.TractorBeam = tractor; + } + + private static bool Reserved(string name) => name.StartsWith("HP.", StringComparison.Ordinal) || + name.StartsWith("Pivot.", StringComparison.Ordinal) || name.StartsWith("SHIP.", StringComparison.Ordinal); + + private static bool PositiveScale(Vector3 scale) => scale.x > 0 && scale.y > 0 && scale.z > 0; + + private static Transform DirectChild(Transform parent, string name) => + parent.Cast().FirstOrDefault(x => x.name == name); + + private static int MuzzleNumber(string name) + { + if (!int.TryParse(name.Substring("Muzzle.".Length), NumberStyles.None, CultureInfo.InvariantCulture, out var n)) + throw new InvalidOperationException($"Invalid muzzle name {name}"); + return n; + } + + private static int ParseInt(string part, string name) + { + if (!int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new InvalidOperationException($"Invalid numeric field in {name}"); + return value; + } + + private static T AddOrGet(GameObject gameObject) where T : Component + { + var component = gameObject.GetComponent(); + return component != null ? component : gameObject.AddComponent(); + } + + private static T LoadAsset(string path) where T : Object + { + var asset = AssetDatabase.LoadAssetAtPath(path); + return asset != null ? asset : throw new InvalidOperationException($"Missing shared asset {path}"); + } + + private static T LoadComponent(string path) where T : Component + { + var prefab = LoadAsset(path); + var component = prefab.GetComponent(); + return component != null ? component : throw new InvalidOperationException($"{path} needs {typeof(T).Name}"); + } +} + +// Model import finishes before generated prefabs read its hierarchy. Invalid exports leave the last valid prefab. +public sealed class ShipModelImport : AssetPostprocessor +{ + private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom) + { + var models = imported.Concat(moved).Where(ShipPrefabAuthoring.IsShipModel).Distinct().ToArray(); + if (models.Length == 0) return; + EditorApplication.delayCall += () => + { + foreach (var model in models) + { + try { Debug.Log($"Ship prefab built: {ShipPrefabAuthoring.Build(model)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {model}: {ex.Message}"); } + } + }; + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta new file mode 100644 index 000000000..8256ec660 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59713c487d73ab3418750d958e24d777 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs new file mode 100644 index 000000000..adda9ff7d --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -0,0 +1,80 @@ +using System; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// Focused Editor witness for the naming contract. Run with -executeMethod ShipPrefabAuthoringSmoke.Run. +public static class ShipPrefabAuthoringSmoke +{ + public static void Run() + { + GameObject root = null; + try + { + root = new GameObject("Fixture"); + Primitive(root, "SHIP.MapIcon"); + Primitive(root, "SHIP.HullCollider"); + Child(root, "SHIP.Shield").localScale = new Vector3(2, 3, 4); + Child(root, "SHIP.TractorBeam"); + var thruster = Child(root, "HP.Thruster.PortAft"); + Primitive(thruster.gameObject, "Emitter"); + var weapon = Child(root, "HP.Weapon.PortGun"); + Child(weapon.gameObject, "Muzzle.1"); + Child(weapon.gameObject, "Muzzle.0"); + var radiator = Child(root, "HP.Radiator.Port"); + Primitive(radiator.gameObject, "RadiatorMesh"); + Child(root, "HP.Equipment.Reactor"); + Child(root, "Pivot.2.-90.90.-80.80.60"); + + ShipPrefabAuthoring.Configure(root); + var ship = root.GetComponent(); + Require(ship != null && ship.EquipmentHardpoints.Length == 4, "equipment references"); + Require(ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); + Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles"); + Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); + Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); + Require(ship.ArticulationPoints.Length == 1 && ship.ArticulationPoints[0].Group == 2, "articulation"); + Require(ship.HullColliders.Length == 1 && ship.HullColliders[0].GetComponent().convex, "hull collision"); + Require(ship.Shield != null && ship.Shield.GetComponent() != null, "shield envelope"); + Require(ship.TractorBeam != null && ship.PingPrefab != null && ship.DestroyEffect != null, "shared assets"); + Require(ship.MapIcon != null && ship.MapIcon.gameObject.layer == LayerMask.NameToLayer("Minimap"), "map icon"); + + var invalid = Child(root, "HP.Weapon.Broken"); + var refused = false; + try { ShipPrefabAuthoring.Configure(root); } + catch (InvalidOperationException ex) { refused = ex.Message.Contains("Muzzle"); } + Require(refused, "invalid hierarchy refusal"); + Object.DestroyImmediate(invalid.gameObject); + + Debug.Log("ShipPrefabAuthoringSmoke: named hierarchy and refusal passed"); + if (Application.isBatchMode) EditorApplication.Exit(0); + } + catch (Exception ex) + { + Debug.LogException(ex); + if (Application.isBatchMode) EditorApplication.Exit(1); + else throw; + } + finally { if (root != null) Object.DestroyImmediate(root); } + } + + private static Transform Child(GameObject parent, string name) + { + var child = new GameObject(name).transform; + child.SetParent(parent.transform, false); + return child; + } + + private static GameObject Primitive(GameObject parent, string name) + { + var mesh = GameObject.CreatePrimitive(PrimitiveType.Cube); + mesh.name = name; + mesh.transform.SetParent(parent.transform, false); + return mesh; + } + + private static void Require(bool condition, string claim) + { + if (!condition) throw new InvalidOperationException($"ShipPrefabAuthoringSmoke failed: {claim}"); + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta new file mode 100644 index 000000000..751fd4e8e --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67d84765dc5ea3243b0ab09bc7f1015e \ No newline at end of file diff --git a/docs/content-batch-one.md b/docs/content-batch-one.md index 4b38e4b30..1e14a5a69 100644 --- a/docs/content-batch-one.md +++ b/docs/content-batch-one.md @@ -738,7 +738,8 @@ is ready to write the moment the mint path is. ## 9. Blockers, mechanism gaps, and operator forks -**F1 — Only one spare ship prefab exists, and two hulls are specified.** +**F1 — Resolved by the 2026-09-26 hull restore.** The following was the authoring baseline when this batch was +mapped. Both Longinus and Djinni prefabs are now referenced by live hull records in `GameData/Aetheria.cc`. `ZoneRenderer.cs:295` instantiates `EngineAssets.Load(hullData.Prefab)` and calls `GetComponent()`. A hull with no prefab GUID renders nothing and null-refs. The project holds exactly two ship prefabs: `Assets/Content/Prefabs/Ships/Longinus.prefab` (used by diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md new file mode 100644 index 000000000..b4e85f7a1 --- /dev/null +++ b/docs/ship-collection-authoring.md @@ -0,0 +1,83 @@ +# Ship collection authoring + +Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs. + +## Objective and ownership + +One Blender collection exports one FBX model. Its hierarchy names the ship's presentation anchors. Unity builds +`Assets/Content/Prefabs/Ships/.prefab` from that model and assigns the components and references that the +runtime currently requires on a hand-built ship prefab. `GameData/Aetheria.cc` remains the owner of `HullData`, +hardpoint cells and types, gameplay stats, products, and the prefab's Addressables key. The model never decides a +hit, thruster force, weapon arc, or shield capacity. + +Today `ShipInstance` reads manually filled arrays on each ship prefab. It matches their transform names against +`HullData.Hardpoints[].Transform`. Its shared ping, invisible material, explosion, shield, and tractor references +also live on each prefab. The builder derives the arrays from the FBX hierarchy and supplies the shared Unity +assets once, from their existing project paths. The old prefabs are left alone. + +## Blender collection and export + +- Name the collection `Ship.` and configure its collection exporter as FBX. Export to + `Assets/Models/Ships/.fbx`. The filename is the generated prefab name. Blender's collection exporter can + export a collection directly and repeat that export after edits. +- Export meshes and empties, with animation disabled. Use Blender's `-Z Forward`, `Y Up` axis conversion for Unity. + Keep the collection origin at the ship's simulation centre, with the nose pointing toward the exported +Z axis. + Apply object scales on visual and collision meshes; the `Shield` empty's scale is the deliberate exception. +- The importer owns every prefab under `Assets/Content/Prefabs/Ships/` whose matching model is in + `Assets/Models/Ships/`. Editing generated components by hand is temporary; the next export rebuilds them. + Put appearance in the FBX and gameplay values in the catalog. + +The exported hierarchy uses **exact, case-sensitive names**. Names beginning `HP.`, `Pivot.`, or `SHIP.` are +reserved. IDs after a role prefix must be unique across the model. Other visual objects may have any names. + +```text + FBX root + Body visible meshes, any nesting + SHIP.MapIcon one mesh; assigned the shared map material and Minimap layer + SHIP.HullCollider one low-poly closed convex mesh; rendered invisible, given a MeshCollider + SHIP.Shield one empty; its position and scale set the shield envelope + SHIP.TractorBeam one empty; its pose sets the tractor effect origin + HP.Thruster.PortAft empty; exact name copied to HullData.Hardpoints[].Transform + Emitter one mesh; thruster particle emission shape + HP.Weapon.PortGun empty; exact name copied to the catalog + Muzzle.0 empty; +Z is firing direction + Muzzle.1 optional additional barrel + HP.Radiator.Port empty; exact name copied to the catalog + RadiatorMesh one mesh; temperature emission surface + HP.Equipment.Reactor empty; exact name copied to the catalog + Pivot.0.-90.90.-80.80.60 optional empty parent of articulated mounts +``` + +`HP.Equipment` covers sensors, reactor, shield, cargo, and other mounts without a special visual component. +`HP.Weapon` covers ballistic, energy, and launcher mounts; the catalog chooses which. Every `HP.*` becomes an +`EquipmentHardpoints` entry. Thruster, weapon, and radiator roles also get their matching components and arrays. +`Muzzle.` children are sorted numerically; their transforms become `FiringPoint`. A weapon +mount requires at least one. A `Pivot` name carries `Group.YawMin.YawMax.PitchMin.PitchMax.Speed` as signed +integers in degrees and degrees per second. It gets `ArticulationPoint`; child mounts follow its transform. Pivots are +presentation only: the simulation's catalog firing arc remains authoritative. + +`SHIP.Shield` receives the existing Shield prefab as a child. That prefab's effective unscaled radius is one +Unity unit; the marker's three scale components are the intended envelope radii in world units. The builder adds +`ShieldEnvelope` to the Shield prefab root if absent. `SHIP.TractorBeam` receives the existing Tractor Beam +prefab. The four `SHIP.*` markers are required exactly once. Ship-specific mesh materials remain authored in +Blender/Unity's material import mapping; the builder only assigns the shared map-icon material. + +## Build, validation, and failure + +Re-exporting an FBX in `Assets/Models/Ships/` schedules a prefab rebuild after Unity finishes importing it. +`Aetheria/Build Ship Prefab From Selected Model` performs the same build on demand. The builder validates all +reserved names, required anchors, component meshes, muzzle numbering, and shared asset references **before** +replacing a prefab. A bad export reports an error and leaves the last valid prefab in place. The output is a +Prefab Variant of the FBX model, so mesh and transform identities remain tied to the source export. + +After adding a ship, author its `HullData` and product in the catalog, set `HullData.Prefab` to the generated +prefab's GUID, and set every `HardpointData.Transform` to an exact `HP.*` name. Run the catalog asset check and +the Unity compile. A visual play check still owns size, materials, shield fit, barrel orientation, and thrust +appearance. The builder cannot infer the 2D schematic from a 3D mesh. + +## Cut and build budget + +This adds an Editor-only builder, its focused smoke, and this contract. It adds no runtime component, persistent field, package, +service, or build target. It replaces per-ship array and shared-reference authoring for newly exported ships. +The focused verification target is Unity Editor script compilation plus a synthetic named-hierarchy check; a +real Blender collection export is required before claiming end-to-end import fidelity.