Define collection-based ship prefab authoring

This commit is contained in:
Meta Vens 2026-09-26 15:26:46 +02:00
commit 4b594e116c
6 changed files with 421 additions and 1 deletions

View file

@ -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<GameObject>(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<Transform>(true);
var named = new Dictionary<string, Transform>(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<MeshRenderer>();
if (mapIcon == null) throw new InvalidOperationException("SHIP.MapIcon needs a mesh renderer");
var hull = Required("SHIP.HullCollider");
var hullFilter = hull.GetComponent<MeshFilter>();
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<ShieldManager>(ShieldPath);
var tractorPrefab = LoadComponent<TractorBeam>(TractorPath);
var ping = LoadComponent<Transform>(PingPath);
var explosion = LoadAsset<GameObject>(ExplosionPath);
var invisible = LoadAsset<Material>(InvisiblePath);
var mapMaterial = LoadAsset<Material>(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<Transform>();
var thrusters = new List<ThrusterHardpoint>();
var weapons = new List<WeaponHardpoint>();
var radiators = new List<RadiatorHardpoint>();
var pivots = new List<ArticulationPoint>();
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<MeshRenderer>();
if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child");
AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter;
thrusters.Add(node.GetComponent<ThrusterHardpoint>());
break;
case "Weapon":
var muzzles = node.Cast<Transform>()
.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.<n> children");
AddOrGet<WeaponHardpoint>(node.gameObject).FiringPoint = muzzles.Select(x => x.node).ToArray();
weapons.Add(node.GetComponent<WeaponHardpoint>());
break;
case "Radiator":
var mesh = DirectChild(node, "RadiatorMesh")?.GetComponent<MeshRenderer>();
if (mesh == null) throw new InvalidOperationException($"{name} needs a RadiatorMesh child");
AddOrGet<RadiatorHardpoint>(node.gameObject).Mesh = mesh;
radiators.Add(node.GetComponent<RadiatorHardpoint>());
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<ArticulationPoint>(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<MeshRenderer>();
if (hullRenderer != null) hullRenderer.forceRenderingOff = true;
var collider = AddOrGet<MeshCollider>(hull.gameObject);
collider.sharedMesh = hullMesh;
collider.convex = true;
var hullSurface = AddOrGet<HullCollider>(hull.gameObject);
var shield = (ShieldManager)PrefabUtility.InstantiatePrefab(shieldPrefab, shieldMarker);
shield.transform.localPosition = Vector3.zero;
shield.transform.localRotation = Quaternion.identity;
if (shield.GetComponent<ShieldEnvelope>() == null) shield.gameObject.AddComponent<ShieldEnvelope>();
var tractor = (TractorBeam)PrefabUtility.InstantiatePrefab(tractorPrefab, tractorMarker);
tractor.transform.localPosition = Vector3.zero;
tractor.transform.localRotation = Quaternion.identity;
var ship = AddOrGet<ShipInstance>(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<Transform>().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<T>(GameObject gameObject) where T : Component
{
var component = gameObject.GetComponent<T>();
return component != null ? component : gameObject.AddComponent<T>();
}
private static T LoadAsset<T>(string path) where T : Object
{
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
return asset != null ? asset : throw new InvalidOperationException($"Missing shared asset {path}");
}
private static T LoadComponent<T>(string path) where T : Component
{
var prefab = LoadAsset<GameObject>(path);
var component = prefab.GetComponent<T>();
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}"); }
}
};
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 59713c487d73ab3418750d958e24d777

View file

@ -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<ShipInstance>();
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<MeshCollider>().convex, "hull collision");
Require(ship.Shield != null && ship.Shield.GetComponent<ShieldEnvelope>() != 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}");
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67d84765dc5ea3243b0ab09bc7f1015e

View file

@ -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<GameObject>(hullData.Prefab)` and calls
`GetComponent<ShipInstance>()`. 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

View file

@ -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/<Name>.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.<Name>` and configure its collection exporter as FBX. Export to
`Assets/Models/Ships/<Name>.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
<Name> 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.<nonnegative integer>` 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.