diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 62105a711..a0cf3a4c1 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -590,12 +590,24 @@ public class ActionGameManager : MonoBehaviour } var generator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, null, 2); var pack = generator.GenerateShipLoadout(data => string.Equals(data.Name, hullName, StringComparison.InvariantCultureIgnoreCase)); - if (pack == null) + Ship ship; + if (pack != null) ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack); + else { - ConsoleController.Instance.AppendLogLine($"No ship hull named \"{hullName}\""); - return; + // An unoffered hull is drawn by nobody; it reaches a ship only through a Loadout (ruling off-market-is-general). + var failures = new List(); + var loadout = ItemManager.ItemData.GetAll().FirstOrDefault(l => + ItemManager.ItemData.Get(l.Hull) is { HullType: HullType.Ship } hull && + string.Equals(hull.Name, hullName, StringComparison.InvariantCultureIgnoreCase)); + ship = loadout == null ? null : Loadouts.Materialize(ItemManager, Zone, loadout, generator.ChooseProduct, failures) as Ship; + if (ship == null) + { + ConsoleController.Instance.AppendLogLine(failures.Count > 0 + ? $"Cannot build \"{hullName}\": {string.Join("; ", failures)}" + : $"No ship hull named \"{hullName}\" that a maker offers or a Loadout fits"); + return; + } } - var ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack); ship.IsPlayerShip = true; ship.Direction = _currentEntity.Direction; ship.Position.xz = _currentEntity.Position.xz + _currentEntity.Direction.Rotate(ItemRotation.Clockwise) * 30; diff --git a/GameData/Mods/aetheria.wren/ship.cc b/GameData/Mods/aetheria.wren/ship.cc new file mode 100644 index 000000000..d81f9bba5 --- /dev/null +++ b/GameData/Mods/aetheria.wren/ship.cc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b1744dfa7dd6c5325ca3bd04fba941408cdc1255c06d1fc0d52fa67c97dc100 +size 10514 diff --git a/GameData/Mods/aetheria.wren/ship.glb b/GameData/Mods/aetheria.wren/ship.glb new file mode 100644 index 000000000..03b70b729 --- /dev/null +++ b/GameData/Mods/aetheria.wren/ship.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98bf1b4a0e19e67568930ca79b9d61529c21c63dd74ee8df3d3f1d419d3a81cc +size 6964 diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 446bb6d89..81871b2dc 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -63,6 +63,11 @@ be empties. Package exports the collection, Grease Pencil excluded, to collection holds exactly one Grease Pencil object, and shows AetherDb's verdict in the panel. It runs the AetherDb in the game folder's `ModTools` (see Tools below). +A package made with `create` and no `--like` has no top speed, which `validate` refuses; `ship-authoring stats --top-speed ` +adds the one `VelocityLimit` it needs (and edits it on a later run). +Without Blender, `ship-authoring starter --like ` writes a complete flyable package: the +shipped hull's stats, a 4×6 grid carrying two thrusters, a radiator, two energy guns and the internal modules, and a boxed +`ship.glb` with every anchor. The model is a stand-in to replace through Blender and **Package**. `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring, and judges `ship.glb` once it exists. @@ -240,8 +245,9 @@ catalog. Continue refuses a run naming a design the catalog no longer holds price and `ship-authoring product --name [--description ]` writes the package's product by that maker (`--remove` deletes it). A package with a product needs a price above 0, - because generation skips price 0; a package with none stays valid and is - console-only. Compose follows. + because generation skips price 0; a package with none stays valid but no + maker offers it, so generation never draws it. Console `fly ` reaches such a hull only + through a catalog Loadout of it. Compose follows. 3. A command-line validator rejects malformed cells, overlapping or dangling hardpoints, missing model nodes, and unresolved asset references with precise errors. It changes no live catalog or scene on failure. The diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index 95958170c..3ac400c56 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -53,7 +53,8 @@ public sealed class BrownoutTests : IDisposable { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, - QualityPriceModifier = new ExponentialLerp() + QualityPriceModifier = new ExponentialLerp(), + ThrustScale = 1f // these pin a thruster's authored push, not the global flight tuning }; private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 00d446384..3e02965db 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -28,7 +28,8 @@ public sealed class ConditionRatioTests : IDisposable { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, - QualityPriceModifier = new ExponentialLerp() + QualityPriceModifier = new ExponentialLerp(), + ThrustScale = 1f // these pin a thruster's authored push, not the global flight tuning }; private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 6dd235551..121d468b3 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -31,7 +31,15 @@ public sealed class RestoredHullsTests // (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to // inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of // hand-copying its fields a second time. - internal static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"); + internal static GameplaySettings Settings() => PinnedFlight(AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings")); + + // The authored settings with the global thrust tuning at 1 (GameplaySettings.ThrustScale, 0.1 in play): tests that pin + // what a thruster, a hull or a brain does read the gear's own thrust, so a retune of the knob does not move them. + internal static GameplaySettings PinnedFlight(GameplaySettings settings) + { + settings.ThrustScale = 1f; + return settings; + } // Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped // assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 69b040d4e..efd17eb71 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -53,7 +53,7 @@ public sealed partial class RunStartTests : IDisposable _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), _cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed); - _items = new ItemManager(1u, _cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RestoredHullsTests.PinnedFlight(authored.Read("GameplaySettings")), _ => { }); _protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction); // GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 717108d8d..3def3e6dd 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -501,6 +501,28 @@ public sealed class ShipModCatalogTests : IDisposable Assert.True(File.Exists(target), error); } + // starter: a complete package from nothing but a hull to take stats from. The package must pass the reader the game boots + // with (GLB node ids and mesh roles included) and plan a rig, and a second run must not touch the first package. + [Fact] + public void StarterWritesAPackageTheGameLoadsAndPlansARigFrom() + { + var like = new HullData { Name = "Like", HullType = HullType.Ship, Mass = 1234, Behaviors = { new VelocityLimitData() } }; + var mods = Path.Combine(_directory.Path, "GameData", "Mods"); + var path = ShipStarterCommand.Write(mods, "probe.starter", "Probe Starter", like); + + var package = ShipModCatalog.ReadPackage(path); + Assert.Equal(("Probe Starter", 1234f), (package.Hull.Name, package.Hull.Mass)); + var plan = ShipModPlan.Build(package.Hull, package.Visual); + Assert.Equal(2, plan.Thrusters.Length); + Assert.Single(plan.Radiators); + Assert.Equal(new[] { 1, 1 }, plan.Weapons.Select(weapon => weapon.Muzzles.Length)); + Assert.Equal(package.Hull.Shape.Width * package.Hull.Shape.Height, package.Hull.Shape.Cells.Cast().Count(cell => cell)); + + var before = File.ReadAllBytes(path); + Assert.Throws(() => ShipStarterCommand.Write(mods, "probe.starter", "Other", like)); + Assert.Equal(before, File.ReadAllBytes(path)); + } + [Fact] public void CentreCommandPrintsShapeCenterOfMass() { diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f2326ed82..343f35082 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -383,10 +383,15 @@ public sealed class ShipProductTests : IDisposable Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); - // A hull with no top speed (create without --like) gets its one VelocityLimit from the first --top-speed. - Assert.Equal(0, Run("stats", bare, "--top-speed", "50").Code); - var added = ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType().Single().TopSpeed; - Assert.Equal((50f, 50f), (added.Min, added.Max)); + + // A hull with no VelocityLimit gets exactly one from --top-speed, the only way a hull made without --like can + // satisfy validate; a second run edits that one rather than adding another. + Assert.Empty(ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType()); + Assert.Equal(0, Run("stats", bare, "--mass", "5", "--top-speed", "50").Code); + Assert.Equal(0, Run("stats", bare, "--top-speed", "60").Code); + var added = Assert.Single(ShipAuthoringStore.Load(bare).Hull.Behaviors.OfType()).TopSpeed; + Assert.Equal((60f, 60f), (added.Min, added.Max)); + Assert.Equal(5f, ShipAuthoringStore.Load(bare).Hull.Mass); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/WrenPackageTests.cs b/tests/Aetheria.Shared.Tests/WrenPackageTests.cs new file mode 100644 index 000000000..bb95cbb55 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/WrenPackageTests.cs @@ -0,0 +1,68 @@ +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using Random = CultMath.Random; + +// GameData/Mods/aetheria.wren: a first-party package written by `ship-authoring starter --like Djinni`, then priced and given +// an unoffered Aeronautics Unlimited product by `ship-authoring price` and `product`; its model is the starter's boxed +// stand-in. It is the pipeline's proof from a hull package on disk to a ship that flies in the headless simulation: the +// package composes into the catalog, its mounts take catalog gear, and the fitted ship moves under thrust. `fly` cannot +// reach it (an unoffered product is never drawn), so these tests are the flight check until a pinned Loadout exists. +public sealed class WrenPackageTests +{ + private const string Id = "aetheria.wren"; + + private static HullData Wren(CultCache cache) => cache.Get(new CultRecordRef(ShipModCatalog.HullKey(Id))); + + [Fact] + public void TheStarterPackageIsInTheComposedCatalogWithTheStatsOfItsLike() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var hull = Wren(cache); + var like = cache.GetByName("Djinni"); + Assert.NotNull(hull); + Assert.Equal(("Wren", HullType.Ship, like.Mass, like.Durability), (hull.Name, hull.HullType, hull.Mass, hull.Durability)); + Assert.Equal(ShipModCatalog.AuthoringKey(Id), hull.Visual.Key); + Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Energy)); + } + + // The starter's product is its maker's but off the market (rulings package-carries-product, off-market-is-unoffered-product): + // the census wants every design sold or placed, and no faction fields a stand-in box. + [Fact] + public void TheStarterIsMadeButNeverOffered() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var hull = Wren(cache); + var product = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(ShipModCatalog.HullKey(Id))); + Assert.Equal("Aeronautics Unlimited", cache.Get(product.Manufacturer).Name); + Assert.True(product.NotOffered); + Assert.True(hull.Price > 0); + Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(cache)); + } + + // Every mount takes a catalog design: the starter's footprints are sized to gear that exists, so it can be fitted out. + [Fact] + public void EveryMountTakesACatalogDesign() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var designs = cache.GetAll().ToArray(); + var bare = Wren(cache).Hardpoints.Where(hardpoint => !designs.Any(hardpoint.Takes)).Select(hardpoint => $"{hardpoint.Transform} ({hardpoint.Type})").ToArray(); + // No shield design fits a 1x1 yet (hardpoint-fit lists Djinni's 4-cell shield with none either); every other mount does. + Assert.Equal(new[] { "internal.shield (Shield)" }, bare); + } + + [Fact] + public void TheStarterFliesUnderThrust() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, "Wren"); + var start = ship.Velocity; + ship.MovementDirection = float2(0, 1); + for (var i = 0; i < 10; i++) ship.Zone.Update(1f / 60f); + Assert.NotEqual(start, ship.Velocity); + Assert.True(ship.GetBehavior().Limit > 0f); + } +} diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 9f8f1c41c..841da957e 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + private const string Usage = "ship-authoring create [--like ] | starter --like | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; @@ -78,6 +78,12 @@ public static class ShipAuthoringCommands Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); return 0; } + if (args[0] == "starter" && args.Length == 6 && args[4] == "--like") + { + var path = ShipStarterCommand.Write(args[1], args[2], args[3], ShippedShipHull(args[5])); + Console.WriteLine($"Wrote a flyable starter package {path}; replace its stand-in model through Blender and Package, then set price and product."); + return 0; + } if (args[0] == "price" && args.Length == 3) { // Judged before the file is touched: a refusal leaves it byte-identical. @@ -110,11 +116,14 @@ public static class ShipAuthoringCommands var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); if (stats.TryGetValue("--top-speed", out var topSpeed)) { - hull.Behaviors ??= new List(); - var limits = hull.Behaviors.OfType().ToList(); - // A hull made without --like has none, and Validate refuses a ship hull without one: the first --top-speed adds it. - if (limits.Count == 0) { limits.Add(new VelocityLimitData()); hull.Behaviors.Add(limits[0]); } - if (limits.Count != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs at most one VelocityLimit on the hull; it has {limits.Count}."); + var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); + if (limits.Length > 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs at most one VelocityLimit on the hull; it has {limits.Length}."); + // A hull made without --like has none, and this is its only way to get one: validate refuses a ship without a top speed. + if (limits.Length == 0) + { + limits = new[] { new VelocityLimitData() }; + (hull.Behaviors ??= new List()).Add(limits[0]); + } // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; } diff --git a/tools/AetherDb/ShipStarterCommand.cs b/tools/AetherDb/ShipStarterCommand.cs new file mode 100644 index 000000000..2a502a1f5 --- /dev/null +++ b/tools/AetherDb/ShipStarterCommand.cs @@ -0,0 +1,213 @@ +using GameCult.Caching; +using CultMath; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +// `ship-authoring starter`: a complete, flyable mod package for authors with no Blender at hand. It writes the package +// (//ship.cc and ship.glb) with the stats of a shipped hull, a small hull grid, a working rig (two thrusters, +// a radiator, two energy guns, the internal modules) and a boxed model that carries every anchor the rig needs. The +// model is a stand-in an author replaces through Blender and Package; the records are judged by the same validator and +// reader the game boots with, so a starter that is written is a starter the game loads. +public static class ShipStarterCommand +{ + // Hull grid, x across, y from the stern (0) to the bow. The mount rows below say what sits in each cell. + private const int Width = 4, Height = 6; + + private sealed class Part + { + public string Id, Role, Parent; + public HardpointType? Mount; + public int2 Cell, Size = new int2(1, 1); + public float3 Position; + // Box half-extents; null for an empty node. Scale is the node scale, used by the shield envelope. + public float3? Half; + public float3? Scale; + } + + public static string Write(string modsRoot, string id, string name, HullData like) + { + ShipAuthoringStore.RequireShipId(id); + var directory = Path.Combine(Path.GetFullPath(modsRoot), id); + var path = Path.Combine(directory, "ship.cc"); + if (File.Exists(path) || File.Exists(Path.Combine(directory, "ship.glb"))) + throw new InvalidOperationException($"Refusing to replace the package in {directory}"); + + // Metres, nose toward -Y, up +Z, as the Blender add-on's kit frame has it; the GLB writer converts to glTF's Y-up. + var parts = new[] + { + new Part { Id = "map", Role = "map-icon", Half = new float3(3f, 5f, .05f) }, + new Part { Id = "collider", Role = "hull-collider", Half = new float3(3f, 5f, 1f) }, + new Part { Id = "shield", Role = "shield", Scale = new float3(4f, 6f, 2.5f) }, + new Part { Id = "tractor", Role = "tractor", Position = new float3(0f, -5f, 0f) }, + new Part { Id = "thruster.port", Role = "thruster-emitter", Mount = HardpointType.Thruster, Cell = new int2(0, 0), Size = new int2(2, 1), Position = new float3(2.2f, 5.4f, 0f), Half = new float3(.4f, .6f, .4f) }, + new Part { Id = "thruster.starboard", Role = "thruster-emitter", Mount = HardpointType.Thruster, Cell = new int2(2, 0), Size = new int2(2, 1), Position = new float3(-2.2f, 5.4f, 0f), Half = new float3(.4f, .6f, .4f) }, + new Part { Id = "radiator", Role = "radiator-mesh", Mount = HardpointType.Radiator, Cell = new int2(1, 1), Size = new int2(2, 1), Position = new float3(0f, 2f, 1.1f), Half = new float3(1.5f, .8f, .08f) }, + new Part { Id = "gun.port", Role = "weapon-mount", Mount = HardpointType.Energy, Cell = new int2(0, 5), Position = new float3(1.8f, -3f, 1.1f) }, + new Part { Id = "gun.port.muzzle", Role = "weapon-muzzle", Parent = "gun.port", Position = new float3(1.8f, -4f, 1.1f) }, + new Part { Id = "gun.starboard", Role = "weapon-mount", Mount = HardpointType.Energy, Cell = new int2(3, 5), Position = new float3(-1.8f, -3f, 1.1f) }, + new Part { Id = "gun.starboard.muzzle", Role = "weapon-muzzle", Parent = "gun.starboard", Position = new float3(-1.8f, -4f, 1.1f) }, + }; + // Internal modules show on no model, so they have a cell and no anchor. + var internals = new[] + { + ("reactor", HardpointType.Reactor, new int2(1, 2), new int2(2, 2)), ("shield", HardpointType.Shield, new int2(3, 4), new int2(1, 1)), + ("control", HardpointType.ControlModule, new int2(1, 4), new int2(2, 2)), ("sensors", HardpointType.Sensors, new int2(0, 4), new int2(1, 1)), + }; + + var ship = new ShipAuthoring + { + Id = id, + ModelAsset = "ship.glb", + Anchors = parts.Select(part => new ShipAnchor { Id = part.Id, Role = part.Role, ModelNodeId = part.Id, ParentId = part.Parent }).ToList() + }; + var hull = ShipAuthoringStore.HullLike(like, name, id); + hull.Shape = new Shape(Width, Height); + for (var x = 0; x < Width; x++) + for (var y = 0; y < Height; y++) + hull.Shape.Cells[x, y] = true; + hull.Hardpoints = parts.Where(part => part.Mount != null) + .Select(part => (part.Id, part.Mount.Value, part.Cell, part.Size)) + .Concat(internals.Select(row => (Id: "internal." + row.Item1, Mount: row.Item2, Cell: row.Item3, Size: row.Item4))) + .Select(row => new HardpointData + { + Type = row.Item2, Position = row.Item3, Shape = Block(row.Item4), Transform = row.Item1, + // The grid's stern is y = 0 and its bow the far row: a drive there pushes the ship toward the bow, which the + // authoring grid spells Reversed (as Djinni's stern drives are). + Rotation = row.Item2 == HardpointType.Thruster ? ItemRotation.Reversed : ItemRotation.None + }).ToList(); + ShipAuthoringStore.Validate(hull, ship); + + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, ship.ModelAsset), Glb(parts)); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + // The reader the game boots with, over the written package: the GLB's node ids and mesh roles are judged too. + ShipModCatalog.ReadPackage(path); + return path; + } + + private static Shape Block(int2 size) + { + var shape = new Shape(size.x, size.y); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // One GLB holding every part as a root-level node, each box with its own vertices (a convex collider cooks from it + // as it is), and each muzzle a child of its mount. Node ids travel in extras.aetheria.id, as Package writes them. + private static byte[] Glb(Part[] parts) + { + var bin = new List(); + var views = new List(); + var accessors = new List(); + var meshes = new List(); + var meshOf = new Dictionary(); + + int View(byte[] bytes, int target) + { + while (bin.Count % 4 != 0) bin.Add(0); + views.Add($@"{{""buffer"":0,""byteOffset"":{bin.Count},""byteLength"":{bytes.Length},""target"":{target}}}"); + bin.AddRange(bytes); + return views.Count - 1; + } + string F(float value) => value.ToString("R", CultureInfo.InvariantCulture); + + foreach (var part in parts.Where(part => part.Half != null)) + { + var (hx, hy, hz) = (part.Half.Value.x, part.Half.Value.y, part.Half.Value.z); + var positions = new List(); + var normals = new List(); + var indices = new List(); + // Six faces, outward normals, in the Blender frame; Gltf() turns each into glTF's. + foreach (var (axis, sign) in new[] { (0, 1), (0, -1), (1, 1), (1, -1), (2, 1), (2, -1) }) + { + var u = (axis + 1) % 3; + var v = (axis + 2) % 3; + var half = new[] { hx, hy, hz }; + var first = (ushort)(positions.Count / 3); + foreach (var (a, b) in new[] { (-1, -1), (1, -1), (1, 1), (-1, 1) }) + { + var corner = new float[3]; + corner[axis] = sign * half[axis]; + corner[u] = a * half[u]; + corner[v] = b * half[v]; + positions.AddRange(Gltf(corner[0], corner[1], corner[2])); + var normal = new float[3]; + normal[axis] = sign; + normals.AddRange(Gltf(normal[0], normal[1], normal[2])); + } + // The (axis, u, v) frame is right-handed, so the corner order above winds counter-clockwise seen from +axis. + var order = sign > 0 ? new[] { 0, 1, 2, 0, 2, 3 } : new[] { 0, 2, 1, 0, 3, 2 }; + indices.AddRange(order.Select(i => (ushort)(first + i))); + } + var positionView = View(positions.SelectMany(BitConverter.GetBytes).ToArray(), 34962); + var normalView = View(normals.SelectMany(BitConverter.GetBytes).ToArray(), 34962); + var indexView = View(indices.SelectMany(BitConverter.GetBytes).ToArray(), 34963); + var low = Gltf(-hx, -hy, -hz); + var high = Gltf(hx, hy, hz); + var box = new[] { Math.Min(low[0], high[0]), Math.Min(low[1], high[1]), Math.Min(low[2], high[2]), Math.Max(low[0], high[0]), Math.Max(low[1], high[1]), Math.Max(low[2], high[2]) }; + accessors.Add($@"{{""bufferView"":{positionView},""componentType"":5126,""count"":{positions.Count / 3},""type"":""VEC3"",""min"":[{F(box[0])},{F(box[1])},{F(box[2])}],""max"":[{F(box[3])},{F(box[4])},{F(box[5])}]}}"); + accessors.Add($@"{{""bufferView"":{normalView},""componentType"":5126,""count"":{normals.Count / 3},""type"":""VEC3""}}"); + accessors.Add($@"{{""bufferView"":{indexView},""componentType"":5123,""count"":{indices.Count},""type"":""SCALAR""}}"); + meshOf[part] = meshes.Count; + meshes.Add($@"{{""primitives"":[{{""attributes"":{{""POSITION"":{accessors.Count - 3},""NORMAL"":{accessors.Count - 2}}},""indices"":{accessors.Count - 1}}}]}}"); + } + + // A muzzle is a child of its mount, so its translation is relative to the mount's. + var byId = parts.ToDictionary(part => part.Id, StringComparer.Ordinal); + var nodeIndex = parts.Select((part, index) => (part.Id, index)).ToDictionary(pair => pair.Id, pair => pair.index, StringComparer.Ordinal); + string Node(Part part) + { + var translation = part.Parent == null ? part.Position : part.Position - byId[part.Parent].Position; + var g = Gltf(translation.x, translation.y, translation.z); + var json = new StringBuilder($@"{{""name"":""{part.Id}"""); + if (meshOf.TryGetValue(part, out var mesh)) json.Append($@",""mesh"":{mesh}"); + if (part.Scale != null) { var s = Gltf(part.Scale.Value.x, part.Scale.Value.y, part.Scale.Value.z); json.Append($@",""scale"":[{F(Math.Abs(s[0]))},{F(Math.Abs(s[1]))},{F(Math.Abs(s[2]))}]"); } + json.Append($@",""translation"":[{F(g[0])},{F(g[1])},{F(g[2])}]"); + var children = parts.Where(child => child.Parent == part.Id).Select(child => nodeIndex[child.Id]).ToArray(); + if (children.Length > 0) json.Append($@",""children"":[{string.Join(",", children)}]"); + json.Append($@",""extras"":{{""aetheria.id"":""{part.Id}""}}}}"); + return json.ToString(); + } + var roots = parts.Where(part => part.Parent == null).Select(part => nodeIndex[part.Id]); + var document = $@"{{""asset"":{{""version"":""2.0"",""generator"":""AetherDb ship-authoring starter""}},""scene"":0,""scenes"":[{{""nodes"":[{string.Join(",", roots)}]}}]," + + $@"""nodes"":[{string.Join(",", parts.Select(Node))}],""meshes"":[{string.Join(",", meshes)}],""accessors"":[{string.Join(",", accessors)}]," + + $@"""bufferViews"":[{string.Join(",", views)}],""buffers"":[{{""byteLength"":{bin.Count + (4 - bin.Count % 4) % 4}}}]}}"; + return Container(document, bin.ToArray()); + } + + // Blender (x right, y back, z up) to glTF (x right, y up, z toward the viewer): the exporter's +Y-up conversion. + private static float[] Gltf(float x, float y, float z) => new[] { x, z, -y }; + + private static byte[] Container(string json, byte[] bin) + { + byte[] Pad(byte[] bytes, byte fill) + { + var padded = new byte[(bytes.Length + 3) / 4 * 4]; + Array.Fill(padded, fill); + bytes.CopyTo(padded, 0); + return padded; + } + var body = Pad(Encoding.UTF8.GetBytes(json), (byte)' '); + var data = Pad(bin, 0); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write(0x46546C67u); + writer.Write(2u); + writer.Write((uint)(28 + body.Length + data.Length)); + writer.Write((uint)body.Length); + writer.Write(0x4E4F534Au); + writer.Write(body); + writer.Write((uint)data.Length); + writer.Write(0x004E4942u); + writer.Write(data); + return stream.ToArray(); + } +}