Aetheria/tools/AetherDb/ShipStarterCommand.cs

213 lines
12 KiB
C#
Raw Normal View History

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();
}
}