Ship S2: the game composes and loads mod ships
Boot opens the derived catalog when GameData/Mods holds packages (ShipModCatalog.ResolveCatalog), preloads each hull's GLB into an assembled inactive prototype (ShipModShips), and LoadEntity clones it when the hull names a Visual. ShipModPlan decides which anchor fills which ShipInstance slot; a ShipModTemplate prefab carries the shared effects. Continue refuses a run that names a design the catalog no longer holds (RunSave.RequireDesigns). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f470c1b775
commit
460bb85ed0
16 changed files with 702 additions and 13 deletions
|
|
@ -91,6 +91,28 @@ public static class RunSave
|
|||
return (game, zones);
|
||||
}
|
||||
|
||||
// Continue's gate: every design a saved run names (each zone's items and each minted lot) must be one this catalog
|
||||
// holds. A run made with a mod that is no longer installed is refused, naming the mod ships, before any scene is
|
||||
// built; the save is never edited to fit.
|
||||
public static void RequireDesigns(CultCache cache, SavedGame game)
|
||||
{
|
||||
var named = (game.Zones ?? Array.Empty<CultRecordRef<SavedZone>>())
|
||||
.SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List<EntityPack>())
|
||||
.SelectMany(EntitySerializer.Items).Select(item => item.Data.Key)
|
||||
.Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key))
|
||||
.Where(key => key.IsSet()).Distinct()
|
||||
.Where(key => cache.Get<ItemData>(key) == null)
|
||||
.Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray();
|
||||
if (named.Length == 0) return;
|
||||
const string modHull = "mod-hull:";
|
||||
var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray();
|
||||
var others = named.Where(key => !key.StartsWith(modHull, StringComparison.Ordinal)).ToArray();
|
||||
throw new InvalidOperationException("This run names designs the catalog no longer holds" +
|
||||
(mods.Length > 0 ? $"; missing mod ships: {string.Join(", ", mods)}" : "") +
|
||||
(others.Length > 0 ? $"; missing other designs: {string.Join(", ", others)}" : "") +
|
||||
". Reinstall them, or start a new game.");
|
||||
}
|
||||
|
||||
// The stored ledger, or a fresh empty one when the run has minted nothing yet.
|
||||
public static ProvenanceLedger Lots(CultCache cache) => cache.GetGlobal<ProvenanceLedger>() ?? new ProvenanceLedger();
|
||||
|
||||
|
|
|
|||
|
|
@ -88,8 +88,35 @@ public static class ShipModCatalog
|
|||
public static Package ReadPackage(string shipPath)
|
||||
{
|
||||
var path = Path.GetFullPath(shipPath);
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
var (hull, ship) = ShipAuthoringStore.Read(path);
|
||||
return Bind(hull, ship, Path.GetDirectoryName(path), path);
|
||||
}
|
||||
|
||||
// The package a catalog hull names: its two records come from the catalog, and only the GLB comes from the mod
|
||||
// directory (<modsRoot>/<id>/<ModelAsset>). Gameplay reads a mod ship through this, never through its ship.cc.
|
||||
public static Package PackageOf(CultCache catalog, HullData hull, string modsRoot)
|
||||
{
|
||||
var ship = catalog.Get(hull.Visual) ??
|
||||
throw new InvalidOperationException($"{hull.Name}: the catalog holds no visual record {hull.Visual.Key.Value}.");
|
||||
ShipAuthoringStore.Validate(hull, ship);
|
||||
var directory = Path.Combine(Path.GetFullPath(modsRoot), ship.Id);
|
||||
return Bind(hull, ship, directory, Path.Combine(directory, "ship.cc"));
|
||||
}
|
||||
|
||||
// The one game-side choice of catalog: the shipped file when the mods root holds no package, otherwise the derived
|
||||
// file, composed afresh from the shipped one and the packages. Recomposed every call, so the derived file is never
|
||||
// stale and never an authority.
|
||||
public static string ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot)
|
||||
{
|
||||
if (!Directory.Exists(modsRoot) ||
|
||||
!Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc"))))
|
||||
return shippedCatalog;
|
||||
Compose(shippedCatalog, derivedCatalog, modsRoot);
|
||||
return derivedCatalog;
|
||||
}
|
||||
|
||||
private static Package Bind(HullData hull, ShipAuthoring ship, string directory, string path)
|
||||
{
|
||||
if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{path}: ship ID must match its package directory name.");
|
||||
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
|
||||
|
|
|
|||
68
Assets/Scripts/ServerShared/ShipModPlan.cs
Normal file
68
Assets/Scripts/ServerShared/ShipModPlan.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
// Which anchor fills which slot of a ship's component arrays. Pure over (hull, visual) so the Unity assembler only
|
||||
// follows it: the hull's hardpoint type decides what a mount is, the anchor's role decides which node plays the part.
|
||||
public sealed class ShipModPlan
|
||||
{
|
||||
public string MapIcon;
|
||||
public string HullCollider;
|
||||
public string Shield;
|
||||
public string Tractor;
|
||||
// Every hull hardpoint's mount id, in hardpoint order: the anchor node each one is matched to by name.
|
||||
public string[] Equipment;
|
||||
// Mount ids whose anchor (role thruster-emitter / radiator-mesh) is the emitter / mesh node itself.
|
||||
public string[] Thrusters;
|
||||
public string[] Radiators;
|
||||
// A weapon mount's muzzle anchors, by Order then id.
|
||||
public (string Mount, string[] Muzzles)[] Weapons;
|
||||
|
||||
// Anchors with role "articulation" become ordinary mount nodes: ArticulationPoint's limits are not authorable until
|
||||
// pivots move onto HullData, so no ArticulationPoint is planned.
|
||||
public static ShipModPlan Build(HullData hull, ShipAuthoring visual)
|
||||
{
|
||||
ShipAuthoringStore.Validate(hull, visual);
|
||||
var hardpoints = hull.Hardpoints ?? new List<HardpointData>();
|
||||
var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal);
|
||||
var role = visual.Anchors.ToDictionary(anchor => anchor.Id, anchor => anchor.Role, StringComparer.Ordinal);
|
||||
string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id;
|
||||
|
||||
foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh"))
|
||||
{
|
||||
var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator;
|
||||
if (!byMount.TryGetValue(anchor.Id, out var mount) || mount.Type != wanted)
|
||||
throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount.");
|
||||
}
|
||||
string[] Mounts(HardpointType type, string wantedRole) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint =>
|
||||
role[hardpoint.Transform] == wantedRole ? hardpoint.Transform :
|
||||
throw new InvalidOperationException($"{visual.Id}: {type} hardpoint {hardpoint.Transform} needs a {wantedRole} anchor of that id.")).ToArray();
|
||||
|
||||
var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle")
|
||||
.OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray();
|
||||
foreach (var muzzle in muzzles)
|
||||
if (string.IsNullOrEmpty(muzzle.ParentId) || !IsWeapon(byMount[muzzle.ParentId].Type))
|
||||
throw new InvalidOperationException($"{visual.Id}: muzzle {muzzle.Id} must be parented to a weapon hardpoint's mount.");
|
||||
var weapons = hardpoints.Where(hardpoint => IsWeapon(hardpoint.Type)).Select(hardpoint => (
|
||||
hardpoint.Transform,
|
||||
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray();
|
||||
foreach (var (mount, ids) in weapons)
|
||||
if (ids.Length == 0)
|
||||
throw new InvalidOperationException($"{visual.Id}: weapon hardpoint {mount} needs at least one muzzle anchor.");
|
||||
|
||||
return new ShipModPlan
|
||||
{
|
||||
MapIcon = Only("map-icon"),
|
||||
HullCollider = Only("hull-collider"),
|
||||
Shield = Only("shield"),
|
||||
Tractor = Only("tractor"),
|
||||
Equipment = hardpoints.Select(hardpoint => hardpoint.Transform).ToArray(),
|
||||
Thrusters = Mounts(HardpointType.Thruster, "thruster-emitter"),
|
||||
Radiators = Mounts(HardpointType.Radiator, "radiator-mesh"),
|
||||
Weapons = weapons
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsWeapon(HardpointType type) =>
|
||||
type == HardpointType.Energy || type == HardpointType.Ballistic || type == HardpointType.Launcher;
|
||||
}
|
||||
Loading…
Reference in a new issue