Merge pull request 'Moddable ships: starter command, top-speed fix, Wren stand-in package, thrust-scale test pins' (#1) from nibu/moddable-ships-wiring into master
This commit is contained in:
commit
c33feebde2
13 changed files with 371 additions and 20 deletions
|
|
@ -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<string>();
|
||||
var loadout = ItemManager.ItemData.GetAll<Loadout>().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;
|
||||
|
|
|
|||
BIN
GameData/Mods/aetheria.wren/ship.cc
(Stored with Git LFS)
Normal file
BIN
GameData/Mods/aetheria.wren/ship.cc
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
GameData/Mods/aetheria.wren/ship.glb
(Stored with Git LFS)
Normal file
BIN
GameData/Mods/aetheria.wren/ship.glb
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -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 <ship.cc> --top-speed <v>`
|
||||
adds the one `VelocityLimit` it needs (and edits it on a later run).
|
||||
Without Blender, `ship-authoring starter <mods-dir> <id> <name> --like <shipped hull>` 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 <ship.cc>` 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 <ship.cc> <maker> --name <name>
|
||||
[--description <text>]` 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 <hull>` 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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>("GameplaySettings");
|
||||
internal static GameplaySettings Settings() => PinnedFlight(AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("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
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ public sealed partial class RunStartTests : IDisposable
|
|||
_galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
|
||||
_cache, new PlayerSettings(),
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed);
|
||||
_items = new ItemManager(1u, _cache, new ProvenanceLedger(), authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
|
||||
_items = new ItemManager(1u, _cache, new ProvenanceLedger(), RestoredHullsTests.PinnedFlight(authored.Read<GameplaySettings>("GameplaySettings")), _ => { });
|
||||
_protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction);
|
||||
|
||||
// GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree
|
||||
|
|
|
|||
|
|
@ -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<bool>().Count(cell => cell));
|
||||
|
||||
var before = File.ReadAllBytes(path);
|
||||
Assert.Throws<InvalidOperationException>(() => ShipStarterCommand.Write(mods, "probe.starter", "Other", like));
|
||||
Assert.Equal(before, File.ReadAllBytes(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentreCommandPrintsShapeCenterOfMass()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<VelocityLimitData>().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<VelocityLimitData>());
|
||||
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<VelocityLimitData>()).TopSpeed;
|
||||
Assert.Equal((60f, 60f), (added.Min, added.Max));
|
||||
Assert.Equal(5f, ShipAuthoringStore.Load(bare).Hull.Mass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
68
tests/Aetheria.Shared.Tests/WrenPackageTests.cs
Normal file
68
tests/Aetheria.Shared.Tests/WrenPackageTests.cs
Normal file
|
|
@ -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<HullData>(ShipModCatalog.HullKey(Id)));
|
||||
|
||||
[Fact]
|
||||
public void TheStarterPackageIsInTheComposedCatalogWithTheStatsOfItsLike()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var hull = Wren(cache);
|
||||
var like = cache.GetByName<HullData>("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<FactionProductData>(), 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<EquippableItemData>().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<VelocityLimit>().Limit > 0f);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
|
||||
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | starter <mods-dir> <stable-id> <name> --like <shipped hull name> | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
|
||||
|
||||
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<BehaviorData>();
|
||||
var limits = hull.Behaviors.OfType<VelocityLimitData>().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<BehaviorData>()).OfType<VelocityLimitData>().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<BehaviorData>()).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;
|
||||
}
|
||||
|
|
|
|||
213
tools/AetherDb/ShipStarterCommand.cs
Normal file
213
tools/AetherDb/ShipStarterCommand.cs
Normal file
|
|
@ -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
|
||||
// (<mods>/<id>/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<byte>();
|
||||
var views = new List<string>();
|
||||
var accessors = new List<string>();
|
||||
var meshes = new List<string>();
|
||||
var meshOf = new Dictionary<Part, int>();
|
||||
|
||||
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<float>();
|
||||
var normals = new List<float>();
|
||||
var indices = new List<ushort>();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue