ship-authoring starter: a complete flyable package for authors with no Blender
Writes <mods>/<id>/ship.cc and ship.glb: a shipped hull's stats, a 4x6 grid with two drives, a radiator, two energy guns and the internal modules, and a boxed model carrying every anchor. The package is read back through ShipModCatalog.ReadPackage, so the GLB's node ids and mesh roles are judged by the reader the game boots with. Refuses to overwrite a package. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
17c19521f5
commit
5b4665dfbb
3 changed files with 240 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
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