Define collection-based ship prefab authoring
This commit is contained in:
parent
354e2f05ba
commit
4b594e116c
6 changed files with 421 additions and 1 deletions
252
Assets/Scripts/Editor/ShipPrefabAuthoring.cs
Normal file
252
Assets/Scripts/Editor/ShipPrefabAuthoring.cs
Normal 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}"); }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta
Normal file
2
Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 59713c487d73ab3418750d958e24d777
|
||||
80
Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs
Normal file
80
Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta
Normal file
2
Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 67d84765dc5ea3243b0ab09bc7f1015e
|
||||
|
|
@ -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
|
||||
|
|
|
|||
83
docs/ship-collection-authoring.md
Normal file
83
docs/ship-collection-authoring.md
Normal 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.
|
||||
Loading…
Reference in a new issue