Merge eureka/aetheria-release-scenarios-menu: every new run comes from a scenario, launched from the New Game submenu

Tutorial Galaxy and Main Galaxy are scenarios; six test arenas show in editor and
development builds. RunStart owns new-run staging; Zone.Admit stays the one admission.
Galaxy zone linking moves to a double-precision Delaunay (the vendored float hull
dropped zones; ~2% of galaxies threw). A fixed seed fixes every fit; a failed start
keeps the saved run and says why. Soul s1 and s2 held (cut-scenarios-menu.h1, h2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-04 01:35:22 +02:00
commit a24ffd77db
84 changed files with 1557 additions and 4857 deletions

View file

@ -915,7 +915,6 @@ MonoBehaviour:
m_EditorClassIdentifier:
Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2}
Dialog: {fileID: 4533226043449861657}
InGame: 0
PanelPrototype: {fileID: 7329274282942065594}
FadeTime: 0.5
FadeDistance: 1024

View file

@ -9636,11 +9636,6 @@ PrefabInstance:
propertyPath: m_SizeDelta.y
value: 90.91
objectReference: {fileID: 0}
- target: {fileID: 4533226043457584178, guid: 3d8d9cbf10b12214e98891c1f03c1e99,
type: 3}
propertyPath: InGame
value: 1
objectReference: {fileID: 0}
- target: {fileID: 4533226043457584178, guid: 3d8d9cbf10b12214e98891c1f03c1e99,
type: 3}
propertyPath: CloudRenderer

View file

@ -106,7 +106,8 @@ public class ActionGameManager : MonoBehaviour
}
public static Galaxy CurrentGalaxy;
public static bool IsTutorial;
// The scenario a new run starts from: MainMenu.Launch sets it, and StartGame's new-run branch consumes it once.
public static Scenario PendingScenario;
public GameSettings Settings;
//public string StarterShipTemplate = "Longinus";
@ -250,7 +251,7 @@ public class ActionGameManager : MonoBehaviour
{
if (CurrentGalaxy != null)
{
var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, IsTutorial,
var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity,
_actionBarSlots.Select(s => s.Save()).ToArray());
RunSave.Commit(CultCache, game, zones, ItemManager.Lots);
}
@ -269,7 +270,9 @@ public class ActionGameManager : MonoBehaviour
EntityInstance.EffectManagerParent = EffectManagerParent;
ConsoleController.MessageReceiver = this;
ItemManager = new ItemManager(CultCache, RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log);
// A new run mints into an empty ledger; the saved run's stays in the store until the new run has started.
ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache),
Settings.GameplaySettings, Debug.Log);
ZoneRenderer.ItemManager = ItemManager;
// If hiding minimap asteroids, turn them off to start with
@ -578,19 +581,7 @@ public class ActionGameManager : MonoBehaviour
{
var nearestFaction = CurrentGalaxy.Factions.MinBy(f => CurrentGalaxy.HomeZones[f].Distance[Zone.GalaxyZone]);
var loadoutGenerator = IsTutorial ? new LoadoutGenerator(
ref ItemManager.Random,
ItemManager,
CurrentGalaxy,
Zone.GalaxyZone,
nearestFaction,
.5f) : new LoadoutGenerator(
ref ItemManager.Random,
ItemManager,
CurrentGalaxy,
Zone.GalaxyZone,
nearestFaction,
.5f);
var loadoutGenerator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, nearestFaction, .5f);
var turret = EntitySerializer.Unpack(ItemManager, Zone, loadoutGenerator.GenerateTurretLoadout());
turret.Position.xz = _currentEntity.Position.xz +
@ -728,7 +719,7 @@ public class ActionGameManager : MonoBehaviour
Settings.ZoneSettings,
CurrentGalaxy,
galaxyZone,
IsTutorial
CurrentGalaxy.IsPrelude
);
galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy);
}
@ -806,21 +797,10 @@ public class ActionGameManager : MonoBehaviour
{
if (CurrentGalaxy != null)
{
var saved = CultCache.GetGlobal<SavedGame>();
if (saved == null)
{
SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance));
// RunStart owns what a new run starts with (docs/scenarios-cut.md, 2.2); this only enters and binds.
RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, null);
PopulateLevel(CurrentGalaxy.Entrance);
var failures = new List<string>();
var staged = RunStart.Stage(ItemManager, Zone, null, Settings.StartingHullName,
IsTutorial ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, failures)
?? throw new InvalidOperationException($"The new run could not stage: {string.Join("; ", failures)}");
BindToEntity(staged.Player);
}
if (PendingScenario != null) StartScenario();
else
{
var saved = CultCache.GetGlobal<SavedGame>();
foreach(var group in CurrentGalaxy.DiscoveredZones
.GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance]))
SectorMap.QueueZoneReveal(group);
@ -845,6 +825,41 @@ public class ActionGameManager : MonoBehaviour
}
}
// RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this enters and binds. The saved run is
// replaced only once the scenario has staged. A start that fails leaves the saved run as it was and says why on
// the main menu, where Continue still resumes the saved run.
private void StartScenario()
{
var scenario = PendingScenario;
PendingScenario = null;
var failures = new List<string>();
RunStart.Staged staged = null;
try
{
staged = RunSave.Replace(CultCache, () =>
{
RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario);
PopulateLevel(CurrentGalaxy.Entrance);
return RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures);
});
}
catch (Exception exception)
{
Debug.LogException(exception);
failures.Add(exception.Message);
}
if (staged == null)
{
CurrentGalaxy = null;
MainMenu.gameObject.SetActive(true);
MainMenu.Refuse($"{scenario.Name} could not start", string.Join("\n", failures));
return;
}
SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance));
BindToEntity(staged.Player);
}
private IEnumerator IntroCutscene(Ship ship)
{
ZoneRenderer.PerspectiveEntity = ship;

View file

@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack;
// Aetheria's stores: one home store per type, composed here and nowhere else.
public static class AetheriaStores
{
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(Scenario) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };

View file

@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using CultMath;
// The Delaunay triangulation of a galaxy's zone positions, as the edges Galaxy links zones along. Bowyer-Watson
// (Bowyer 1981, Watson 1981) in double precision: each point is inserted by removing every triangle whose
// circumcircle holds it and fanning the cavity's boundary to it. Every point lands in the triangulation, so for two or
// more distinct points the edge graph is connected.
public static class Delaunay
{
// Each edge once, as (lower index, higher index), in ascending order.
public static List<(int a, int b)> Edges(IReadOnlyList<float2> points)
{
var n = points.Count;
var x = new double[n + 3];
var y = new double[n + 3];
double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue;
for (var i = 0; i < n; i++)
{
x[i] = points[i].x;
y[i] = points[i].y;
minX = Math.Min(minX, x[i]);
minY = Math.Min(minY, y[i]);
maxX = Math.Max(maxX, x[i]);
maxY = Math.Max(maxY, y[i]);
}
// A super-triangle far outside the points, so its vertices bend no edge between them; its triangles are
// dropped at the end.
var size = Math.Max(Math.Max(maxX - minX, maxY - minY), 1e-6) * 1000;
double midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;
x[n] = midX - size; y[n] = midY - size;
x[n + 1] = midX + size; y[n + 1] = midY - size;
x[n + 2] = midX; y[n + 2] = midY + size;
var triangles = new List<Triangle> { new Triangle(n, n + 1, n + 2, x, y) };
var cavity = new Dictionary<(int, int), int>();
for (var p = 0; p < n; p++)
{
cavity.Clear();
for (var t = triangles.Count - 1; t >= 0; t--)
{
var triangle = triangles[t];
var dx = x[p] - triangle.CenterX;
var dy = y[p] - triangle.CenterY;
if (dx * dx + dy * dy >= triangle.RadiusSquared) continue;
Count(cavity, triangle.A, triangle.B);
Count(cavity, triangle.B, triangle.C);
Count(cavity, triangle.C, triangle.A);
triangles[t] = triangles[triangles.Count - 1];
triangles.RemoveAt(triangles.Count - 1);
}
// The cavity's boundary is every edge only one removed triangle had.
foreach (var edge in cavity)
if (edge.Value == 1)
triangles.Add(new Triangle(edge.Key.Item1, edge.Key.Item2, p, x, y));
}
var edges = new SortedSet<(int a, int b)>();
foreach (var triangle in triangles)
{
if (triangle.A >= n || triangle.B >= n || triangle.C >= n) continue;
edges.Add(Ordered(triangle.A, triangle.B));
edges.Add(Ordered(triangle.B, triangle.C));
edges.Add(Ordered(triangle.C, triangle.A));
}
return new List<(int a, int b)>(edges);
}
private static void Count(Dictionary<(int, int), int> cavity, int a, int b)
{
var key = Ordered(a, b);
cavity[key] = cavity.TryGetValue(key, out var seen) ? seen + 1 : 1;
}
private static (int a, int b) Ordered(int a, int b) => a < b ? (a, b) : (b, a);
private readonly struct Triangle
{
public readonly int A, B, C;
public readonly double CenterX, CenterY, RadiusSquared;
public Triangle(int a, int b, int c, double[] x, double[] y)
{
A = a;
B = b;
C = c;
double ax = x[a], ay = y[a], bx = x[b], by = y[b], cx = x[c], cy = y[c];
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
double a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy;
CenterX = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
CenterY = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
RadiusSquared = (ax - CenterX) * (ax - CenterX) + (ay - CenterY) * (ay - CenterY);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a05b77dab9114e19b222b79a8403fc21

View file

@ -7,7 +7,6 @@ using System.Threading;
using System.Threading.Tasks;
using DataStructures.ViliWonka.Heap;
using Ink.Runtime;
using MIConvexHull;
using JM.LinqFaster;
using UniRx;
using CultMath;
@ -29,6 +28,10 @@ public class Galaxy
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
// The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy
// restored from a save.
public uint Seed { get; }
private HashSet<CultRecordKey> _containedFactions;
private GalaxyZone[] _exitPath;
@ -105,7 +108,8 @@ public class Galaxy
Background = background;
Log = log;
var factions = cache.GetAll<Faction>();
var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed);
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
var random = new Random(Seed);
Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray();
foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral;
@ -139,6 +143,11 @@ public class Galaxy
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
}
// How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a
// prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next.
public int InfluenceOf(Faction faction) =>
IsPrelude ? (faction.InfluenceDistance + 1) / 2 : faction.InfluenceDistance;
public Galaxy(
TutorialGenerationSettings settings,
SectorBackgroundSettings background,
@ -155,7 +164,8 @@ public class Galaxy
Background = background;
Log = log;
var random = new Random(seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed);
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
var random = new Random(Seed);
var factions = new List<Faction>();
@ -177,11 +187,7 @@ public class Galaxy
factions.AddRange(neutralFactions);
Factions = factions.ToArray();
foreach (var faction in Factions)
{
FactionRelationships[faction] = FactionRelationship.Neutral;
faction.InfluenceDistance = (faction.InfluenceDistance + 1) / 2;
}
foreach (var faction in Factions) FactionRelationships[faction] = FactionRelationship.Neutral;
Zones = GenerateZones(settings.ZoneCount, ref random, progressCallback);
@ -190,13 +196,13 @@ public class Galaxy
CalculateDistanceMatrix(progressCallback);
HomeZones[protagonistFaction] = Zones
.MaxBy(z => ConnectedRegion(z, protagonistFaction.InfluenceDistance).Count);
.MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count);
HomeZones[antagonistFaction] = Zones
.MaxBy(z => ConnectedRegion(z, antagonistFaction.InfluenceDistance).Count * z.Distance[HomeZones[protagonistFaction]]);
.MaxBy(z => ConnectedRegion(z, InfluenceOf(antagonistFaction)).Count * z.Distance[HomeZones[protagonistFaction]]);
HomeZones[protagonistFaction] = Zones
.MaxBy(z => ConnectedRegion(z, protagonistFaction.InfluenceDistance).Count * sqrt(z.Distance[HomeZones[antagonistFaction]]));
.MaxBy(z => ConnectedRegion(z, InfluenceOf(protagonistFaction)).Count * sqrt(z.Distance[HomeZones[antagonistFaction]]));
// var antagonistRegion = ConnectedRegion(HomeZones[antagonistFaction], antagonistFaction.InfluenceDistance);
// var protagonistRegion = ConnectedRegion(HomeZones[protagonistFaction], protagonistFaction.InfluenceDistance);
@ -205,13 +211,13 @@ public class Galaxy
var bufferDistance = Zones.Min(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]));
var potentialBufferZones = Zones
.Where(z => abs(z.Distance[HomeZones[antagonistFaction]] - z.Distance[HomeZones[protagonistFaction]]) == bufferDistance);
HomeZones[bufferFaction] = potentialBufferZones.MaxBy(z => ConnectedRegion(z, bufferFaction.InfluenceDistance).Count);
HomeZones[bufferFaction] = potentialBufferZones.MaxBy(z => ConnectedRegion(z, InfluenceOf(bufferFaction)).Count);
// Place neutral headquarters away from existing factions while also maximizing territory
foreach (var faction in neutralFactions)
{
HomeZones[faction] = Zones.MaxBy(z =>
ConnectedRegion(z, faction.InfluenceDistance).Count *
ConnectedRegion(z, InfluenceOf(faction)).Count *
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
}
@ -221,7 +227,7 @@ public class Galaxy
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
if (potentialQuestZones.Any())
HomeZones[questFaction] = potentialQuestZones
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, questFaction.InfluenceDistance).Count);
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
else
HomeZones[questFaction] = Zones
.Where(z => z.Factions.Contains(antagonistFaction))
@ -287,9 +293,9 @@ public class Galaxy
// While occupying as much territory as possible
foreach (var mega in bossMegas)
{
HomeZones[mega] = ConnectedRegion(BossZones[mega], mega.InfluenceDistance)
HomeZones[mega] = ConnectedRegion(BossZones[mega], InfluenceOf(mega))
.MaxBy(z =>
ConnectedRegion(z, mega.InfluenceDistance).Count *
ConnectedRegion(z, InfluenceOf(mega)).Count *
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
}
@ -297,7 +303,7 @@ public class Galaxy
foreach (var mega in Factions.Where(m => !bossMegas.Contains(m)))
{
HomeZones[mega] = Zones.MaxBy(z =>
pow(ConnectedRegion(z, mega.InfluenceDistance).Count, HomeZones.Count) *
pow(ConnectedRegion(z, InfluenceOf(mega)).Count, HomeZones.Count) *
Exit.Distance[z] * Entrance.Distance[z] *
HomeZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])) *
BossZones.Values.Aggregate(1f, (i, os) => i * sqrt(os.Distance[z])));
@ -315,14 +321,14 @@ public class Galaxy
// Factions are present in all zones within their sphere of influence
zone.Factions = Factions
.Where(f => HomeZones.ContainsKey(f))
.Where(f => zone.Distance[HomeZones[f]] <= f.InfluenceDistance)
.Where(f => zone.Distance[HomeZones[f]] <= InfluenceOf(f))
.ToArray();
// Owner of a zone is the faction with the nearest headquarters
var nearestFaction = Factions
.Where(f => HomeZones.ContainsKey(f))
.MinBy(f => (float)zone.Distance[HomeZones[f]]);
if (zone.Distance[HomeZones[nearestFaction]] <= nearestFaction.InfluenceDistance)
if (zone.Distance[HomeZones[nearestFaction]] <= InfluenceOf(nearestFaction))
zone.Owner = nearestFaction;
}
}
@ -332,12 +338,13 @@ public class Galaxy
ref Random random,
Action<string> progressCallback = null)
{
var used = new HashSet<string>();
for (var i = 0; i < Factions.Length; i++)
{
progressCallback?.Invoke($"Feeding Markov Chains: {i + 1} / {Factions.Length}");
//if(progressCallback!=null) Thread.Sleep(250); // Inserting Delay to make it seem like it's doing more work lmao
var faction = Factions[i];
_nameGenerators[faction] = new MarkovNameGenerator(ref random, cache.Get(faction.GeonameFile).Names, nameGeneratorSettings);
_nameGenerators[faction] = new MarkovNameGenerator(ref random, cache.Get(faction.GeonameFile).Names, nameGeneratorSettings, used);
}
// Generate zone name using the owner's name generator, otherwise assign catalogue ID
@ -359,22 +366,10 @@ public class Galaxy
progressCallback?.Invoke("Triangulating Zone Positions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
// Create a delaunay triangulation to connect adjacent sectors
var triangulation = DelaunayTriangulation<Vertex2<GalaxyZone>, Cell2<GalaxyZone>>
.Create(Zones.Select(z => new Vertex2<GalaxyZone>(z.Position, z)).ToList(), 1e-7f);
var links = new HashSet<(GalaxyZone, GalaxyZone)>();
foreach (var cell in triangulation.Cells)
{
if (!links.Contains((cell.Vertices[0].StoredObject, cell.Vertices[1].StoredObject)) &&
!links.Contains((cell.Vertices[1].StoredObject, cell.Vertices[0].StoredObject)))
links.Add((cell.Vertices[0].StoredObject, cell.Vertices[1].StoredObject));
if (!links.Contains((cell.Vertices[1].StoredObject, cell.Vertices[2].StoredObject)) &&
!links.Contains((cell.Vertices[2].StoredObject, cell.Vertices[1].StoredObject)))
links.Add((cell.Vertices[1].StoredObject, cell.Vertices[2].StoredObject));
if (!links.Contains((cell.Vertices[0].StoredObject, cell.Vertices[2].StoredObject)) &&
!links.Contains((cell.Vertices[2].StoredObject, cell.Vertices[0].StoredObject)))
links.Add((cell.Vertices[0].StoredObject, cell.Vertices[2].StoredObject));
}
// The Delaunay triangulation connects adjacent sectors, every zone among them
var links = Delaunay.Edges(Zones.Select(z => z.Position).ToArray())
.Select(edge => (Zones[edge.a], Zones[edge.b]))
.ToList();
progressCallback?.Invoke("Eliminating Zone Links");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao

View file

@ -0,0 +1,86 @@
using System;
using System.IO;
using GameCult.Caching;
using MessagePack;
using static CultMath.math;
using Random = CultMath.Random;
// The galaxy half of a scenario's vocabulary (docs/scenarios-cut.md, R.4): the two kinds of galaxy the game generates,
// at the scenario's seed. The menu hands it the generation inputs; RunStart.Generate sets the seed and hands it to
// Scenario.Generate. The background's noise position derives from the same seed, so a fixed seed fixes the whole
// galaxy, and a clock seed makes every launch a new one.
public sealed class GalaxyStage
{
private readonly SectorGenerationSettings _sector;
private readonly SectorBackgroundSettings _sectorBackground;
private readonly TutorialGenerationSettings _prelude;
private readonly SectorBackgroundSettings _preludeBackground;
private readonly NameGeneratorSettings _names;
private readonly CultCache _cache;
private readonly PlayerSettings _playerSettings;
private readonly DirectoryInfo _narrativeDirectory;
private readonly Action<string> _log;
private readonly Action<string> _progress;
private readonly Func<uint> _clock;
public GalaxyStage(
SectorGenerationSettings sector,
SectorBackgroundSettings sectorBackground,
TutorialGenerationSettings prelude,
SectorBackgroundSettings preludeBackground,
NameGeneratorSettings names,
CultCache cache,
PlayerSettings playerSettings,
DirectoryInfo narrativeDirectory,
Action<string> log,
Action<string> progress = null,
Func<uint> clock = null)
{
_sector = sector;
_sectorBackground = sectorBackground;
_prelude = prelude;
_preludeBackground = preludeBackground;
_names = names;
_cache = cache;
_playerSettings = playerSettings;
_narrativeDirectory = narrativeDirectory;
_log = log;
_progress = progress;
_clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue));
}
// The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero.
public uint Seed { get; private set; }
internal void SeedFrom(Scenario scenario)
{
var clock = _clock();
Seed = scenario.Seed != 0 ? scenario.Seed : clock != 0 ? clock : 1;
}
// A main galaxy: the sector settings, factions drawn from the catalog, an exit to reach.
public Galaxy Main()
{
var background = Copy(_sectorBackground);
var random = new Random(Seed);
background.NoisePosition = random.NextFloat() * 1000;
return new Galaxy(_sector, background, _names, _cache, _log, _progress, Seed);
}
// A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud.
public Galaxy Prelude()
{
var background = Copy(_preludeBackground);
var random = new Random(Seed);
var iteration = 1;
do
{
background.NoisePosition = random.NextFloat() * 1000;
_progress?.Invoke($"Finding Galaxy Position: iteration {iteration++}");
} while (background.CloudDensity(float2(.5f)) < .5f);
return new Galaxy(_prelude, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
}
private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) =>
MessagePackSerializer.Deserialize<SectorBackgroundSettings>(MessagePackSerializer.Serialize(settings));
}

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 3969283304824fe4a8eca1eb522fd3a7
guid: f5cf458f47074786bf11e72b5d93c545
MonoImporter:
externalObjects: {}
serializedVersion: 2

View file

@ -14,14 +14,17 @@ public class MarkovNameGenerator
{
private Dictionary<string, List<char>> _chains = new Dictionary<string, List<char>>();
private List<string> _samples = new List<string>();
private static List<string> _used = new List<string>();
// The names already given, shared by every generator that must not repeat another's: a galaxy passes one set to
// all its factions' generators. Never process-wide, so one galaxy's names cannot change the next galaxy's.
private readonly HashSet<string> _used;
private int _order;
private int _minLength;
private int _maxLength;
private Random _random;
public MarkovNameGenerator(ref Random random, IEnumerable<string> sampleNames, NameGeneratorSettings settings)
public MarkovNameGenerator(ref Random random, IEnumerable<string> sampleNames, NameGeneratorSettings settings, HashSet<string> used)
{
_used = used;
var names = new HashSet<string>();
foreach (var line in sampleNames)
{
@ -41,6 +44,7 @@ public class MarkovNameGenerator
public MarkovNameGenerator(ref Random random, IEnumerable<string> sampleNames, int order, int minLength, int maxLength)
{
_used = new HashSet<string>();
_random = random;
_order = max(1, order);
@ -113,12 +117,6 @@ public class MarkovNameGenerator
}
}
//Reset the used names
public void Reset()
{
_used.Clear();
}
//Get a random letter from the chain
private char GetLetter(string token)
{

View file

@ -1,114 +0,0 @@
using System.Collections;
using System.Linq;
using MIConvexHull;
using CultMath;
using static CultMath.math;
/// <summary>
/// A vertex is a simple class that stores the postion of a point, node or vertex.
/// </summary>
public class Cell2 : TriangulationCell<Vertex2, Cell2>
{
float2? circumCenter, centroid;
public float2 Circumcenter {
get {
circumCenter = circumCenter ?? GetCircumcenter();
return circumCenter.Value;
}
}
public float2 Centroid {
get {
centroid = centroid ?? GetCentroid();
return centroid.Value;
}
}
public Cell2 ()
{
}
double LengthSquared (float[] v)
{
float norm = 0;
for (int i = 0; i < v.Length; i++) {
var t = v [i];
norm += t * t;
}
return norm;
}
float Determinant(float[,] m)
{
float fCofactor00 = m[1,1] * m[2,2] - m[1,2] * m[2,1];
float fCofactor10 = m[1,2] * m[2,0] - m[1,0] * m[2,2];
float fCofactor20 = m[1,0] * m[2,1] - m[1,1] * m[2,0];
float fDet = m[0,0] * fCofactor00 + m[0,1] * fCofactor10 + m[0,2] * fCofactor20;
return fDet;
}
float2 GetCircumcenter ()
{
// From MathWorld: http://mathworld.wolfram.com/Circumcircle.html
var points = Vertices;
float[,] m = new float[3, 3];
// x, y, 1
for (int i = 0; i < 3; i++) {
m[i, 0] = points[i].x;
m[i, 1] = points[i].y;
m[i, 2] = 1;
}
var a = Determinant(m);
// size, y, 1
for (int i = 0; i < 3; i++) {
m[i, 0] = lengthsq(points[i].StoredPosition);
}
var dx = -Determinant(m);
// size, x, 1
for (int i = 0; i < 3; i++) {
m[i, 1] = points[i].x;
}
var dy = Determinant(m);
// size, x, y
//for (int i = 0; i < 3; i++) {
// m[i, 2] = points[i].y;
//}
//var c = -Det(m);
var s = -1.0f / (2.0f * a);
//var r = System.Math.Abs(s) * System.Math.Sqrt(dx * dx + dy * dy - 4 * a * c);
return new float2(s * dx, s * dy);
}
float2 GetCentroid ()
{
return new float2(Vertices.Select(v => v.Position[0]).Average(), Vertices.Select(v => v.Position[1]).Average());
}
}

View file

@ -1,114 +0,0 @@
using System.Collections;
using System.Linq;
using MIConvexHull;
using CultMath;
using static CultMath.math;
/// <summary>
/// A vertex is a simple class that stores the postion of a point, node or vertex.
/// </summary>
public class Cell2<T> : TriangulationCell<Vertex2<T>, Cell2<T>>
{
float2? circumCenter, centroid;
public float2 Circumcenter {
get {
circumCenter = circumCenter ?? GetCircumcenter();
return circumCenter.Value;
}
}
public float2 Centroid {
get {
centroid = centroid ?? GetCentroid();
return centroid.Value;
}
}
public Cell2 ()
{
}
double LengthSquared (float[] v)
{
float norm = 0;
for (int i = 0; i < v.Length; i++) {
var t = v [i];
norm += t * t;
}
return norm;
}
float Determinant(float[,] m)
{
float fCofactor00 = m[1,1] * m[2,2] - m[1,2] * m[2,1];
float fCofactor10 = m[1,2] * m[2,0] - m[1,0] * m[2,2];
float fCofactor20 = m[1,0] * m[2,1] - m[1,1] * m[2,0];
float fDet = m[0,0] * fCofactor00 + m[0,1] * fCofactor10 + m[0,2] * fCofactor20;
return fDet;
}
float2 GetCircumcenter ()
{
// From MathWorld: http://mathworld.wolfram.com/Circumcircle.html
var points = Vertices;
float[,] m = new float[3, 3];
// x, y, 1
for (int i = 0; i < 3; i++) {
m[i, 0] = points[i].x;
m[i, 1] = points[i].y;
m[i, 2] = 1;
}
var a = Determinant(m);
// size, y, 1
for (int i = 0; i < 3; i++) {
m[i, 0] = lengthsq(points[i].StoredPosition);
}
var dx = -Determinant(m);
// size, x, 1
for (int i = 0; i < 3; i++) {
m[i, 1] = points[i].x;
}
var dy = Determinant(m);
// size, x, y
//for (int i = 0; i < 3; i++) {
// m[i, 2] = points[i].y;
//}
//var c = -Det(m);
var s = -1.0f / (2.0f * a);
//var r = System.Math.Abs(s) * System.Math.Sqrt(dx * dx + dy * dy - 4 * a * c);
return new float2(s * dx, s * dy);
}
float2 GetCentroid ()
{
return new float2(Vertices.Select(v => v.Position[0]).Average(), Vertices.Select(v => v.Position[1]).Average());
}
}

View file

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: ea854ab9e5204f92828e92ffb1244964
timeCreated: 1615420193

View file

@ -1,46 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
namespace MIConvexHull
{
internal static class Constants
{
/// <summary>
/// The default plane distance tolerance
/// </summary>
internal const float DefaultPlaneDistanceTolerance = 1e-10f;
/// <summary>
/// The starting delta dot product in simplex
/// </summary>
internal const float StartingDeltaDotProductInSimplex = 0.5f;
/// <summary>
/// The connector table size
/// </summary>
internal const int ConnectorTableSize = 2017;
}
}

View file

@ -1,135 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
namespace MIConvexHull
{
/// <summary>
/// Factory class for computing convex hulls.
/// </summary>
public static class ConvexHull
{
/// <summary>
/// Creates a convex hull of the input data.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TFace">The type of the t face.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>
/// ConvexHull&lt;TVertex, TFace&gt;.
/// </returns>
public static ConvexHull<TVertex, TFace> Create<TVertex, TFace>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
where TFace : ConvexFace<TVertex, TFace>, new()
{
return ConvexHull<TVertex, TFace>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Creates a convex hull of the input data.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>
/// ConvexHull&lt;TVertex, DefaultConvexFace&lt;TVertex&gt;&gt;.
/// </returns>
public static ConvexHull<TVertex, DefaultConvexFace<TVertex>> Create<TVertex>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
{
return ConvexHull<TVertex, DefaultConvexFace<TVertex>>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Creates a convex hull of the input data.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>
/// ConvexHull&lt;DefaultVertex, DefaultConvexFace&lt;DefaultVertex&gt;&gt;.
/// </returns>
public static ConvexHull<DefaultVertex, DefaultConvexFace<DefaultVertex>> Create(IList<float[]> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
{
var points = data.Select(p => new DefaultVertex { Position = p }).ToList();
return ConvexHull<DefaultVertex, DefaultConvexFace<DefaultVertex>>.Create(points, PlaneDistanceTolerance);
}
}
/// <summary>
/// Representation of a convex hull.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TFace">The type of the t face.</typeparam>
public class ConvexHull<TVertex, TFace>
where TVertex : IVertex
where TFace : ConvexFace<TVertex, TFace>, new()
{
/// <summary>
/// Can only be created using a factory method.
/// </summary>
internal ConvexHull()
{
}
/// <summary>
/// Points of the convex hull.
/// </summary>
/// <value>The points.</value>
public IEnumerable<TVertex> Points { get; internal set; }
/// <summary>
/// Faces of the convex hull.
/// </summary>
/// <value>The faces.</value>
public IEnumerable<TFace> Faces { get; internal set; }
/// <summary>
/// Creates the convex hull.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance.</param>
/// <returns>
/// ConvexHull&lt;TVertex, TFace&gt;.
/// </returns>
/// <exception cref="System.ArgumentNullException">The supplied data is null.</exception>
/// <exception cref="ArgumentNullException">data</exception>
public static ConvexHull<TVertex, TFace> Create(IList<TVertex> data, float PlaneDistanceTolerance)
{
if (data == null) throw new ArgumentNullException("The supplied data is null.");
return ConvexHullAlgorithm.GetConvexHull<TVertex, TFace>(data, PlaneDistanceTolerance);
}
}
}

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: a6e88469e3b81a541a0bc8a66e60d0c7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,304 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
namespace MIConvexHull
{
/// <summary>
/// A more lightweight alternative to List of T.
/// On clear, only resets the count and does not clear the references
/// =&gt; this works because of the ObjectManager.
/// Includes a stack functionality.
/// </summary>
/// <typeparam name="T"></typeparam>
internal class SimpleList<T>
{
/// <summary>
/// The capacity
/// </summary>
private int capacity;
/// <summary>
/// The count
/// </summary>
public int Count;
/// <summary>
/// The items
/// </summary>
private T[] items;
/// <summary>
/// Get the i-th element.
/// </summary>
/// <param name="i">The i.</param>
/// <returns>T.</returns>
public T this[int i]
{
get { return items[i]; }
set { items[i] = value; }
}
/// <summary>
/// Size matters.
/// </summary>
private void EnsureCapacity()
{
if (capacity == 0)
{
capacity = 32;
items = new T[32];
}
else
{
var newItems = new T[capacity*2];
Array.Copy(items, newItems, capacity);
capacity = 2*capacity;
items = newItems;
}
}
/// <summary>
/// Adds a vertex to the buffer.
/// </summary>
/// <param name="item">The item.</param>
public void Add(T item)
{
if (Count + 1 > capacity) EnsureCapacity();
items[Count++] = item;
}
/// <summary>
/// Pushes the value to the back of the list.
/// </summary>
/// <param name="item">The item.</param>
public void Push(T item)
{
if (Count + 1 > capacity) EnsureCapacity();
items[Count++] = item;
}
/// <summary>
/// Pops the last value from the list.
/// </summary>
/// <returns>T.</returns>
public T Pop()
{
return items[--Count];
}
/// <summary>
/// Sets the Count to 0, otherwise does nothing.
/// </summary>
public void Clear()
{
Count = 0;
}
}
/// <summary>
/// Class IndexBuffer.
/// A fancy name for a list of integers.
/// </summary>
internal class IndexBuffer : SimpleList<int>
{
}
/// <summary>
/// A priority based linked list.
/// </summary>
internal sealed class FaceList
{
/// <summary>
/// The last
/// </summary>
private ConvexFaceInternal last;
/// <summary>
/// Get the first element.
/// </summary>
/// <value>The first.</value>
public ConvexFaceInternal First { get; private set; }
/// <summary>
/// Adds the element to the beginning.
/// </summary>
/// <param name="face">The face.</param>
private void AddFirst(ConvexFaceInternal face)
{
face.InList = true;
First.Previous = face;
face.Next = First;
First = face;
}
/// <summary>
/// Adds a face to the list.
/// </summary>
/// <param name="face">The face.</param>
public void Add(ConvexFaceInternal face)
{
if (face.InList)
{
if (First.VerticesBeyond.Count < face.VerticesBeyond.Count)
{
Remove(face);
AddFirst(face);
}
return;
}
face.InList = true;
if (First != null && First.VerticesBeyond.Count < face.VerticesBeyond.Count)
{
First.Previous = face;
face.Next = First;
First = face;
}
else
{
if (last != null)
{
last.Next = face;
}
face.Previous = last;
last = face;
if (First == null)
{
First = face;
}
}
}
/// <summary>
/// Removes the element from the list.
/// </summary>
/// <param name="face">The face.</param>
public void Remove(ConvexFaceInternal face)
{
if (!face.InList) return;
face.InList = false;
if (face.Previous != null)
{
face.Previous.Next = face.Next;
}
else if ( /*first == face*/ face.Previous == null)
{
First = face.Next;
}
if (face.Next != null)
{
face.Next.Previous = face.Previous;
}
else if ( /*last == face*/ face.Next == null)
{
last = face.Previous;
}
face.Next = null;
face.Previous = null;
}
}
/// <summary>
/// Connector list.
/// </summary>
internal sealed class ConnectorList
{
/// <summary>
/// The last
/// </summary>
private FaceConnector last;
/// <summary>
/// Get the first element.
/// </summary>
/// <value>The first.</value>
public FaceConnector First { get; private set; }
/// <summary>
/// Adds the element to the beginning.
/// </summary>
/// <param name="connector">The connector.</param>
private void AddFirst(FaceConnector connector)
{
First.Previous = connector;
connector.Next = First;
First = connector;
}
/// <summary>
/// Adds a face to the list.
/// </summary>
/// <param name="element">The element.</param>
public void Add(FaceConnector element)
{
if (last != null)
{
last.Next = element;
}
element.Previous = last;
last = element;
if (First == null)
{
First = element;
}
}
/// <summary>
/// Removes the element from the list.
/// </summary>
/// <param name="connector">The connector.</param>
public void Remove(FaceConnector connector)
{
if (connector.Previous != null)
{
connector.Previous.Next = connector.Next;
}
else if ( /*first == face*/ connector.Previous == null)
{
First = connector.Next;
}
if (connector.Next != null)
{
connector.Next.Previous = connector.Previous;
}
else if ( /*last == face*/ connector.Next == null)
{
last = connector.Previous;
}
connector.Next = null;
connector.Previous = null;
}
}
}

View file

@ -1,69 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
namespace MIConvexHull
{
/// <summary>
/// A convex face representation containing adjacency information.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TFace">The type of the t face.</typeparam>
public abstract class ConvexFace<TVertex, TFace>
where TVertex : IVertex
where TFace : ConvexFace<TVertex, TFace>
{
/// <summary>
/// Adjacency. Array of length "dimension".
/// If F = Adjacency[i] then the vertices shared with F are Vertices[j] where j != i.
/// In the context of triangulation, can be null (indicates the cell is at boundary).
/// </summary>
/// <value>The adjacency.</value>
public TFace[] Adjacency { get; set; }
/// <summary>
/// The vertices stored in clockwise order for dimensions 2 - 4, in higher dimensions the order is arbitrary.
/// Unless I accidentally switch some index somewhere in which case the order is CCW. Either way, it is consistent.
/// 3D Normal = (V[1] - V[0]) x (V[2] - V[1]).
/// </summary>
/// <value>The vertices.</value>
public TVertex[] Vertices { get; set; }
/// <summary>
/// The normal vector of the face. Null if used in triangulation.
/// </summary>
/// <value>The normal.</value>
public float[] Normal { get; set; }
}
/// <summary>
/// A default convex face representation.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
public class DefaultConvexFace<TVertex> : ConvexFace<TVertex, DefaultConvexFace<TVertex>>
where TVertex : IVertex
{
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5db1a96860ec6d949930695ae9bedcc0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,721 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
namespace MIConvexHull
{
/*
* This part of the implementation handles initialization of the convex hull algorithm:
* - Constructor & Process initiation
* - Determine the dimension by looking at length of Position vector of 10 random data points from the input.
* - Identify bounding box points in each direction.
* - Pick (Dimension + 1) points from the extremes and construct the initial simplex.
*/
/// <summary>
/// Class ConvexHullAlgorithm.
/// </summary>
internal partial class ConvexHullAlgorithm
{
#region Starting functions and constructor
/// <summary>
/// The main function for the Convex Hull algorithm. It is static, but it creates
/// an instantiation of this class in order to allow for parallel execution.
/// Following this simple function, the constructor and the main function "FindConvexHull" is listed.
/// </summary>
/// <typeparam name="TVertex">The type of the vertices in the data.</typeparam>
/// <typeparam name="TFace">The desired type of the faces.</typeparam>
/// <param name="data">The data is the vertices as a collection of IVertices.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance.</param>
/// <returns>
/// MIConvexHull.ConvexHull&lt;TVertex, TFace&gt;.
/// </returns>
internal static ConvexHull<TVertex, TFace> GetConvexHull<TVertex, TFace>(IList<TVertex> data,
float PlaneDistanceTolerance)
where TFace : ConvexFace<TVertex, TFace>, new()
where TVertex : IVertex
{
var ch = new ConvexHullAlgorithm(data.Cast<IVertex>().ToArray(), false, PlaneDistanceTolerance);
ch.GetConvexHull();
if (ch.NumOfDimensions == 2) return ch.Return2DResultInOrder<TVertex, TFace>(data);
return new ConvexHull<TVertex, TFace>
{
Points = ch.GetHullVertices(data),
Faces = ch.GetConvexFaces<TVertex, TFace>()
};
}
/// <summary>
/// Initializes a new instance of the <see cref="ConvexHullAlgorithm" /> class.
/// </summary>
/// <param name="vertices">The vertices.</param>
/// <param name="lift">if set to <c>true</c> [lift].</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance.</param>
/// <exception cref="System.InvalidOperationException">Dimension of the input must be 2 or greater.</exception>
/// <exception cref="System.ArgumentException">There are too few vertices (m) for the n-dimensional space. (m must be greater +
/// than the n, but m is + NumberOfVertices + and n is + NumOfDimensions</exception>
/// <exception cref="InvalidOperationException">PointTranslationGenerator cannot be null if PointTranslationType is enabled.
/// or
/// Dimension of the input must be 2 or greater.</exception>
/// <exception cref="ArgumentException">There are too few vertices (m) for the n-dimensional space. (m must be greater " +
/// "than the n, but m is " + NumberOfVertices + " and n is " + Dimension</exception>
private ConvexHullAlgorithm(IVertex[] vertices, bool lift, float PlaneDistanceTolerance)
{
IsLifted = lift;
Vertices = vertices;
NumberOfVertices = vertices.Length;
NumOfDimensions = DetermineDimension();
if (IsLifted) NumOfDimensions++;
if (NumOfDimensions < 2) throw new InvalidOperationException("Dimension of the input must be 2 or greater.");
if (NumberOfVertices <= NumOfDimensions)
throw new ArgumentException(
"There are too few vertices (m) for the n-dimensional space. (m must be greater " +
"than the n, but m is " + NumberOfVertices + " and n is " + NumOfDimensions);
this.PlaneDistanceTolerance = PlaneDistanceTolerance;
UnprocessedFaces = new FaceList();
ConvexFaces = new IndexBuffer();
FacePool = new ConvexFaceInternal[(NumOfDimensions + 1) * 10]; // must be initialized before object manager
AffectedFaceFlags = new bool[(NumOfDimensions + 1) * 10];
ObjectManager = new ObjectManager(this);
Center = new float[NumOfDimensions];
TraverseStack = new IndexBuffer();
UpdateBuffer = new int[NumOfDimensions];
UpdateIndices = new int[NumOfDimensions];
EmptyBuffer = new IndexBuffer();
AffectedFaceBuffer = new IndexBuffer();
ConeFaceBuffer = new SimpleList<DeferredFace>();
SingularVertices = new HashSet<int>();
BeyondBuffer = new IndexBuffer();
ConnectorTable = new ConnectorList[Constants.ConnectorTableSize];
for (var i = 0; i < Constants.ConnectorTableSize; i++) ConnectorTable[i] = new ConnectorList();
VertexVisited = new bool[NumberOfVertices];
Positions = new float[NumberOfVertices * NumOfDimensions];
boundingBoxPoints = new List<int>[NumOfDimensions];
minima = new float[NumOfDimensions];
maxima = new float[NumOfDimensions];
mathHelper = new MathHelper(NumOfDimensions, Positions);
}
/// <summary>
/// Check the dimensionality of the input data.
/// </summary>
/// <returns>System.Int32.</returns>
/// <exception cref="ArgumentException">Invalid input data (non-uniform dimension).</exception>
private int DetermineDimension()
{
var r = new Random();
var dimensions = new List<int>();
for (var i = 0; i < 10; i++)
dimensions.Add(Vertices[r.Next(NumberOfVertices)].Position.Length);
var dimension = dimensions.Min();
if (dimension != dimensions.Max())
throw new ArgumentException("Invalid input data (non-uniform dimension).");
return dimension;
}
/// <summary>
/// Gets/calculates the convex hull. This is
/// </summary>
private void GetConvexHull()
{
// accessing a 1D array is quicker than a jagged array, so the first step is to make this array
SerializeVerticesToPositions();
// next the bounding box extremes are found. This is used to shift, scale and find the starting simplex.
FindBoundingBoxPoints();
// the positions are shifted to avoid divide by zero problems
// and if Delaunay or Voronoi, then the parabola terms are scaled back to match the size of the other coords
ShiftAndScalePositions();
// Find the (dimension+1) initial points and create the simplexes.
CreateInitialSimplex();
// Now, the main loop. These initial faces of a simplex are replaced and expanded
// outwards to make the convex hull and faces.
while (UnprocessedFaces.First != null)
{
var currentFace = UnprocessedFaces.First;
CurrentVertex = currentFace.FurthestVertex;
UpdateCenter();
// The affected faces get tagged
TagAffectedFaces(currentFace);
// Create the cone from the currentVertex and the affected faces horizon.
if (!SingularVertices.Contains(CurrentVertex) && CreateCone()) CommitCone();
else HandleSingular();
// Need to reset the tags
var count = AffectedFaceBuffer.Count;
for (var i = 0; i < count; i++) AffectedFaceFlags[AffectedFaceBuffer[i]] = false;
}
}
#endregion
/// <summary>
/// Serializes the vertices into the 1D array, Positions. The 1D array has much quicker access in C#.
/// </summary>
private void SerializeVerticesToPositions()
{
var index = 0;
if (IsLifted) // "Lifted" means that the last dimension is the sum of the squares of the others.
{
foreach (var v in Vertices)
{
var parabolaTerm = 0.0f; // the lifted term is a sum of squares.
var origNumDim = NumOfDimensions - 1;
for (var i = 0; i < origNumDim; i++)
{
var coordinate = v.Position[i];
Positions[index++] = coordinate;
parabolaTerm += coordinate * coordinate;
}
Positions[index++] = parabolaTerm;
}
}
else
foreach (var v in Vertices)
{
for (var i = 0; i < NumOfDimensions; i++)
Positions[index++] = v.Position[i];
}
}
/// <summary>
/// Finds the bounding box points.
/// </summary>
private void FindBoundingBoxPoints()
{
indexOfDimensionWithLeastExtremes = -1;
var minNumExtremes = int.MaxValue;
for (var i = 0; i < NumOfDimensions; i++)
{
var minIndices = new List<int>();
var maxIndices = new List<int>();
float min = float.PositiveInfinity, max = float.NegativeInfinity;
for (var j = 0; j < NumberOfVertices; j++)
{
var v = GetCoordinate(j, i);
var difference = min - v;
if (difference >= PlaneDistanceTolerance)
{
// you found a better solution than before, clear out the list and store new value
min = v;
minIndices.Clear();
minIndices.Add(j);
}
else if (difference > 0)
{
// you found a solution slightly better than before, clear out those that are no longer on the list and store new value
min = v;
minIndices.RemoveAll(index => min - GetCoordinate(index, i) > PlaneDistanceTolerance);
minIndices.Add(j);
}
else if (difference > -PlaneDistanceTolerance)
{
//same or almost as good as current limit, so store it
minIndices.Add(j);
}
difference = v - max;
if (difference >= PlaneDistanceTolerance)
{
// you found a better solution than before, clear out the list and store new value
max = v;
maxIndices.Clear();
maxIndices.Add(j);
}
else if (difference > 0)
{
// you found a solution slightly better than before, clear out those that are no longer on the list and store new value
max = v;
maxIndices.RemoveAll(index => min - GetCoordinate(index, i) > PlaneDistanceTolerance);
maxIndices.Add(j);
}
else if (difference > -PlaneDistanceTolerance)
{
//same or almost as good as current limit, so store it
maxIndices.Add(j);
}
}
minima[i] = min;
maxima[i] = max;
minIndices.AddRange(maxIndices);
if (minIndices.Count < minNumExtremes)
{
minNumExtremes = minIndices.Count;
indexOfDimensionWithLeastExtremes = i;
}
boundingBoxPoints[i] = minIndices;
}
}
/// <summary>
/// Shifts and scales the Positions to avoid future errors. This does not alter the original data.
/// </summary>
private void ShiftAndScalePositions()
{
var positionsLength = Positions.Length;
if (IsLifted)
{
var origNumDim = NumOfDimensions - 1;
var parabolaScale = 2 / (minima.Sum(x => Math.Abs(x)) + maxima.Sum(x => Math.Abs(x))
- Math.Abs(maxima[origNumDim]) - Math.Abs(minima[origNumDim]));
// the parabola scale is 1 / average of the sum of the other dimensions.
// multiplying this by the parabola will scale it back to be on near similar size to the
// other dimensions. Without this, the term is much larger than the others, which causes
// problems for roundoff error and finding the normal of faces.
minima[origNumDim] *= parabolaScale; // change the extreme values as well
maxima[origNumDim] *= parabolaScale;
// it is done here because
for (int i = origNumDim; i < positionsLength; i += NumOfDimensions)
Positions[i] *= parabolaScale;
}
var shiftAmount = new float[NumOfDimensions];
for (int i = 0; i < NumOfDimensions; i++)
// now the entire model is shifted to all positive numbers...plus some more.
// why?
// 1) to avoid dealing with a point at the origin {0,0,...,0} which causes problems
// for future normal finding
// 2) note that weird shift that is used (max - min - min). This is to avoid scaling
// issues. this shift means that the minima in a dimension will always be a positive
// number (no points at zero), and the minima [in a given dimension] will always be
// half of the maxima. 'Half' is much preferred to 'thousands of times'
// Think of the first term as the range (max - min), then the second term avoids cases
// where there are both positive and negative numbers.
if (maxima[i] == minima[i]) shiftAmount[i] = 0.0f;
else shiftAmount[i] = (maxima[i] - minima[i]) - minima[i];
for (int i = 0; i < positionsLength; i++)
Positions[i] += shiftAmount[i % NumOfDimensions];
}
/// <summary>
/// Find the (dimension+1) initial points and create the simplexes.
/// Creates the initial simplex of n+1 vertices by using points from the bounding box.
/// Special care is taken to ensure that the vertices chosen do not result in a degenerate shape
/// where vertices are collinear (co-planar, etc). This would technically be resolved when additional
/// vertices are checked in the main loop, but: 1) a degenerate simplex would not eliminate any other
/// vertices (thus no savings there), 2) the creation of the face normal is prone to error.
/// </summary>
private void CreateInitialSimplex()
{
var initialPoints = FindInitialPoints();
#region Create the first faces from (dimension + 1) vertices.
var faces = new int[NumOfDimensions + 1];
for (var i = 0; i < NumOfDimensions + 1; i++)
{
var vertices = new int[NumOfDimensions];
for (int j = 0, k = 0; j <= NumOfDimensions; j++)
{
if (i != j) vertices[k++] = initialPoints[j];
}
var newFace = FacePool[ObjectManager.GetFace()];
newFace.Vertices = vertices;
Array.Sort(vertices);
mathHelper.CalculateFacePlane(newFace, Center);
faces[i] = newFace.Index;
}
// update the adjacency (check all pairs of faces)
for (var i = 0; i < NumOfDimensions; i++)
for (var j = i + 1; j < NumOfDimensions + 1; j++) UpdateAdjacency(FacePool[faces[i]], FacePool[faces[j]]);
#endregion
#region Init the vertex beyond buffers.
foreach (var faceIndex in faces)
{
var face = FacePool[faceIndex];
FindBeyondVertices(face);
if (face.VerticesBeyond.Count == 0) ConvexFaces.Add(face.Index); // The face is on the hull
else UnprocessedFaces.Add(face);
}
#endregion
// Set all vertices to false (unvisited).
foreach (var vertex in initialPoints) VertexVisited[vertex] = false;
}
/// <summary>
/// Finds (dimension + 1) initial points.
/// </summary>
/// <returns>List&lt;System.Int32&gt;.</returns>
/// <exception cref="System.ArgumentException">The input data is degenerate. It appears to exist in " + NumOfDimensions +
/// " dimensions, but it is a " + (NumOfDimensions - 1) + " dimensional set (i.e. the point of collinear,"
/// + " coplanar, or co-hyperplanar.)</exception>
private List<int> FindInitialPoints()
{
var bigNumber = maxima.Sum() * NumOfDimensions * NumberOfVertices;
// the first two points are taken from the dimension that had the fewest extremes
// well, in most cases there will only be 2 in all dimensions: one min and one max
// but a lot of engineering part shapes are nice and square and can have hundreds of
// parallel vertices at the extremes
var vertex1 = boundingBoxPoints[indexOfDimensionWithLeastExtremes].First(); // these are min and max vertices along
var vertex2 = boundingBoxPoints[indexOfDimensionWithLeastExtremes].Last(); // the dimension that had the fewest points
boundingBoxPoints[indexOfDimensionWithLeastExtremes].RemoveAt(0);
boundingBoxPoints[indexOfDimensionWithLeastExtremes].RemoveAt(boundingBoxPoints[indexOfDimensionWithLeastExtremes].Count - 1);
var initialPoints = new List<int> { vertex1, vertex2 };
VertexVisited[vertex1] = VertexVisited[vertex2] = true;
CurrentVertex = vertex1; UpdateCenter();
CurrentVertex = vertex2; UpdateCenter();
var edgeVectors = new float[NumOfDimensions][];
edgeVectors[0] = mathHelper.VectorBetweenVertices(vertex2, vertex1);
// now the remaining vertices are just combined in one big list
var extremes = boundingBoxPoints.SelectMany(x => x).ToList();
// otherwise find the remaining points by maximizing the initial simplex volume
var index = 1;
while (index < NumOfDimensions && extremes.Any())
{
var bestVertex = -1;
var bestEdgeVector = new float[] { };
var maxVolume = Constants.DefaultPlaneDistanceTolerance;
for (var i = extremes.Count - 1; i >= 0; i--)
{
// count backwards in order to remove potential duplicates
var vIndex = extremes[i];
if (initialPoints.Contains(vIndex)) extremes.RemoveAt(i);
else
{
edgeVectors[index] = mathHelper.VectorBetweenVertices(vIndex, vertex1);
var volume = mathHelper.GetSimplexVolume(edgeVectors, index, bigNumber);
if (maxVolume < volume)
{
maxVolume = volume;
bestVertex = vIndex;
bestEdgeVector = edgeVectors[index];
}
}
}
extremes.Remove(bestVertex);
if (bestVertex == -1) break;
initialPoints.Add(bestVertex);
edgeVectors[index++] = bestEdgeVector;
CurrentVertex = bestVertex; UpdateCenter();
}
// hmm, there are not enough points on the bounding box to make a simplex. It is rare but entirely possibly.
// As an extreme, the bounding box can be made in n dimensions from only 2 unique points. When we can't find
// enough unique points, we start again with ALL the vertices. The following is a near replica of the code
// above, but instead of extremes, we consider "allVertices".
if (initialPoints.Count <= NumOfDimensions && !IsLifted)
{
var allVertices = Enumerable.Range(0, NumberOfVertices).ToList();
while (index < NumOfDimensions && allVertices.Any())
{
var bestVertex = -1;
var bestEdgeVector = new float[] { };
var maxVolume = 0.0;
for (var i = allVertices.Count - 1; i >= 0; i--)
{
// count backwards in order to remove potential duplicates
var vIndex = allVertices[i];
if (initialPoints.Contains(vIndex)) allVertices.RemoveAt(i);
else
{
edgeVectors[index] = mathHelper.VectorBetweenVertices(vIndex, vertex1);
var volume = mathHelper.GetSimplexVolume(edgeVectors, index, bigNumber);
if (maxVolume < volume)
{
maxVolume = volume;
bestVertex = vIndex;
bestEdgeVector = edgeVectors[index];
}
}
}
allVertices.Remove(bestVertex);
if (bestVertex == -1) break;
initialPoints.Add(bestVertex);
edgeVectors[index++] = bestEdgeVector;
CurrentVertex = bestVertex; UpdateCenter();
}
}
if (initialPoints.Count <= NumOfDimensions && IsLifted)
{
var allVertices = Enumerable.Range(0, NumberOfVertices).ToList();
while (index < NumOfDimensions && allVertices.Any())
{
var bestVertex = -1;
var bestEdgeVector = new float[] { };
var maxVolume = 0.0;
for (var i = allVertices.Count - 1; i >= 0; i--)
{
// count backwards in order to remove potential duplicates
var vIndex = allVertices[i];
if (initialPoints.Contains(vIndex)) allVertices.RemoveAt(i);
else
{
mathHelper.RandomOffsetToLift(vIndex, maxima.Last()-minima.Last());
edgeVectors[index] = mathHelper.VectorBetweenVertices(vIndex, vertex1);
var volume = mathHelper.GetSimplexVolume(edgeVectors, index, bigNumber);
if (maxVolume < volume)
{
maxVolume = volume;
bestVertex = vIndex;
bestEdgeVector = edgeVectors[index];
}
}
}
allVertices.Remove(bestVertex);
if (bestVertex == -1) break;
initialPoints.Add(bestVertex);
edgeVectors[index++] = bestEdgeVector;
CurrentVertex = bestVertex; UpdateCenter();
}
}
if (initialPoints.Count <= NumOfDimensions && IsLifted)
throw new ArgumentException("The input data is degenerate. It appears to exist in " + NumOfDimensions +
" dimensions, but it is a " + (NumOfDimensions - 1) + " dimensional set (i.e. the point of collinear,"
+ " coplanar, or co-hyperplanar.)");
return initialPoints;
}
/// <summary>
/// Check if 2 faces are adjacent and if so, update their AdjacentFaces array.
/// </summary>
/// <param name="l">The l.</param>
/// <param name="r">The r.</param>
private void UpdateAdjacency(ConvexFaceInternal l, ConvexFaceInternal r)
{
var lv = l.Vertices;
var rv = r.Vertices;
int i;
// reset marks on the 1st face
for (i = 0; i < lv.Length; i++) VertexVisited[lv[i]] = false;
// mark all vertices on the 2nd face
for (i = 0; i < rv.Length; i++) VertexVisited[rv[i]] = true;
// find the 1st false index
for (i = 0; i < lv.Length; i++) if (!VertexVisited[lv[i]]) break;
// no vertex was marked
if (i == NumOfDimensions) return;
// check if only 1 vertex wasn't marked
for (var j = i + 1; j < lv.Length; j++) if (!VertexVisited[lv[j]]) return;
// if we are here, the two faces share an edge
l.AdjacentFaces[i] = r.Index;
// update the adj. face on the other face - find the vertex that remains marked
for (i = 0; i < lv.Length; i++) VertexVisited[lv[i]] = false;
for (i = 0; i < rv.Length; i++)
{
if (VertexVisited[rv[i]]) break;
}
r.AdjacentFaces[i] = l.Index;
}
/// <summary>
/// Used in the "initialization" code.
/// </summary>
/// <param name="face">The face.</param>
private void FindBeyondVertices(ConvexFaceInternal face)
{
var beyondVertices = face.VerticesBeyond;
MaxDistance = float.NegativeInfinity;
FurthestVertex = 0;
for (var i = 0; i < NumberOfVertices; i++)
{
if (VertexVisited[i]) continue;
IsBeyond(face, beyondVertices, i);
}
face.FurthestVertex = FurthestVertex;
}
#region Fields
/// <summary>
/// Corresponds to the dimension of the data.
/// When the "lifted" hull is computed, Dimension is automatically incremented by one.
/// </summary>
internal readonly int NumOfDimensions;
/// <summary>
/// Are we on a paraboloid?
/// </summary>
private readonly bool IsLifted;
/// <summary>
/// Explained in ConvexHullComputationConfig.
/// </summary>
private readonly float PlaneDistanceTolerance;
/*
* Representation of the input vertices.
*
* - In the algorithm, a vertex is represented by its index in the Vertices array.
* This makes the algorithm a lot faster (up to 30%) than using object reference everywhere.
* - Positions are stored as a single array of values. Coordinates for vertex with index i
* are stored at indices <i * Dimension, (i + 1) * Dimension)
* - VertexMarks are used by the algorithm to help identify a set of vertices that is "above" (or "beyond")
* a specific face.
*/
/// <summary>
/// The vertices
/// </summary>
private readonly IVertex[] Vertices;
/// <summary>
/// The positions
/// </summary>
private float[] Positions;
/// <summary>
/// The vertex marks
/// </summary>
private readonly bool[] VertexVisited;
private readonly int NumberOfVertices;
/*
* The triangulation faces are represented in a single pool for objects that are being reused.
* This allows for represent the faces as integers and significantly speeds up many computations.
* - AffectedFaceFlags are used to mark affected faces/
*/
/// <summary>
/// The face pool
/// </summary>
internal ConvexFaceInternal[] FacePool;
/// <summary>
/// The affected face flags
/// </summary>
internal bool[] AffectedFaceFlags;
/// <summary>
/// Used to track the size of the current hull in the Update/RollbackCenter functions.
/// </summary>
private int ConvexHullSize;
/// <summary>
/// A list of faces that that are not a part of the final convex hull and still need to be processed.
/// </summary>
private readonly FaceList UnprocessedFaces;
/// <summary>
/// A list of faces that form the convex hull.
/// </summary>
private readonly IndexBuffer ConvexFaces;
/// <summary>
/// The vertex that is currently being processed.
/// </summary>
private int CurrentVertex;
/// <summary>
/// A helper variable to determine the furthest vertex for a particular convex face.
/// </summary>
private double MaxDistance;
/// <summary>
/// A helper variable to help determine the index of the vertex that is furthest from the face that is currently being
/// processed.
/// </summary>
private int FurthestVertex;
/// <summary>
/// The centroid of the currently computed hull.
/// </summary>
private readonly float[] Center;
/*
* Helper arrays to store faces for adjacency update.
* This is just to prevent unnecessary allocations.
*/
/// <summary>
/// The update buffer
/// </summary>
private readonly int[] UpdateBuffer;
/// <summary>
/// The update indices
/// </summary>
private readonly int[] UpdateIndices;
/// <summary>
/// Used to determine which faces need to be updated at each step of the algorithm.
/// </summary>
private readonly IndexBuffer TraverseStack;
/// <summary>
/// Used for VerticesBeyond for faces that are on the convex hull.
/// </summary>
private readonly IndexBuffer EmptyBuffer;
/// <summary>
/// Used to determine which vertices are "above" (or "beyond") a face
/// </summary>
private IndexBuffer BeyondBuffer;
/// <summary>
/// Stores faces that are visible from the current vertex.
/// </summary>
private readonly IndexBuffer AffectedFaceBuffer;
/// <summary>
/// Stores faces that form a "cone" created by adding new vertex.
/// </summary>
private readonly SimpleList<DeferredFace> ConeFaceBuffer;
/// <summary>
/// Stores a list of "singular" (or "generate", "planar", etc.) vertices that cannot be part of the hull.
/// </summary>
private readonly HashSet<int> SingularVertices;
/// <summary>
/// The connector table helps to determine the adjacency of convex faces.
/// Hashing is used instead of pairwise comparison. This significantly speeds up the computations,
/// especially for higher dimensions.
/// </summary>
private readonly ConnectorList[] ConnectorTable;
/// <summary>
/// Manages the memory allocations and storage of unused objects.
/// Saves the garbage collector a lot of work.
/// </summary>
private readonly ObjectManager ObjectManager;
/// <summary>
/// Helper class for handling math related stuff.
/// </summary>
private readonly MathHelper mathHelper;
private readonly List<int>[] boundingBoxPoints;
private int indexOfDimensionWithLeastExtremes;
private readonly float[] minima;
private readonly float[] maxima;
#endregion
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 48bd00cb6ec8d244f90e1c1608f39074
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,635 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System.Collections.Generic;
namespace MIConvexHull
{
/*
* Main part of the algorithm
* Basic idea:
* - Create the initial hull (done in Initialize.cs)
*
* For each face there are "vertices beyond" which are "visible" from it.
* If there are no such vertices, the face is on the hull.
*
* - While there is at least one face with at least one "vertex beyond":
* * Pick the furthest beyond vertex
* * For this vertex:
* > find all faces that are visible from it (TagAffectedFaces)
* > remove them and replace them with a "cone" created by the vertex and the boundary
* of the affected faces, and for each new face, compute "beyond vertices"
* (CreateCone + CommitCone)
*
* + Implement it in way that is fast, but hard to understand and maintain.
*/
/// <summary>
/// Class ConvexHullAlgorithm.
/// </summary>
internal partial class ConvexHullAlgorithm
{
/// <summary>
/// Tags all faces seen from the current vertex with 1.
/// </summary>
/// <param name="currentFace">The current face.</param>
private void TagAffectedFaces(ConvexFaceInternal currentFace)
{
AffectedFaceBuffer.Clear();
AffectedFaceBuffer.Add(currentFace.Index);
TraverseAffectedFaces(currentFace.Index);
}
/// <summary>
/// Recursively traverse all the relevant faces.
/// </summary>
/// <param name="currentFace">The current face.</param>
private void TraverseAffectedFaces(int currentFace)
{
TraverseStack.Clear();
TraverseStack.Push(currentFace);
AffectedFaceFlags[currentFace] = true;
while (TraverseStack.Count > 0)
{
var top = FacePool[TraverseStack.Pop()];
for (var i = 0; i < NumOfDimensions; i++)
{
var adjFace = top.AdjacentFaces[i];
if (!AffectedFaceFlags[adjFace] &&
mathHelper.GetVertexDistance(CurrentVertex, FacePool[adjFace]) >= PlaneDistanceTolerance)
{
AffectedFaceBuffer.Add(adjFace);
AffectedFaceFlags[adjFace] = true;
TraverseStack.Push(adjFace);
}
}
}
}
/// <summary>
/// Creates a new deferred face.
/// </summary>
/// <param name="face">The face.</param>
/// <param name="faceIndex">Index of the face.</param>
/// <param name="pivot">The pivot.</param>
/// <param name="pivotIndex">Index of the pivot.</param>
/// <param name="oldFace">The old face.</param>
/// <returns>DeferredFace.</returns>
private DeferredFace MakeDeferredFace(ConvexFaceInternal face, int faceIndex, ConvexFaceInternal pivot,
int pivotIndex, ConvexFaceInternal oldFace)
{
var ret = ObjectManager.GetDeferredFace();
ret.Face = face;
ret.FaceIndex = faceIndex;
ret.Pivot = pivot;
ret.PivotIndex = pivotIndex;
ret.OldFace = oldFace;
return ret;
}
/// <summary>
/// Connect faces using a connector.
/// </summary>
/// <param name="connector">The connector.</param>
private void ConnectFace(FaceConnector connector)
{
var index = connector.HashCode % Constants.ConnectorTableSize;
var list = ConnectorTable[index];
for (var current = list.First; current != null; current = current.Next)
{
if (FaceConnector.AreConnectable(connector, current, NumOfDimensions))
{
list.Remove(current);
FaceConnector.Connect(current, connector);
current.Face = null;
connector.Face = null;
ObjectManager.DepositConnector(current);
ObjectManager.DepositConnector(connector);
return;
}
}
list.Add(connector);
}
/// <summary>
/// Removes the faces "covered" by the current vertex and adds the newly created ones.
/// </summary>
/// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
private bool CreateCone()
{
var currentVertexIndex = CurrentVertex;
ConeFaceBuffer.Clear();
for (var fIndex = 0; fIndex < AffectedFaceBuffer.Count; fIndex++)
{
var oldFaceIndex = AffectedFaceBuffer[fIndex];
var oldFace = FacePool[oldFaceIndex];
// Find the faces that need to be updated
var updateCount = 0;
for (var i = 0; i < NumOfDimensions; i++)
{
var af = oldFace.AdjacentFaces[i];
if (!AffectedFaceFlags[af]) // Tag == false when oldFaces does not contain af
{
UpdateBuffer[updateCount] = af;
UpdateIndices[updateCount] = i;
++updateCount;
}
}
for (var i = 0; i < updateCount; i++)
{
var adjacentFace = FacePool[UpdateBuffer[i]];
var oldFaceAdjacentIndex = 0;
var adjFaceAdjacency = adjacentFace.AdjacentFaces;
for (var j = 0; j < adjFaceAdjacency.Length; j++)
{
if (oldFaceIndex == adjFaceAdjacency[j])
{
oldFaceAdjacentIndex = j;
break;
}
}
var forbidden = UpdateIndices[i]; // Index of the face that corresponds to this adjacent face
int oldVertexIndex;
int[] vertices;
var newFaceIndex = ObjectManager.GetFace();
var newFace = FacePool[newFaceIndex];
vertices = newFace.Vertices;
for (var j = 0; j < NumOfDimensions; j++) vertices[j] = oldFace.Vertices[j];
oldVertexIndex = vertices[forbidden];
int orderedPivotIndex;
// correct the ordering
if (currentVertexIndex < oldVertexIndex)
{
orderedPivotIndex = 0;
for (var j = forbidden - 1; j >= 0; j--)
{
if (vertices[j] > currentVertexIndex) vertices[j + 1] = vertices[j];
else
{
orderedPivotIndex = j + 1;
break;
}
}
}
else
{
orderedPivotIndex = NumOfDimensions - 1;
for (var j = forbidden + 1; j < NumOfDimensions; j++)
{
if (vertices[j] < currentVertexIndex) vertices[j - 1] = vertices[j];
else
{
orderedPivotIndex = j - 1;
break;
}
}
}
vertices[orderedPivotIndex] = CurrentVertex;
if (!mathHelper.CalculateFacePlane(newFace, Center))
{
return false;
}
ConeFaceBuffer.Add(MakeDeferredFace(newFace, orderedPivotIndex, adjacentFace, oldFaceAdjacentIndex,
oldFace));
}
}
return true;
}
/// <summary>
/// Commits a cone and adds a vertex to the convex hull.
/// </summary>
private void CommitCone()
{
// Fill the adjacency.
for (var i = 0; i < ConeFaceBuffer.Count; i++)
{
var face = ConeFaceBuffer[i];
var newFace = face.Face;
var adjacentFace = face.Pivot;
var oldFace = face.OldFace;
var orderedPivotIndex = face.FaceIndex;
newFace.AdjacentFaces[orderedPivotIndex] = adjacentFace.Index;
adjacentFace.AdjacentFaces[face.PivotIndex] = newFace.Index;
// let there be a connection.
for (var j = 0; j < NumOfDimensions; j++)
{
if (j == orderedPivotIndex) continue;
var connector = ObjectManager.GetConnector();
connector.Update(newFace, j, NumOfDimensions);
ConnectFace(connector);
}
// the id adjacent face on the hull? If so, we can use simple method to find beyond vertices.
if (adjacentFace.VerticesBeyond.Count == 0)
FindBeyondVertices(newFace, oldFace.VerticesBeyond);
// it is slightly more effective if the face with the lower number of beyond vertices comes first.
else if (adjacentFace.VerticesBeyond.Count < oldFace.VerticesBeyond.Count)
FindBeyondVertices(newFace, adjacentFace.VerticesBeyond, oldFace.VerticesBeyond);
else
FindBeyondVertices(newFace, oldFace.VerticesBeyond, adjacentFace.VerticesBeyond);
// This face will definitely lie on the hull
if (newFace.VerticesBeyond.Count == 0)
{
ConvexFaces.Add(newFace.Index);
UnprocessedFaces.Remove(newFace);
ObjectManager.DepositVertexBuffer(newFace.VerticesBeyond);
newFace.VerticesBeyond = EmptyBuffer;
}
else // Add the face to the list
{
UnprocessedFaces.Add(newFace);
}
// recycle the object.
ObjectManager.DepositDeferredFace(face);
}
// Recycle the affected faces.
for (var fIndex = 0; fIndex < AffectedFaceBuffer.Count; fIndex++)
{
var face = AffectedFaceBuffer[fIndex];
UnprocessedFaces.Remove(FacePool[face]);
ObjectManager.DepositFace(face);
}
}
/// <summary>
/// Check whether the vertex v is beyond the given face. If so, add it to beyondVertices.
/// </summary>
/// <param name="face">The face.</param>
/// <param name="beyondVertices">The beyond vertices.</param>
/// <param name="v">The v.</param>
private void IsBeyond(ConvexFaceInternal face, IndexBuffer beyondVertices, int v)
{
var distance = mathHelper.GetVertexDistance(v, face);
if (distance >= PlaneDistanceTolerance)
{
if (distance > MaxDistance)
{
// If it's within the tolerance distance, use the lex. larger point
if (distance - MaxDistance < PlaneDistanceTolerance)
{ // todo: why is this LexCompare necessary. Would seem to favor x over y over z (etc.)?
if (LexCompare(v, FurthestVertex) > 0)
{
MaxDistance = distance;
FurthestVertex = v;
}
}
else
{
MaxDistance = distance;
FurthestVertex = v;
}
}
beyondVertices.Add(v);
}
}
/// <summary>
/// Compares the values of two vertices. The return value (-1, 0 or +1) are found
/// by first checking the first coordinate and then progressing through the rest.
/// In this way {2, 8} will be a "-1" (less than) {3, 1}.
/// </summary>
/// <param name="u">The base vertex index, u.</param>
/// <param name="v">The compared vertex index, v.</param>
/// <returns>System.Int32.</returns>
private int LexCompare(int u, int v)
{
int uOffset = u * NumOfDimensions, vOffset = v * NumOfDimensions;
for (var i = 0; i < NumOfDimensions; i++)
{
double x = Positions[uOffset + i], y = Positions[vOffset + i];
var comp = x.CompareTo(y);
if (comp != 0) return comp;
}
return 0;
}
/// <summary>
/// Used by update faces.
/// </summary>
/// <param name="face">The face.</param>
/// <param name="beyond">The beyond.</param>
/// <param name="beyond1">The beyond1.</param>
private void FindBeyondVertices(ConvexFaceInternal face, IndexBuffer beyond, IndexBuffer beyond1)
{
var beyondVertices = BeyondBuffer;
MaxDistance = double.NegativeInfinity;
FurthestVertex = 0;
int v;
for (var i = 0; i < beyond1.Count; i++) VertexVisited[beyond1[i]] = true;
VertexVisited[CurrentVertex] = false;
for (var i = 0; i < beyond.Count; i++)
{
v = beyond[i];
if (v == CurrentVertex) continue;
VertexVisited[v] = false;
IsBeyond(face, beyondVertices, v);
}
for (var i = 0; i < beyond1.Count; i++)
{
v = beyond1[i];
if (VertexVisited[v]) IsBeyond(face, beyondVertices, v);
}
face.FurthestVertex = FurthestVertex;
// Pull the old switch a roo (switch the face beyond buffers)
var temp = face.VerticesBeyond;
face.VerticesBeyond = beyondVertices;
if (temp.Count > 0) temp.Clear();
BeyondBuffer = temp;
}
/// <summary>
/// Finds the beyond vertices.
/// </summary>
/// <param name="face">The face.</param>
/// <param name="beyond">The beyond.</param>
private void FindBeyondVertices(ConvexFaceInternal face, IndexBuffer beyond)
{
var beyondVertices = BeyondBuffer;
MaxDistance = double.NegativeInfinity;
FurthestVertex = 0;
int v;
for (var i = 0; i < beyond.Count; i++)
{
v = beyond[i];
if (v == CurrentVertex) continue;
IsBeyond(face, beyondVertices, v);
}
face.FurthestVertex = FurthestVertex;
// Pull the old switch a roo (switch the face beyond buffers)
var temp = face.VerticesBeyond;
face.VerticesBeyond = beyondVertices;
if (temp.Count > 0) temp.Clear();
BeyondBuffer = temp;
}
/// <summary>
/// Recalculates the centroid of the current hull.
/// </summary>
private void UpdateCenter()
{
for (var i = 0; i < NumOfDimensions; i++) Center[i] *= ConvexHullSize;
ConvexHullSize += 1;
var f = 1.0f / ConvexHullSize;
var co = CurrentVertex * NumOfDimensions;
for (var i = 0; i < NumOfDimensions; i++) Center[i] = f * (Center[i] + Positions[co + i]);
}
/// <summary>
/// Removes the last vertex from the center.
/// </summary>
private void RollbackCenter()
{
for (var i = 0; i < NumOfDimensions; i++) Center[i] *= ConvexHullSize;
ConvexHullSize -= 1;
var f = ConvexHullSize > 0 ? 1.0f / ConvexHullSize : 0.0f;
var co = CurrentVertex * NumOfDimensions;
for (var i = 0; i < NumOfDimensions; i++) Center[i] = f * (Center[i] - Positions[co + i]);
}
/// <summary>
/// Handles singular vertex.
/// </summary>
private void HandleSingular()
{
RollbackCenter();
SingularVertices.Add(CurrentVertex);
// This means that all the affected faces must be on the hull and that all their "vertices beyond" are singular.
for (var fIndex = 0; fIndex < AffectedFaceBuffer.Count; fIndex++)
{
var face = FacePool[AffectedFaceBuffer[fIndex]];
var vb = face.VerticesBeyond;
for (var i = 0; i < vb.Count; i++)
{
SingularVertices.Add(vb[i]);
}
ConvexFaces.Add(face.Index);
UnprocessedFaces.Remove(face);
ObjectManager.DepositVertexBuffer(face.VerticesBeyond);
face.VerticesBeyond = EmptyBuffer;
}
}
/// <summary>
/// Get a vertex coordinate. In order to reduce speed, all vertex coordinates
/// have been placed in a single array.
/// </summary>
/// <param name="vIndex">The vertex index.</param>
/// <param name="dimension">The index of the dimension.</param>
/// <returns>System.Double.</returns>
private float GetCoordinate(int vIndex, int dimension)
{
return Positions[vIndex * NumOfDimensions + dimension];
}
#region Returning the Results in the proper format
/// <summary>
/// Gets the hull vertices.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <param name="data">The data.</param>
/// <returns>TVertex[].</returns>
private TVertex[] GetHullVertices<TVertex>(IList<TVertex> data)
{
var cellCount = ConvexFaces.Count;
var hullVertexCount = 0;
for (var i = 0; i < NumberOfVertices; i++) VertexVisited[i] = false;
for (var i = 0; i < cellCount; i++)
{
var vs = FacePool[ConvexFaces[i]].Vertices;
for (var j = 0; j < vs.Length; j++)
{
var v = vs[j];
if (!VertexVisited[v])
{
VertexVisited[v] = true;
hullVertexCount++;
}
}
}
var result = new TVertex[hullVertexCount];
for (var i = 0; i < NumberOfVertices; i++)
{
if (VertexVisited[i]) result[--hullVertexCount] = data[i];
}
return result;
}
/// <summary>
/// Finds the convex hull and creates the TFace objects.
/// </summary>
/// <typeparam name="TFace">The type of the t face.</typeparam>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <returns>TFace[].</returns>
private TFace[] GetConvexFaces<TVertex, TFace>()
where TFace : ConvexFace<TVertex, TFace>, new()
where TVertex : IVertex
{
var faces = ConvexFaces;
var cellCount = faces.Count;
var cells = new TFace[cellCount];
for (var i = 0; i < cellCount; i++)
{
var face = FacePool[faces[i]];
var vertices = new TVertex[NumOfDimensions];
for (var j = 0; j < NumOfDimensions; j++)
{
vertices[j] = (TVertex)Vertices[face.Vertices[j]];
}
cells[i] = new TFace
{
Vertices = vertices,
Adjacency = new TFace[NumOfDimensions],
Normal = IsLifted ? null : face.Normal
};
face.Tag = i;
}
for (var i = 0; i < cellCount; i++)
{
var face = FacePool[faces[i]];
var cell = cells[i];
for (var j = 0; j < NumOfDimensions; j++)
{
if (face.AdjacentFaces[j] < 0) continue;
cell.Adjacency[j] = cells[FacePool[face.AdjacentFaces[j]].Tag];
}
// Fix the vertex orientation.
if (face.IsNormalFlipped)
{
var tempVert = cell.Vertices[0];
cell.Vertices[0] = cell.Vertices[NumOfDimensions - 1];
cell.Vertices[NumOfDimensions - 1] = tempVert;
var tempAdj = cell.Adjacency[0];
cell.Adjacency[0] = cell.Adjacency[NumOfDimensions - 1];
cell.Adjacency[NumOfDimensions - 1] = tempAdj;
}
}
return cells;
}
/// <summary>
/// For 2D only: Returns the result in counter-clockwise order starting with the element with the lowest X value.
/// If there are multiple vertices with the same minimum X, then the one with the lowest Y is chosen.
/// </summary>
/// <typeparam name="TVertex">The type of the vertex.</typeparam>
/// <typeparam name="TFace">The type of the face.</typeparam>
/// <param name="data">The data.</param>
/// <returns></returns>
private ConvexHull<TVertex, TFace> Return2DResultInOrder<TVertex, TFace>(IList<TVertex> data)
where TVertex : IVertex
where TFace : ConvexFace<TVertex, TFace>, new()
{
TFace[] faces = GetConvexFaces<TVertex, TFace>();
var numPoints = faces.Length;
var orderDictionary = new Dictionary<TVertex, TFace>();
foreach (var face in faces)
orderDictionary.Add(face.Vertices[1], face);
var firstPoint = faces[0].Vertices[1];
var nextPoint = faces[0].Vertices[0];
var orderedPointList = new List<TVertex>();
orderedPointList.Add(firstPoint);
var orderedFaceList = new List<TFace>();
orderedFaceList.Add(faces[1]);
var lowestXMinIndex = 0;
var k = 0;
while (!nextPoint.Equals(firstPoint))
{
orderedPointList.Add(nextPoint);
var nextFace = orderDictionary[nextPoint];
orderedFaceList.Add(nextFace);
if (nextPoint.Position[0] < orderedPointList[lowestXMinIndex].Position[0]
|| (nextPoint.Position[0] == orderedPointList[lowestXMinIndex].Position[0]
&& nextPoint.Position[1] <= orderedPointList[lowestXMinIndex].Position[1]))
lowestXMinIndex = k;
k++;
nextPoint = nextFace.Vertices[0];
}
TVertex[] points = new TVertex[numPoints];
for (int i = 0; i < numPoints; i++)
{
var j = (i + lowestXMinIndex) % numPoints;
points[i] = orderedPointList[j];
faces[i] = orderedFaceList[j];
}
return new ConvexHull<TVertex, TFace>
{
Points = points,
Faces = faces
};
}
#endregion
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: f7f265217d00ea340b87645242a45181
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,237 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
namespace MIConvexHull
{
/// <summary>
/// For deferred face addition.
/// </summary>
internal sealed class DeferredFace
{
/// <summary>
/// The faces.
/// </summary>
public ConvexFaceInternal Face, Pivot, OldFace;
/// <summary>
/// The indices.
/// </summary>
public int FaceIndex, PivotIndex;
}
/// <summary>
/// A helper class used to connect faces.
/// </summary>
internal sealed class FaceConnector
{
/// <summary>
/// The edge to be connected.
/// </summary>
public int EdgeIndex;
/// <summary>
/// The face.
/// </summary>
public ConvexFaceInternal Face;
/// <summary>
/// The hash code computed from indices.
/// </summary>
public uint HashCode;
/// <summary>
/// Next node in the list.
/// </summary>
public FaceConnector Next;
/// <summary>
/// Prev node in the list.
/// </summary>
public FaceConnector Previous;
/// <summary>
/// The vertex indices.
/// </summary>
public int[] Vertices;
/// <summary>
/// Ctor.
/// </summary>
/// <param name="dimension">The dimension.</param>
public FaceConnector(int dimension)
{
Vertices = new int[dimension - 1];
}
/// <summary>
/// Updates the connector.
/// </summary>
/// <param name="face">The face.</param>
/// <param name="edgeIndex">Index of the edge.</param>
/// <param name="dim">The dim.</param>
public void Update(ConvexFaceInternal face, int edgeIndex, int dim)
{
Face = face;
EdgeIndex = edgeIndex;
uint hashCode = 23;
unchecked
{
var vs = face.Vertices;
int i, c = 0;
for (i = 0; i < edgeIndex; i++)
{
Vertices[c++] = vs[i];
hashCode += 31*hashCode + (uint) vs[i];
}
for (i = edgeIndex + 1; i < vs.Length; i++)
{
Vertices[c++] = vs[i];
hashCode += 31*hashCode + (uint) vs[i];
}
}
HashCode = hashCode;
}
/// <summary>
/// Can two faces be connected.
/// </summary>
/// <param name="a">a.</param>
/// <param name="b">The b.</param>
/// <param name="dim">The dim.</param>
/// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
public static bool AreConnectable(FaceConnector a, FaceConnector b, int dim)
{
if (a.HashCode != b.HashCode) return false;
var av = a.Vertices;
var bv = b.Vertices;
for (var i = 0; i < av.Length; i++)
{
if (av[i] != bv[i]) return false;
}
return true;
}
/// <summary>
/// Connect two faces.
/// </summary>
/// <param name="a">a.</param>
/// <param name="b">The b.</param>
public static void Connect(FaceConnector a, FaceConnector b)
{
a.Face.AdjacentFaces[a.EdgeIndex] = b.Face.Index;
b.Face.AdjacentFaces[b.EdgeIndex] = a.Face.Index;
}
}
/// <summary>
/// This internal class manages the faces of the convex hull. It is a
/// separate class from the desired user class.
/// </summary>
internal sealed class ConvexFaceInternal
{
/// <summary>
/// Gets or sets the adjacent face data.
/// </summary>
public int[] AdjacentFaces;
/// <summary>
/// The furthest vertex.
/// </summary>
public int FurthestVertex;
/// <summary>
/// Index of the face inside the pool.
/// </summary>
public int Index;
/// <summary>
/// Is it present in the list.
/// </summary>
public bool InList;
/// <summary>
/// Is the normal flipped?
/// </summary>
public bool IsNormalFlipped;
/// <summary>
/// Next node in the list.
/// </summary>
public ConvexFaceInternal Next;
/// <summary>
/// Gets or sets the normal vector.
/// </summary>
public float[] Normal;
/// <summary>
/// Face plane constant element.
/// </summary>
public float Offset;
//public int UnprocessedIndex;
/// <summary>
/// Prev node in the list.
/// </summary>
public ConvexFaceInternal Previous;
/// <summary>
/// Used to traverse affected faces and create the Delaunay representation.
/// </summary>
public int Tag;
/// <summary>
/// Gets or sets the vertices.
/// </summary>
public int[] Vertices;
/// <summary>
/// Gets or sets the vertices beyond.
/// </summary>
public IndexBuffer VerticesBeyond;
/// <summary>
/// Initializes a new instance of the <see cref="ConvexFaceInternal" /> class.
/// </summary>
/// <param name="dimension">The dimension.</param>
/// <param name="index">The index.</param>
/// <param name="beyondList">The beyond list.</param>
public ConvexFaceInternal(int dimension, int index, IndexBuffer beyondList)
{
Index = index;
AdjacentFaces = new int[dimension];
VerticesBeyond = beyondList;
Normal = new float[dimension];
Vertices = new int[dimension];
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 58c58e163f4c86e48a6df3ee6c5056c0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,480 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
using System.Collections.Generic;
using CultMath;
using static CultMath.math;
using Random = CultMath.Random;
namespace MIConvexHull
{
/// <summary>
/// A helper class mostly for normal computation. If convex hulls are computed
/// in higher dimensions, it might be a good idea to add a specific
/// FindNormalVectorND function.
/// </summary>
internal class MathHelper
{
/// <summary>
/// The dimension
/// </summary>
private readonly int Dimension;
/// <summary>
/// The matrix pivots
/// </summary>
private readonly int[] matrixPivots;
/// <summary>
/// The n d matrix
/// </summary>
private readonly float[] nDMatrix;
/// <summary>
/// The n d normal helper vector
/// </summary>
private readonly float[] nDNormalHelperVector;
/// <summary>
/// The nt x
/// </summary>
private readonly float[] ntX;
/// <summary>
/// The nt y
/// </summary>
private readonly float[] ntY;
/// <summary>
/// The nt z
/// </summary>
private readonly float[] ntZ;
/// <summary>
/// The position data
/// </summary>
private readonly float[] PositionData;
private static Random _random = new Random((uint) (DateTime.Now.Ticks % uint.MaxValue));
/// <summary>
/// Initializes a new instance of the <see cref="MathHelper"/> class.
/// </summary>
/// <param name="dimension">The dimension.</param>
/// <param name="positions">The positions.</param>
internal MathHelper(int dimension, float[] positions)
{
PositionData = positions;
Dimension = dimension;
ntX = new float[Dimension];
ntY = new float[Dimension];
ntZ = new float[Dimension];
nDNormalHelperVector = new float[Dimension];
nDMatrix = new float[Dimension * Dimension];
matrixPivots = new int[Dimension];
}
/// <summary>
/// Calculates the normal and offset of the hyper-plane given by the face's vertices.
/// </summary>
/// <param name="face">The face.</param>
/// <param name="center">The center.</param>
/// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
internal bool CalculateFacePlane(ConvexFaceInternal face, float[] center)
{
var vertices = face.Vertices;
var normal = face.Normal;
FindNormalVector(vertices, normal);
if (float.IsNaN(normal[0]))
{
return false;
}
var offset = 0.0f;
var centerDistance = 0.0f;
var fi = vertices[0] * Dimension;
for (var i = 0; i < Dimension; i++)
{
var n = normal[i];
offset += n * PositionData[fi + i];
centerDistance += n * center[i];
}
face.Offset = -offset;
centerDistance -= offset;
if (centerDistance > 0)
{
for (var i = 0; i < Dimension; i++) normal[i] = -normal[i];
face.Offset = offset;
face.IsNormalFlipped = true;
}
else face.IsNormalFlipped = false;
return true;
}
/// <summary>
/// Check if the vertex is "visible" from the face.
/// The vertex is "over face" if the return value is &gt; Constants.PlaneDistanceTolerance.
/// </summary>
/// <param name="v">The v.</param>
/// <param name="f">The f.</param>
/// <returns>The vertex is "over face" if the result is positive.</returns>
internal double GetVertexDistance(int v, ConvexFaceInternal f)
{
var normal = f.Normal;
var x = v * Dimension;
var distance = f.Offset;
for (var i = 0; i < normal.Length; i++) distance += normal[i] * PositionData[x + i];
return distance;
}
/// <summary>
/// Returns the vector the between vertices.
/// </summary>
/// <param name="toIndex">To index.</param>
/// <param name="fromIndex">From index.</param>
/// <returns>System.Double[].</returns>
internal float[] VectorBetweenVertices(int toIndex, int fromIndex)
{
var target = new float[Dimension];
VectorBetweenVertices(toIndex, fromIndex, target);
return target;
}
/// <summary>
/// Returns the vector the between vertices.
/// </summary>
/// <param name="fromIndex">From index.</param>
/// <param name="toIndex">To index.</param>
/// <param name="target">The target.</param>
/// <returns></returns>
private void VectorBetweenVertices(int toIndex, int fromIndex, float[] target)
{
int u = toIndex * Dimension, v = fromIndex * Dimension;
for (var i = 0; i < Dimension; i++)
{
target[i] = PositionData[u + i] - PositionData[v + i];
}
}
internal void RandomOffsetToLift(int index, float maxHeight)
{
var liftIndex = (index * Dimension) + Dimension - 1;
PositionData[liftIndex] += 0.0001f * maxHeight * (_random.NextFloat() - 0.5f);
}
#region Find the normal vector of the face
/// <summary>
/// Finds normal vector of a hyper-plane given by vertices.
/// Stores the results to normalData.
/// </summary>
/// <param name="vertices">The vertices.</param>
/// <param name="normalData">The normal data.</param>
private void FindNormalVector(int[] vertices, float[] normalData)
{
switch (Dimension)
{
case 2:
FindNormalVector2D(vertices, normalData);
break;
case 3:
FindNormalVector3D(vertices, normalData);
break;
case 4:
FindNormalVector4D(vertices, normalData);
break;
default:
FindNormalVectorND(vertices, normalData);
break;
}
}
/// <summary>
/// Finds 2D normal vector.
/// </summary>
/// <param name="vertices">The vertices.</param>
/// <param name="normal">The normal.</param>
private void FindNormalVector2D(int[] vertices, float[] normal)
{
VectorBetweenVertices(vertices[1], vertices[0], ntX);
var nx = -ntX[1];
var ny = ntX[0];
var norm = sqrt(nx * nx + ny * ny);
var f = 1.0f / norm;
normal[0] = f * nx;
normal[1] = f * ny;
}
/// <summary>
/// Finds 3D normal vector.
/// </summary>
/// <param name="vertices">The vertices.</param>
/// <param name="normal">The normal.</param>
private void FindNormalVector3D(int[] vertices, float[] normal)
{
VectorBetweenVertices(vertices[1], vertices[0], ntX);
VectorBetweenVertices(vertices[2], vertices[1], ntY);
var nx = ntX[1] * ntY[2] - ntX[2] * ntY[1];
var ny = ntX[2] * ntY[0] - ntX[0] * ntY[2];
var nz = ntX[0] * ntY[1] - ntX[1] * ntY[0];
var norm = sqrt(nx * nx + ny * ny + nz * nz);
var f = 1.0f / norm;
normal[0] = f * nx;
normal[1] = f * ny;
normal[2] = f * nz;
}
/// <summary>
/// Finds 4D normal vector.
/// </summary>
/// <param name="vertices">The vertices.</param>
/// <param name="normal">The normal.</param>
private void FindNormalVector4D(int[] vertices, float[] normal)
{
VectorBetweenVertices(vertices[1], vertices[0], ntX);
VectorBetweenVertices(vertices[2], vertices[1], ntY);
VectorBetweenVertices(vertices[3], vertices[2], ntZ);
var x = ntX;
var y = ntY;
var z = ntZ;
// This was generated using Mathematica
var nx = x[3] * (y[2] * z[1] - y[1] * z[2])
+ x[2] * (y[1] * z[3] - y[3] * z[1])
+ x[1] * (y[3] * z[2] - y[2] * z[3]);
var ny = x[3] * (y[0] * z[2] - y[2] * z[0])
+ x[2] * (y[3] * z[0] - y[0] * z[3])
+ x[0] * (y[2] * z[3] - y[3] * z[2]);
var nz = x[3] * (y[1] * z[0] - y[0] * z[1])
+ x[1] * (y[0] * z[3] - y[3] * z[0])
+ x[0] * (y[3] * z[1] - y[1] * z[3]);
var nw = x[2] * (y[0] * z[1] - y[1] * z[0])
+ x[1] * (y[2] * z[0] - y[0] * z[2])
+ x[0] * (y[1] * z[2] - y[2] * z[1]);
var norm = sqrt(nx * nx + ny * ny + nz * nz + nw * nw);
var f = 1.0f / norm;
normal[0] = f * nx;
normal[1] = f * ny;
normal[2] = f * nz;
normal[3] = f * nw;
}
/// <summary>
/// Finds the normal vector nd.
/// </summary>
/// <param name="vertices">The vertices.</param>
/// <param name="normal">The normal.</param>
private void FindNormalVectorND(int[] vertices, float[] normal)
{
/* We need to solve the matrix A n = B where
* - A contains coordinates of vertices as columns
* - B is vector with all 1's. Really, it should be the distance of
* the plane from the origin, but - since we're not worried about that
* here and we will normalize the normal anyway - all 1's suffices.
*/
var iPiv = matrixPivots;
var data = nDMatrix;
var norm = 0.0f;
// Solve determinants by replacing x-th column by all 1.
for (var x = 0; x < Dimension; x++)
{
for (var i = 0; i < Dimension; i++)
{
var offset = vertices[i] * Dimension;
for (var j = 0; j < Dimension; j++)
{
// maybe I got the i/j mixed up here regarding the representation Math.net uses...
// ...but it does not matter since Det(A) = Det(Transpose(A)).
data[Dimension * i + j] = j == x ? 1.0f : PositionData[offset + j];
}
}
LUFactor(data, Dimension, iPiv, nDNormalHelperVector);
var coord = 1.0f;
for (var i = 0; i < Dimension; i++)
{
if (iPiv[i] != i) coord *= -data[Dimension * i + i]; // the determinant sign changes on row swap.
else coord *= data[Dimension * i + i];
}
normal[x] = coord;
norm += coord * coord;
}
// Normalize the result
var f = 1.0f / sqrt(norm);
for (var i = 0; i < normal.Length; i++) normal[i] *= f;
}
#endregion
#region Simplex Volume
/// <summary>
/// Gets the simplex volume. Prior to having enough edge vectors, the method pads the remaining with all
/// "other numbers". So, yes, this method is not really finding the volume. But a relative volume-like measure. It
/// uses the magnitude of the determinant as the volume stand-in following the Cayley-Menger theorem.
/// </summary>
/// <param name="edgeVectors">The edge vectors.</param>
/// <param name="lastIndex">The last index.</param>
/// <param name="bigNumber">The big number.</param>
/// <returns>System.Double.</returns>
internal float GetSimplexVolume(float[][] edgeVectors, int lastIndex, float bigNumber)
{
var A = new float[Dimension * Dimension];
var index = 0;
for (int i = 0; i < Dimension; i++)
for (int j = 0; j < Dimension; j++)
if (i <= lastIndex)
A[index++] = edgeVectors[i][j];
else A[index] = (pow(-1, index) * index++) / bigNumber;
// this last term is used for all the vertices in the comparison for the yet determined vertices
// the idea is to come up with sets of numbers that are orthogonal so that an non-zero value will result
// and to choose smallish numbers since the choice of vectors will affect what the end volume is.
// A better way (todo?) is to solve a smaller matrix. However, cases were found in which the obvious smaller vector
// (the upper left) had too many zeros. So, one would need to find the right subset. Indeed choosing a subset
// biases the first dimensions of the others. Perhaps a larger volume would be created from a different vertex
// if another subset of dimensions were used.
return abs(DeterminantDestructive(A));
}
/// <summary>
/// Determinants the destructive.
/// </summary>
/// <param name="A">a.</param>
/// <returns>System.Double.</returns>
private float DeterminantDestructive(float[] A)
{
switch (Dimension)
{
case 0:
return 0.0f;
case 1:
return A[0];
case 2:
return A[0] * A[3] - A[1] * A[2];
case 3:
return A[0] * A[4] * A[8] + A[1] * A[5] * A[6] + A[2] * A[3] * A[7]
- A[0] * A[5] * A[7] - A[1] * A[3] * A[8] - A[2] * A[4] * A[6];
default:
{
var iPiv = new int[Dimension];
var helper = new float[Dimension];
LUFactor(A, Dimension, iPiv, helper);
var det = 1.0f;
for (var i = 0; i < iPiv.Length; i++)
{
det *= A[Dimension * i + i];
if (iPiv[i] != i) det *= -1; // the determinant sign changes on row swap.
}
return det;
}
}
}
#endregion
// Modified from Math.NET
// Copyright (c) 2009-2013 Math.NET
/// <summary>
/// Lus the factor.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="order">The order.</param>
/// <param name="ipiv">The ipiv.</param>
/// <param name="vecLUcolj">The vec l ucolj.</param>
private static void LUFactor(float[] data, int order, int[] ipiv, float[] vecLUcolj)
{
// Initialize the pivot matrix to the identity permutation.
for (var i = 0; i < order; i++)
{
ipiv[i] = i;
}
// Outer loop.
for (var j = 0; j < order; j++)
{
var indexj = j * order;
var indexjj = indexj + j;
// Make a copy of the j-th column to localize references.
for (var i = 0; i < order; i++)
{
vecLUcolj[i] = data[indexj + i];
}
// Apply previous transformations.
for (var i = 0; i < order; i++)
{
// Most of the time is spent in the following dot product.
var kmax = Math.Min(i, j);
var s = 0.0f;
for (var k = 0; k < kmax; k++)
{
s += data[k * order + i] * vecLUcolj[k];
}
data[indexj + i] = vecLUcolj[i] -= s;
}
// Find pivot and exchange if necessary.
var p = j;
for (var i = j + 1; i < order; i++)
{
if (abs(vecLUcolj[i]) > abs(vecLUcolj[p]))
{
p = i;
}
}
if (p != j)
{
for (var k = 0; k < order; k++)
{
var indexk = k * order;
var indexkp = indexk + p;
var indexkj = indexk + j;
var temp = data[indexkp];
data[indexkp] = data[indexkj];
data[indexkj] = temp;
}
ipiv[j] = p;
}
// Compute multipliers.
if (j < order & data[indexjj] != 0.0f)
{
for (var i = j + 1; i < order; i++)
{
data[indexj + i] /= data[indexjj];
}
}
}
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 8bb3327041efa18479319ec117493f8c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,218 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
namespace MIConvexHull
{
/// <summary>
/// A helper class for object allocation/storage.
/// This helps the GC a lot as it prevents the creation of about 75% of
/// new face objects (in the case of ConvexFaceInternal). In the case of
/// FaceConnectors and DefferedFaces, the difference is even higher (in most
/// cases O(1) vs O(number of created faces)).
/// </summary>
internal class ObjectManager
{
/// <summary>
/// The dimension
/// </summary>
private readonly int Dimension;
/// <summary>
/// The connector stack
/// </summary>
private FaceConnector ConnectorStack;
/// <summary>
/// The deferred face stack
/// </summary>
private readonly SimpleList<DeferredFace> DeferredFaceStack;
/// <summary>
/// The empty buffer stack
/// </summary>
private readonly SimpleList<IndexBuffer> EmptyBufferStack;
/// <summary>
/// The face pool
/// </summary>
private ConvexFaceInternal[] FacePool;
/// <summary>
/// The face pool size
/// </summary>
private int FacePoolSize;
/// <summary>
/// The face pool capacity
/// </summary>
private int FacePoolCapacity;
/// <summary>
/// The free face indices
/// </summary>
private readonly IndexBuffer FreeFaceIndices;
/// <summary>
/// The hull
/// </summary>
private readonly ConvexHullAlgorithm Hull;
/// <summary>
/// Create the manager.
/// </summary>
/// <param name="hull">The hull.</param>
public ObjectManager(ConvexHullAlgorithm hull)
{
Dimension = hull.NumOfDimensions;
Hull = hull;
FacePool = hull.FacePool;
FacePoolSize = 0;
FacePoolCapacity = hull.FacePool.Length;
FreeFaceIndices = new IndexBuffer();
EmptyBufferStack = new SimpleList<IndexBuffer>();
DeferredFaceStack = new SimpleList<DeferredFace>();
}
/// <summary>
/// Return the face to the pool for later use.
/// </summary>
/// <param name="faceIndex">Index of the face.</param>
public void DepositFace(int faceIndex)
{
var face = FacePool[faceIndex];
var af = face.AdjacentFaces;
for (var i = 0; i < af.Length; i++)
{
af[i] = -1;
}
FreeFaceIndices.Push(faceIndex);
}
/// <summary>
/// Reallocate the face pool, including the AffectedFaceFlags
/// </summary>
private void ReallocateFacePool()
{
var newPool = new ConvexFaceInternal[2*FacePoolCapacity];
var newTags = new bool[2*FacePoolCapacity];
Array.Copy(FacePool, newPool, FacePoolCapacity);
Buffer.BlockCopy(Hull.AffectedFaceFlags, 0, newTags, 0, FacePoolCapacity*sizeof (bool));
FacePoolCapacity = 2*FacePoolCapacity;
Hull.FacePool = newPool;
FacePool = newPool;
Hull.AffectedFaceFlags = newTags;
}
/// <summary>
/// Create a new face and put it in the pool.
/// </summary>
/// <returns>System.Int32.</returns>
private int CreateFace()
{
var index = FacePoolSize;
var face = new ConvexFaceInternal(Dimension, index, GetVertexBuffer());
FacePoolSize++;
if (FacePoolSize > FacePoolCapacity) ReallocateFacePool();
FacePool[index] = face;
return index;
}
/// <summary>
/// Return index of an unused face or creates a new one.
/// </summary>
/// <returns>System.Int32.</returns>
public int GetFace()
{
if (FreeFaceIndices.Count > 0) return FreeFaceIndices.Pop();
return CreateFace();
}
/// <summary>
/// Store a face connector in the "embedded" linked list.
/// </summary>
/// <param name="connector">The connector.</param>
public void DepositConnector(FaceConnector connector)
{
if (ConnectorStack == null)
{
connector.Next = null;
ConnectorStack = connector;
}
else
{
connector.Next = ConnectorStack;
ConnectorStack = connector;
}
}
/// <summary>
/// Get an unused face connector. If none is available, create it.
/// </summary>
/// <returns>FaceConnector.</returns>
public FaceConnector GetConnector()
{
if (ConnectorStack == null) return new FaceConnector(Dimension);
var ret = ConnectorStack;
ConnectorStack = ConnectorStack.Next;
ret.Next = null;
return ret;
}
/// <summary>
/// Deposit the index buffer.
/// </summary>
/// <param name="buffer">The buffer.</param>
public void DepositVertexBuffer(IndexBuffer buffer)
{
buffer.Clear();
EmptyBufferStack.Push(buffer);
}
/// <summary>
/// Get a store index buffer or create a new instance.
/// </summary>
/// <returns>IndexBuffer.</returns>
public IndexBuffer GetVertexBuffer()
{
return EmptyBufferStack.Count != 0 ? EmptyBufferStack.Pop() : new IndexBuffer();
}
/// <summary>
/// Deposit the deferred face.
/// </summary>
/// <param name="face">The face.</param>
public void DepositDeferredFace(DeferredFace face)
{
DeferredFaceStack.Push(face);
}
/// <summary>
/// Get the deferred face.
/// </summary>
/// <returns>DeferredFace.</returns>
public DeferredFace GetDeferredFace()
{
return DeferredFaceStack.Count != 0 ? DeferredFaceStack.Pop() : new DeferredFace();
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: e8d582e97c6824740ba9e1a41641b372
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
using System.Collections;
using MIConvexHull;
public class Face3 : ConvexFace<Vertex3, Face3>
{
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 30a2635ffe51545499499ce392b234c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,53 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
namespace MIConvexHull
{
/// <summary>
/// An interface for a structure with nD position.
/// </summary>
public interface IVertex
{
/// <summary>
/// Position of the vertex.
/// </summary>
/// <value>The position.</value>
float[] Position { get; }
}
/// <summary>
/// "Default" vertex.
/// </summary>
/// <seealso cref="MIConvexHull.IVertex" />
public class DefaultVertex : IVertex
{
/// <summary>
/// Position of the vertex.
/// </summary>
/// <value>The position.</value>
public float[] Position { get; set; }
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: a55f713690988bb48aef7cbd4f01ebc7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,181 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System.Collections.Generic;
using System.Linq;
namespace MIConvexHull
{
/// <summary>
/// Simple interface to unify different types of triangulations in the future.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
public interface ITriangulation<TVertex, TCell>
where TCell : TriangulationCell<TVertex, TCell>, new()
where TVertex : IVertex
{
/// <summary>
/// Triangulation simplexes. For 2D - triangles, 3D - tetrahedrons, etc ...
/// </summary>
/// <value>The cells.</value>
IEnumerable<TCell> Cells { get; }
}
/// <summary>
/// Factory class for creating triangulations.
/// </summary>
public static class Triangulation
{
/// <summary>
/// Creates the Delaunay triangulation of the input data.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>ITriangulation&lt;TVertex, DefaultTriangulationCell&lt;TVertex&gt;&gt;.</returns>
public static ITriangulation<TVertex, DefaultTriangulationCell<TVertex>> CreateDelaunay<TVertex>(
IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
{
return DelaunayTriangulation<TVertex, DefaultTriangulationCell<TVertex>>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Creates the Delaunay triangulation of the input data.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>ITriangulation&lt;DefaultVertex, DefaultTriangulationCell&lt;DefaultVertex&gt;&gt;.</returns>
public static ITriangulation<DefaultVertex, DefaultTriangulationCell<DefaultVertex>> CreateDelaunay(
IList<float[]> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
{
var points = data.Select(p => new DefaultVertex { Position = p }).ToList();
return DelaunayTriangulation<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>.Create(points, PlaneDistanceTolerance);
}
/// <summary>
/// Creates the Delaunay triangulation of the input data.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TFace">The type of the t face.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>ITriangulation&lt;TVertex, TFace&gt;.</returns>
public static ITriangulation<TVertex, TFace> CreateDelaunay<TVertex, TFace>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
where TFace : TriangulationCell<TVertex, TFace>, new()
{
return DelaunayTriangulation<TVertex, TFace>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <typeparam name="TEdge">The type of the t edge.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>VoronoiMesh&lt;TVertex, TCell, TEdge&gt;.</returns>
public static VoronoiMesh<TVertex, TCell, TEdge> CreateVoronoi<TVertex, TCell, TEdge>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TCell : TriangulationCell<TVertex, TCell>, new()
where TVertex : IVertex
where TEdge : VoronoiEdge<TVertex, TCell>, new()
{
return VoronoiMesh<TVertex, TCell, TEdge>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>VoronoiMesh&lt;TVertex, DefaultTriangulationCell&lt;TVertex&gt;, VoronoiEdge&lt;TVertex, DefaultTriangulationCell&lt;TVertex&gt;&gt;&gt;.</returns>
public static
VoronoiMesh
<TVertex, DefaultTriangulationCell<TVertex>, VoronoiEdge<TVertex, DefaultTriangulationCell<TVertex>>>
CreateVoronoi<TVertex>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
{
return
VoronoiMesh
<TVertex, DefaultTriangulationCell<TVertex>, VoronoiEdge<TVertex, DefaultTriangulationCell<TVertex>>
>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>VoronoiMesh&lt;DefaultVertex, DefaultTriangulationCell&lt;DefaultVertex&gt;, VoronoiEdge&lt;DefaultVertex, DefaultTriangulationCell&lt;DefaultVertex&gt;&gt;&gt;.</returns>
public static
VoronoiMesh
<DefaultVertex, DefaultTriangulationCell<DefaultVertex>,
VoronoiEdge<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>>
CreateVoronoi(IList<float[]> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
{
var points = data.Select(p => new DefaultVertex { Position = p.ToArray() }).ToList();
return
VoronoiMesh
<DefaultVertex, DefaultTriangulationCell<DefaultVertex>,
VoronoiEdge<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>>.Create(points, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>VoronoiMesh&lt;TVertex, TCell, VoronoiEdge&lt;TVertex, TCell&gt;&gt;.</returns>
public static VoronoiMesh<TVertex, TCell, VoronoiEdge<TVertex, TCell>> CreateVoronoi<TVertex, TCell>(
IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
where TCell : TriangulationCell<TVertex, TCell>, new()
{
return VoronoiMesh<TVertex, TCell, VoronoiEdge<TVertex, TCell>>.Create(data, PlaneDistanceTolerance);
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 0762bfe861ed97749a222fb1c217788a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 5bf99a94223c6e1458c243cb68091852
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,96 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System.Collections.Generic;
using System.Linq;
namespace MIConvexHull
{
/*
* Code here handles triangulation related stuff.
*/
/// <summary>
/// Class ConvexHullAlgorithm.
/// </summary>
internal partial class ConvexHullAlgorithm
{
/// <summary>
/// Computes the Delaunay triangulation.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance.</param>
/// <returns>TCell[].</returns>
internal static TCell[] GetDelaunayTriangulation<TVertex, TCell>(IList<TVertex> data,
float PlaneDistanceTolerance )
where TCell : TriangulationCell<TVertex, TCell>, new()
where TVertex : IVertex
{
var ch = new ConvexHullAlgorithm(data.Cast<IVertex>().ToArray(), true, PlaneDistanceTolerance);
ch.GetConvexHull();
ch.RemoveUpperFaces();
return ch.GetConvexFaces<TVertex, TCell>();
}
/// <summary>
/// Removes up facing Tetrahedrons from the triangulation.
/// </summary>
private void RemoveUpperFaces()
{
var delaunayFaces = ConvexFaces;
var dimension = NumOfDimensions - 1;
// Remove the "upper" faces
for (var i = delaunayFaces.Count - 1; i >= 0; i--)
{
var candidateIndex = delaunayFaces[i];
var candidate = FacePool[candidateIndex];
if (candidate.Normal[dimension] >= 0.0)
{
for (var fi = 0; fi < candidate.AdjacentFaces.Length; fi++)
{
var af = candidate.AdjacentFaces[fi];
if (af >= 0)
{
var face = FacePool[af];
for (var j = 0; j < face.AdjacentFaces.Length; j++)
{
if (face.AdjacentFaces[j] == candidateIndex)
{
face.AdjacentFaces[j] = -1;
}
}
}
}
delaunayFaces[i] = delaunayFaces[delaunayFaces.Count - 1];
delaunayFaces.Pop();
}
}
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: de8661d86f2667647b9dca59c1c97e9a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,75 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
using System.Collections.Generic;
namespace MIConvexHull
{
/// <summary>
/// Calculation and representation of Delaunay triangulation.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <seealso cref="MIConvexHull.ITriangulation{TVertex, TCell}" />
public class DelaunayTriangulation<TVertex, TCell> : ITriangulation<TVertex, TCell>
where TCell : TriangulationCell<TVertex, TCell>, new()
where TVertex : IVertex
{
/// <summary>
/// Can only be created using a factory method.
/// </summary>
private DelaunayTriangulation()
{
}
/// <summary>
/// Cells of the triangulation.
/// </summary>
/// <value>The cells.</value>
public IEnumerable<TCell> Cells { get; private set; }
/// <summary>
/// Creates the Delaunay triangulation of the input data.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>DelaunayTriangulation&lt;TVertex, TCell&gt;.</returns>
/// <exception cref="System.ArgumentNullException">data</exception>
/// <exception cref="ArgumentNullException">data</exception>
public static DelaunayTriangulation<TVertex, TCell> Create(IList<TVertex> data,
float PlaneDistanceTolerance)// = Constants.DefaultPlaneDistanceTolerance)
{
if (data == null) throw new ArgumentNullException("data");
if (data.Count == 0) return new DelaunayTriangulation<TVertex, TCell> { Cells = new TCell[0] };
var cells = ConvexHullAlgorithm.GetDelaunayTriangulation<TVertex, TCell>(data, PlaneDistanceTolerance);
return new DelaunayTriangulation<TVertex, TCell> { Cells = cells };
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 4b0e872e78d2ab8428b446cfa5c06580
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,51 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
namespace MIConvexHull
{
/// <summary>
/// Representation of the triangulation cell. Pretty much the same as ConvexFace,
/// just wanted to distinguish the two.
/// To declare your own face type, use class Face : DelaunayFace(of Vertex, of Face)
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <seealso cref="MIConvexHull.ConvexFace{TVertex, TCell}" />
public abstract class TriangulationCell<TVertex, TCell> : ConvexFace<TVertex, TCell>
where TVertex : IVertex
where TCell : ConvexFace<TVertex, TCell>
{
}
/// <summary>
/// Default triangulation cell.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
public class DefaultTriangulationCell<TVertex> : TriangulationCell<TVertex, DefaultTriangulationCell<TVertex>>
where TVertex : IVertex
{
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 808182249e0e66143b65945d9aeb37fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,93 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
namespace MIConvexHull
{
/// <summary>
/// A class representing an (undirected) edge of the Voronoi graph.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
public class VoronoiEdge<TVertex, TCell>
where TVertex : IVertex
where TCell : TriangulationCell<TVertex, TCell>
{
/// <summary>
/// Create an instance of the edge.
/// </summary>
public VoronoiEdge()
{
}
/// <summary>
/// Create an instance of the edge.
/// </summary>
/// <param name="source">The source.</param>
/// <param name="target">The target.</param>
public VoronoiEdge(TCell source, TCell target)
{
Source = source;
Target = target;
}
/// <summary>
/// Source of the edge.
/// </summary>
/// <value>The source.</value>
public TCell Source { get; internal set; }
/// <summary>
/// Target of the edge.
/// </summary>
/// <value>The target.</value>
public TCell Target { get; internal set; }
/// <summary>
/// ...
/// </summary>
/// <param name="obj">The object to compare with the current object.</param>
/// <returns><c>true</c> if the specified <see cref="System.Object" /> is equal to this instance; otherwise, <c>false</c>.</returns>
public override bool Equals(object obj)
{
var other = obj as VoronoiEdge<TVertex, TCell>;
if (other == null) return false;
if (ReferenceEquals(this, other)) return true;
return (Source == other.Source && Target == other.Target)
|| (Source == other.Target && Target == other.Source);
}
/// <summary>
/// ...
/// </summary>
/// <returns>A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.</returns>
public override int GetHashCode()
{
var hash = 23;
hash = hash*31 + Source.GetHashCode();
return hash*31 + Target.GetHashCode();
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 2f5c388c78c121a47b14c2f4ed26af38
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,203 +0,0 @@
/******************************************************************************
*
* The MIT License (MIT)
*
* MIConvexHull, Copyright (c) 2015 David Sehnal, Matthew Campbell
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*****************************************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
namespace MIConvexHull
{
/// <summary>
/// A factory class for creating a Voronoi mesh.
/// </summary>
public static class VoronoiMesh
{
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <typeparam name="TEdge">The type of the t edge.</typeparam>
/// <param name="data">The data.</param>
/// <returns>VoronoiMesh&lt;TVertex, TCell, TEdge&gt;.</returns>
public static VoronoiMesh<TVertex, TCell, TEdge> Create<TVertex, TCell, TEdge>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TCell : TriangulationCell<TVertex, TCell>, new()
where TVertex : IVertex
where TEdge : VoronoiEdge<TVertex, TCell>, new()
{
return VoronoiMesh<TVertex, TCell, TEdge>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <param name="data">The data.</param>
/// <returns>VoronoiMesh&lt;TVertex, DefaultTriangulationCell&lt;TVertex&gt;, VoronoiEdge&lt;TVertex, DefaultTriangulationCell&lt;TVertex&gt;&gt;&gt;.</returns>
public static
VoronoiMesh
<TVertex, DefaultTriangulationCell<TVertex>, VoronoiEdge<TVertex, DefaultTriangulationCell<TVertex>>>
Create<TVertex>(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
{
return
VoronoiMesh
<TVertex, DefaultTriangulationCell<TVertex>, VoronoiEdge<TVertex, DefaultTriangulationCell<TVertex>>
>.Create(data, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <param name="data">The data.</param>
/// <returns>VoronoiMesh&lt;DefaultVertex, DefaultTriangulationCell&lt;DefaultVertex&gt;, VoronoiEdge&lt;DefaultVertex, DefaultTriangulationCell&lt;DefaultVertex&gt;&gt;&gt;.</returns>
public static
VoronoiMesh
<DefaultVertex, DefaultTriangulationCell<DefaultVertex>,
VoronoiEdge<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>>
Create(IList<float[]> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
{
var points = data.Select(p => new DefaultVertex { Position = p.ToArray() }).ToList();
return
VoronoiMesh
<DefaultVertex, DefaultTriangulationCell<DefaultVertex>,
VoronoiEdge<DefaultVertex, DefaultTriangulationCell<DefaultVertex>>>.Create(points, PlaneDistanceTolerance);
}
/// <summary>
/// Create the voronoi mesh.
/// </summary>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
/// <param name="data">The data.</param>
/// <returns>VoronoiMesh&lt;TVertex, TCell, VoronoiEdge&lt;TVertex, TCell&gt;&gt;.</returns>
public static VoronoiMesh<TVertex, TCell, VoronoiEdge<TVertex, TCell>> Create<TVertex, TCell>(
IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
where TVertex : IVertex
where TCell : TriangulationCell<TVertex, TCell>, new()
{
return VoronoiMesh<TVertex, TCell, VoronoiEdge<TVertex, TCell>>.Create(data, PlaneDistanceTolerance);
}
}
/// <summary>
/// A representation of a voronoi mesh.
/// </summary>
/// <typeparam name="TEdge">The type of the t edge.</typeparam>
/// <typeparam name="TVertex">The type of the t vertex.</typeparam>
/// <typeparam name="TCell">The type of the t cell.</typeparam>
public class VoronoiMesh<TVertex, TCell, TEdge>
where TCell : TriangulationCell<TVertex, TCell>, new()
where TVertex : IVertex
where TEdge : VoronoiEdge<TVertex, TCell>, new()
{
/// <summary>
/// Can only be created using a factory method.
/// </summary>
private VoronoiMesh()
{
}
/// <summary>
/// Vertices of the diagram.
/// </summary>
/// <value>The vertices.</value>
public IEnumerable<TCell> Vertices { get; private set; }
/// <summary>
/// Edges connecting the cells.
/// The same information can be retrieved Cells' Adjacency.
/// </summary>
/// <value>The edges.</value>
public IEnumerable<TEdge> Edges { get; private set; }
/// <summary>
/// Create a Voronoi diagram of the input data.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="PlaneDistanceTolerance">The plane distance tolerance (default is 1e-10). If too high, points
/// will be missed. If too low, the algorithm may break. Only adjust if you notice problems.</param>
/// <returns>VoronoiMesh&lt;TVertex, TCell, TEdge&gt;.</returns>
/// <exception cref="System.ArgumentNullException">data</exception>
/// <exception cref="ArgumentNullException">data</exception>
public static VoronoiMesh<TVertex, TCell, TEdge> Create(IList<TVertex> data,
float PlaneDistanceTolerance = Constants.DefaultPlaneDistanceTolerance)
{
if (data == null) throw new ArgumentNullException("data");
var t = DelaunayTriangulation<TVertex, TCell>.Create(data, PlaneDistanceTolerance);
var vertices = t.Cells.ToList();
var edges = new HashSet<TEdge>(new EdgeComparer());
foreach (var f in vertices)
{
for (var i = 0; i < f.Adjacency.Length; i++)
{
var af = f.Adjacency[i];
if (af != null) edges.Add(new TEdge { Source = f, Target = af });
}
}
return new VoronoiMesh<TVertex, TCell, TEdge>
{
Vertices = vertices,
Edges = edges.ToList()
};
}
/// <summary>
/// This is probably not needed, but might make things a tiny bit faster.
/// </summary>
/// <seealso cref="System.Collections.Generic.IEqualityComparer{TEdge}" />
private class EdgeComparer : IEqualityComparer<TEdge>
{
/// <summary>
/// Equals the specified x.
/// </summary>
/// <param name="x">The x.</param>
/// <param name="y">The y.</param>
/// <returns>System.Boolean.</returns>
public bool Equals(TEdge x, TEdge y)
{
return (x.Source == y.Source && x.Target == y.Target) || (x.Source == y.Target && x.Target == y.Source);
}
/// <summary>
/// Returns a hash code for this instance.
/// </summary>
/// <param name="obj">The <see cref="T:System.Object" /> for which a hash code is to be returned.</param>
/// <returns>A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.</returns>
public int GetHashCode(TEdge obj)
{
return obj.Source.GetHashCode() ^ obj.Target.GetHashCode();
}
}
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: fbb5750f20479844eb4e504302755db9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,34 +0,0 @@
using System;
using System.Collections;
using MIConvexHull;
using CultMath;
using static CultMath.math;
public class Vertex2 : IVertex
{
public float[] Position => new[] {StoredPosition.x, StoredPosition.y};
public float2 StoredPosition;
public float x { get { return StoredPosition.x; } }
public float y { get { return StoredPosition.y; } }
public Vertex2(float x, float y)
{
StoredPosition = float2(x, y);
}
public Vertex2(float2 pos)
{
StoredPosition = pos;
}
public float2 ToFloat2() {
return StoredPosition;
}
public float Distance(Vertex2 v)
{
return length(StoredPosition-v.StoredPosition);
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 67d3ec22ea52a2642a14ff864e3017b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,16 +0,0 @@
using CultMath;
public class Vertex2<T> : Vertex2
{
public T StoredObject { get; }
public Vertex2(float x, float y, T obj) : base(x, y)
{
StoredObject = obj;
}
public Vertex2(float2 pos, T obj) : base(pos)
{
StoredObject = obj;
}
}

View file

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: e42612a273ec490380eaa67bed216a6d
timeCreated: 1615418804

View file

@ -1,30 +0,0 @@
using System.Collections;
using MIConvexHull;
using CultMath;
using static CultMath.math;
public class Vertex3 : IVertex
{
public float[] Position => new[] {StoredPosition.x, StoredPosition.y};
public float3 StoredPosition;
public Vertex3(float x, float y, float z)
{
StoredPosition = float3(x, y, z);
}
public Vertex3(float3 pos)
{
StoredPosition = pos;
}
public float3 ToFloat3() {
return StoredPosition;
}
public float Distance(Vertex3 v)
{
return length(StoredPosition-v.StoredPosition);
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 3553f231414f28347996a803a79425fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -10,7 +10,7 @@ using static CultMath.math;
public class PlayerSettings
{
[Key(0)] public string Name = "Anonymous";
[Key(2)] public bool TutorialPassed;
// Keys 1 and 2 are retired; an older player.cc that still carries them loads, and they are dropped.
[Key(3)] public Dictionary<string, string> HashedStoryFiles = new Dictionary<string, string>();
[Key(4)] public PlayerGameplaySettings GameplaySettings = new PlayerGameplaySettings();
[Key(5)] public PlayerInputSettings InputSettings = new PlayerInputSettings();

View file

@ -1,131 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using Random = CultMath.Random;
// The owner of what a new run starts with (docs/scenarios-cut.md, 2.2): how the arena zone is generated, the player
// ship, and a scenario's other entities with their stance, pilot and cargo. A plain New Game is the absent-scenario
// case of the same path. Nothing else generates, unpacks or admits a new run's player; everything it stages joins the
// arena through Zone.Admit. It holds no Unity type.
// The owner of a new run (docs/scenarios-cut.md, R.4). Every new run is a scenario: RunStart generates its galaxy,
// generates its arena (the galaxy's entrance zone) under the scenario's Ambient rule, and admits what the scenario
// stages through Zone.Admit, all of it or none of it. A scenario only declares; nothing else generates a new run's
// galaxy, generates or admits its player, or chooses its hull or faction. It holds no Unity type.
public static class RunStart
{
public sealed class Staged
{
public Ship Player;
public List<Entity> Entities; // the scenario's entities, in its order; empty with no scenario
public List<Entity> Entities; // what the scenario placed besides the player, in its order
}
// The arena is the galaxy's entrance zone, generated under the scenario's Ambient rule; with no scenario, ambient.
public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) =>
galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone(
itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario?.Ambient ?? true);
// The check entry: every failure staging this scenario would hit, found with no zone. With no galaxy there is no
// availability to filter by (LoadoutGenerator.IsAvailable), and a scenario's arena is a prelude, which offers every
// product anyway. It touches no store, so the menu can call it before it clears the saved run.
public static List<string> Check(ItemManager itemManager, Scenario scenario)
// The scenario's galaxy, at the scenario's seed or, when that is zero, the clock's.
public static Galaxy Generate(Scenario scenario, GalaxyStage stage)
{
var failures = new List<string>();
if (scenario != null) Build(itemManager, null, scenario, _ => true, failures);
return failures;
stage.SeedFrom(scenario);
return scenario.Generate(stage) ?? throw new InvalidOperationException($"Scenario {scenario.Name} generated no galaxy.");
}
// Stages a new run into its arena, all-or-nothing: null, with every failure listed and the zone unchanged, unless
// everything builds. With no scenario: a generated ship of the startingHull (any ship hull when it is empty) for
// defaultFaction, at the origin. With one: its player and entities, each at its authored position and heading with
// its cargo, each entity's stance set both ways with the player, and a Minion for each piloted one.
public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario)
{
SeedItems(itemManager, galaxy, 1);
return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone(
itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient);
}
// Runs the scenario's Stage, then admits its player and everything it placed: each entity's stance set both ways
// with the player, a pilot for each piloted one. Returns null, with every failure listed and the arena unchanged,
// unless the whole scenario staged.
public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull,
Faction defaultFaction, List<string> failures)
{
if (scenario == null)
{
var generator = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, defaultFaction, 2);
var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(startingHull) || data.Name == startingHull);
if (pack == null)
{
failures.Add($"player: no ship hull named {startingHull}");
return null;
}
var ship = (Ship) EntitySerializer.Unpack(itemManager, arena, pack);
ship.IsPlayerShip = true;
ship.Position = float3.zero;
arena.Admit(ship, piloted: false);
return new Staged { Player = ship, Entities = new List<Entity>() };
}
// Presets carry no faction, so availability is the galaxy's alone.
var availability = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, null, 2);
var built = Build(itemManager, arena, scenario, availability.IsAvailable, failures);
if (built == null) return null;
var (player, entities) = built.Value;
player.IsPlayerShip = true;
arena.Admit(player, piloted: false);
for (var i = 0; i < entities.Count; i++)
{
var hostile = scenario.Entities[i].Stance == ScenarioStance.Hostile;
entities[i].SetIff(player, hostile);
player.SetIff(entities[i], hostile);
arena.Admit(entities[i], scenario.Entities[i].Piloted);
}
return new Staged { Player = player, Entities = entities };
}
// Everything a scenario places, built and positioned but admitted nowhere; null when anything failed.
private static (Ship player, List<Entity> entities)? Build(ItemManager itemManager, Zone zone, Scenario scenario,
Predicate<FactionProductData> isAvailable, List<string> failures)
TutorialGenerationSettings tutorialGenerationSettings, List<string> failures)
{
SeedItems(itemManager, arena.Galaxy, 2);
var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings);
scenario.Stage(stage);
var reported = failures.Count;
Entity player = null;
if (scenario.Player == null) failures.Add("player: the scenario places no player");
else
{
player = Place(itemManager, zone, scenario.Player, "player", isAvailable, failures);
if (player != null && !(player is Ship)) failures.Add($"player: {player.Name} is not a ship hull");
}
var entities = new List<Entity>();
for (var i = 0; i < scenario.Entities.Count; i++)
{
var where = $"entity {i}";
var entity = Place(itemManager, zone, scenario.Entities[i], where, isAvailable, failures);
if (entity != null && scenario.Entities[i].Piloted && !(entity is Ship))
failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted");
entities.Add(entity);
}
return failures.Count > reported ? null : ((Ship) player, entities);
}
private static Entity Place(ItemManager itemManager, Zone zone, ScenarioShip placed, string where,
Predicate<FactionProductData> isAvailable, List<string> failures)
{
var loadout = itemManager.ItemData.Get(placed.Loadout);
if (loadout == null)
{
failures.Add($"{where}: preset {placed.Loadout.Key} is not in the catalog");
return null;
}
var reported = failures.Count;
var materialized = new List<string>();
var entity = Loadouts.Materialize(itemManager, zone, loadout, isAvailable, materialized);
failures.AddRange(materialized.Select(failure => $"{where} ({loadout.Name}): {failure}"));
var cargo = placed.Cargo.Select(design => Loadouts.Resolve(itemManager, design, isAvailable, $"{where} cargo", failures)).ToList();
failures.AddRange(stage.Failures);
if (!stage.PlayerPlaced)
failures.Add("player: the scenario places no player");
if (failures.Count > reported) return null;
entity.Position.xz = placed.Position;
if (lengthsq(placed.Direction) > 0) entity.Direction = normalize(placed.Direction);
var bay = entity.CargoBays.FirstOrDefault();
foreach (var build in cargo)
var player = stage.PlayerShip;
player.IsPlayerShip = true;
arena.Admit(player, piloted: false);
foreach (var placed in stage.Placed)
{
var item = build();
if (bay == null || !bay.TryStore(item))
failures.Add($"{where} cargo: no room for {itemManager.GetData(item).Name}");
var hostile = placed.Stance == ScenarioStance.Hostile;
placed.Entity.SetIff(player, hostile);
player.SetIff(placed.Entity, hostile);
arena.Admit(placed.Entity, placed.Piloted);
}
return failures.Count > reported ? null : entity;
return new Staged { Player = player, Entities = stage.Placed.Select(placed => placed.Entity).ToList() };
}
// A new run's item draws (each lot's quality, each generated fit) come from its galaxy's seed, not the process clock,
// so a fixed seed fixes the arena's gear and the player's fit. Each step draws its own stream, so whatever runs
// between them (populating the level) cannot shift it.
private static void SeedItems(ItemManager itemManager, Galaxy galaxy, uint step) =>
itemManager.Random = new Random(galaxy.Seed * 0x9E3779B1u + step);
}

View file

@ -55,7 +55,7 @@ public static class RunSave
{
// The live run as plain documents. Writes nothing.
public static (SavedGame Game, SavedZone[] Zones) Capture(CultCache cache, Galaxy galaxy, Zone currentZone,
Entity currentEntity, bool isTutorial, SavedActionBarBinding[] actionBar)
Entity currentEntity, SavedActionBarBinding[] actionBar)
{
var factions = galaxy.HomeZones.Keys.ToArray();
var game = new SavedGame
@ -74,7 +74,7 @@ public static class RunSave
CurrentZoneEntity = currentZone.Entities.IndexOf(currentEntity),
Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance),
Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit),
IsTutorial = isTutorial,
IsTutorial = galaxy.IsPrelude,
ActionBarBindings = actionBar
};
@ -155,17 +155,37 @@ public static class RunSave
}
// Removes every run record (SavedGame, SavedZone, OrbitData, BodyData, ProvenanceLedger) in one Commit.
public static void Clear(CultCache cache)
public static void Clear(CultCache cache) => Remove(cache, Records(cache));
// A new run replaces the saved one only once it has started: start runs with the saved run still in the store.
// When it returns a value, the saved run's records go. When it returns null or throws, the records it wrote go and
// the saved run stays as it was.
public static T Replace<T>(CultCache cache, Func<T> start) where T : class
{
var run = cache.AllStoredDocuments
var saved = Records(cache);
T started = null;
try
{
started = start();
}
finally
{
Remove(cache, started != null ? saved : Records(cache).Except(saved).ToArray());
}
return started;
}
private static CultRecordKey[] Records(CultCache cache) =>
cache.AllStoredDocuments
.Where(stored => IsRunRecord(stored.Descriptor.DocumentType))
.Select(stored => stored.Key)
.ToArray();
private static void Remove(CultCache cache, CultRecordKey[] records) =>
cache.Commit(batch =>
{
foreach (var key in run) batch.Remove(key);
foreach (var key in records) batch.Remove(key);
});
}
public static bool IsRunRecord(Type documentType) =>
AetheriaStores.RunTypes.Any(home => home.IsAssignableFrom(documentType));

View file

@ -1,38 +1,28 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
using CultMath;
// A scenario is an arena whose conditions are present at spawn (docs/scenarios-cut.md): the player's ship and every
// other entity already fitted and placed. Authored catalog data, read at runtime; RunStart stages it into a new run.
// It sets conditions and nothing else: once staged, its entities are ordinary run state.
[CultDocument("aetheria.scenario", "1"), MessagePackObject]
public class Scenario
// A scenario is one way to set up a new run (docs/scenarios-cut.md, R): a script, compiled with the game, that says
// which galaxy to generate and what the run starts with. New Game lists them (Scenarios). RunStart runs one: it
// generates the galaxy through GalaxyStage, the arena under Ambient, and admits what Stage placed through a
// ScenarioStage. A scenario only declares; once staged, everything it placed is ordinary run state.
public abstract class Scenario
{
[CultName, Key(0)] public string Name;
[Key(1)] public string Brief; // the conditions in a line or two, shown in the menu
[Key(2)] public uint Seed; // the arena galaxy derives from it, so every launch has one layout
[Key(3)] public bool Ambient; // false: planets, orbits and stations, but no generated ship or turret
[Key(4)] public ScenarioShip Player;
[Key(5)] public List<ScenarioEntity> Entities = new List<ScenarioEntity>();
}
public abstract string Name { get; }
// A preset placed in the arena. A ship hull stages a Ship; a turret hull an orbital entity with no orbit, which stays
// where it is put.
[MessagePackObject]
public class ScenarioShip
{
[Key(0)] public CultRecordRef<Loadout> Loadout;
[Key(1)] public float2 Position; // zone xz
[Key(2)] public float2 Direction; // zero keeps the entity's default heading
[Key(3)] public List<CultRecordRef<EquippableItemData>> Cargo = new List<CultRecordRef<EquippableItemData>>(); // first cargo bay
}
// The conditions and what to verify in them, shown under the scenario's button.
public abstract string Brief { get; }
[MessagePackObject]
public class ScenarioEntity : ScenarioShip
{
[Key(4)] public ScenarioStance Stance; // applied both ways between this entity and the player
[Key(5)] public bool Piloted; // a Minion flies it; otherwise it has no agent and stays put
// Zero draws the seed from the clock, so each launch is a new galaxy. Anything else fixes the galaxy, its
// background, its arena and every fit, each item's maker and workmanship included, so every launch has the same
// layout.
public virtual uint Seed => 0;
// False: the arena keeps its planets, orbits and stations but generates no ship or turret, so nothing wanders into
// the scenario's conditions.
public virtual bool Ambient => true;
// Which galaxy the run is in: stage.Main() or stage.Prelude().
public abstract Galaxy Generate(GalaxyStage stage);
// What the run starts with: exactly one stage.Player, and anything else the scenario places.
public abstract void Stage(ScenarioStage stage);
}
public enum ScenarioStance

View file

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The run half of a scenario's vocabulary (docs/scenarios-cut.md, R.4). A scenario's Stage builds its fits and places
// them here; nothing joins the arena until Stage returns with no failure, and then RunStart admits it all at once.
// Every verb names what it could not do instead of throwing, so one bad name in a script lists every bad name.
// Designs and hulls are named as the catalog names them. Only RunStart builds a stage.
public sealed class ScenarioStage
{
private readonly ItemManager _items;
private readonly Predicate<FactionProductData> _available;
private readonly List<string> _failures = new List<string>();
private readonly List<Placement> _placed = new List<Placement>();
private int _placements;
internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings)
{
_items = items;
Arena = arena;
StartingHull = startingHull;
TutorialGenerationSettings = tutorialGenerationSettings;
// A fit names designs, not manufacturers, so what the galaxy offers decides which product builds each one.
_available = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).IsAvailable;
}
public Galaxy Galaxy => Arena.Galaxy;
public Zone Arena { get; }
public string StartingHull { get; }
public TutorialGenerationSettings TutorialGenerationSettings { get; }
internal IReadOnlyList<string> Failures => _failures;
internal bool PlayerPlaced { get; private set; }
internal Ship PlayerShip { get; private set; }
internal IReadOnlyList<Placement> Placed => _placed;
internal sealed class Placement
{
public Entity Entity;
public ScenarioStance Stance;
public bool Piloted;
}
// A hull fitted slot by slot: each slot a design, the hull cell it goes in, and its rotation.
public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots)
{
var where = $"fit {hull}";
var hullData = Named<HullData>(hull, where);
var loadout = new Loadout { Name = hull, WeaponGroups = new int[0][] };
if (hullData != null) loadout.Hull = _items.ItemData.RefOf(hullData);
foreach (var (design, cell, rotation) in slots)
{
var data = Named<EquippableItemData>(design, where);
if (data == null) continue;
loadout.Slots.Add(new LoadoutSlot { Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation });
}
return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout) : null;
}
// A hull with nothing fitted.
public ScenarioFit Bare(string hull) => Fit(hull);
// A fit generated as the game generates its ships, for the faction when one is given, from the run's item draws,
// which RunStart seeds from the galaxy's seed. An empty hull means any ship hull.
public ScenarioFit Generated(string hull, Faction faction = null)
{
var generator = new LoadoutGenerator(ref _items.Random, _items, Galaxy, Arena.GalaxyZone, faction, 2);
var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(hull) || data.Name == hull);
if (pack != null) return new ScenarioFit(pack);
_failures.Add($"generated: no ship hull named {hull}");
return null;
}
// A loadout preset from the catalog, as capturepreset or Studio wrote it.
public ScenarioFit Preset(string name)
{
var preset = _items.ItemData.GetAll<Loadout>().FirstOrDefault(loadout => loadout.Name == name);
if (preset != null) return new ScenarioFit(preset);
_failures.Add($"preset {name}: no such preset");
return null;
}
// The player's ship. A run has exactly one, and it is a ship hull.
public Ship Player(ScenarioFit fit, float2 at, float2 facing = default)
{
if (PlayerPlaced)
{
_failures.Add("player: the scenario places a second player");
return null;
}
PlayerPlaced = true;
var entity = Build(fit, "player", at, facing);
if (entity == null) return null;
if (entity is Ship ship) return PlayerShip = ship;
_failures.Add($"player: {entity.Name} is not a ship hull");
return null;
}
// Anything else in the arena. A stance holds both ways between it and the player. A piloted ship gets the zone's
// pilot; an unpiloted entity has none and goes nowhere on its own. A turret hull stays where it is put.
public Entity Place(ScenarioFit fit, float2 at, float2 facing = default, ScenarioStance stance = ScenarioStance.Neutral, bool piloted = false)
{
var where = $"entity {_placements++}";
var entity = Build(fit, where, at, facing);
if (entity == null) return null;
if (piloted && !(entity is Ship)) _failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted");
_placed.Add(new Placement { Entity = entity, Stance = stance, Piloted = piloted });
return entity;
}
// Items in the entity's first cargo bay, one per design named.
public void Cargo(Entity entity, params string[] designs)
{
if (entity == null) return;
var where = $"cargo of {entity.Name}";
var bay = entity.CargoBays.FirstOrDefault();
foreach (var design in designs)
{
var data = Named<EquippableItemData>(design, where);
if (data == null) continue;
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), _available, where, _failures);
if (build != null && (bay == null || !bay.TryStore(build())))
_failures.Add($"{where}: no room for {design}");
}
}
private Entity Build(ScenarioFit fit, string where, float2 at, float2 facing)
{
if (fit == null) return null; // the verb that made it named why
Entity entity;
if (fit.Pack != null) entity = EntitySerializer.Unpack(_items, Arena, fit.Pack);
else
{
var failures = new List<string>();
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _available, failures);
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
if (entity == null) return null;
}
entity.Position = float3(at.x, 0, at.y);
if (lengthsq(facing) > 0) entity.Direction = normalize(facing);
return entity;
}
private T Named<T>(string name, string where) where T : EquippableItemData
{
var named = _items.ItemData.GetAll<T>().Where(data => data.Name == name).Take(2).ToArray();
if (named.Length == 1) return named[0];
_failures.Add(named.Length == 0 ? $"{where}: no design named {name}" : $"{where}: more than one design is named {name}");
return null;
}
}
// What a scenario places: a fit built from named designs or a preset, or a ship generated as the game generates them.
// Only ScenarioStage makes one.
public sealed class ScenarioFit
{
internal readonly Loadout Loadout;
internal readonly EntityPack Pack;
internal ScenarioFit(Loadout loadout) => Loadout = loadout;
internal ScenarioFit(EntityPack pack) => Pack = pack;
}

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: ee065f025d2f5374db2ceaa973b822d4
guid: d8cd00dc23604dfb91e128e1a9a4a3d5
MonoImporter:
externalObjects: {}
serializedVersion: 2

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 4a109a0b0bb6e8f40935ca2ec55e5bd7
guid: 35866d82219241ae9fdb0030039afbed
folderAsset: yes
DefaultImporter:
externalObjects: {}

View file

@ -0,0 +1,46 @@
using static CultMath.math;
using static ItemRotation;
public sealed class Arcs : Scenario
{
public override string Name => "Arcs";
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " +
"hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round.";
public override uint Seed => 103;
public override bool Ambient => false;
// Where the targets sit: the player is at the origin facing +z, and every mount fires forward.
public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 0);
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var fighter = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), None),
("GT 3K", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
var turret = stage.Fit("Turret",
("ClearPath", int2(1, 2), None),
("ClearPath", int2(5, 2), None),
("Skiron", int2(0, 2), CounterClockwise),
("Skiron", int2(7, 2), Clockwise),
("Core Power", int2(3, 3), None),
("Turret Control Module", int2(3, 5), None),
("not if i see you first", int2(4, 5), None),
("Fire Control Array", int2(3, 6), None));
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
stage.Place(stage.Bare("LonginusX"), Bow);
stage.Place(stage.Bare("LonginusX"), Stern);
stage.Place(turret, Turret, stance: ScenarioStance.Hostile);
}
}

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: adf902e96e0c51049b65bc002d6ef4ca
guid: 724a07b881f34e8c9b32b3f2f338ac78
MonoImporter:
externalObjects: {}
serializedVersion: 2

View file

@ -0,0 +1,15 @@
using float2 = CultMath.float2;
public sealed class DjinniShakedown : Scenario
{
public override string Name => "Djinni Shakedown";
public override string Brief => "A fitted Djinni in empty space. Verify: its thrusters move and turn it; with the hull " +
"offline and thrusters firing, its speed does not run away.";
public override uint Seed => 101;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage) =>
stage.Player(stage.Generated("Djinni"), float2.zero);
}

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 447289e858e19c14abbe91dfbd0e3089
guid: 1afb559630c241ed813ddd3c444c618b
MonoImporter:
externalObjects: {}
serializedVersion: 2

View file

@ -0,0 +1,35 @@
using static CultMath.math;
using static ItemRotation;
public sealed class Duel : Scenario
{
public override string Name => "Duel";
public override string Brief => "One piloted, hostile LonginusX with slow GT 3K missiles and a Spectra in its hold. Verify: rolled " +
"hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand.";
public override uint Seed => 104;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var fighter = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), None),
("GT 3K", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("PotaT+-", int2(1, 5), None),
("Fire Control Array", int2(2, 5), None),
("Store-All Plus", int2(2, 8), None));
stage.Player(fighter, float2(0, 0));
var rival = stage.Place(fighter, float2(0, 900), facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true);
stage.Cargo(rival, "Spectra");
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f6ba813d4a9447c89e1bbb6997b7ac35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,33 @@
using static CultMath.math;
using static ItemRotation;
public sealed class LauncherAngles : Scenario
{
public override string Name => "Launcher Angles";
public override string Brief => "A GT 3K, a pswarm and two FastBlasts; a hostile LonginusX broadside on, a bare Longinus beside it. " +
"Verify: hits pulse only the facing edge; a pswarm round whose target dies fades unexploded.";
public override uint Seed => 105;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var launcher = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), None),
("pswarm", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
stage.Player(launcher, float2(0, 0));
stage.Place(stage.Bare("LonginusX"), float2(0, 900), facing: float2(1, 0), stance: ScenarioStance.Hostile);
stage.Place(stage.Bare("Longinus"), float2(300, 700));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 58cfaf062ddf458c99c12f3652243b45
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,14 @@
using float2 = CultMath.float2;
public sealed class LongHaul : Scenario
{
public override string Name => "Long Haul";
public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " +
"warp, quit and Continue keep items, tier and brand; death disables Continue.";
public override uint Seed => 106;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage) =>
stage.Player(stage.Generated(stage.StartingHull), float2.zero);
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3a7911611f9845709e9ae5ba34d91dfb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,13 @@
using float2 = CultMath.float2;
// The full galaxy: factions drawn from the catalog, bosses on the road to the exit, entered in the starting hull.
public sealed class MainGalaxy : Scenario
{
public override string Name => "Main Galaxy";
public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit.";
public override Galaxy Generate(GalaxyStage stage) => stage.Main();
public override void Stage(ScenarioStage stage) =>
stage.Player(stage.Generated(stage.StartingHull), float2.zero);
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c7f6bdb3ed7b4b1aa137569c7e79bccd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,11 @@
// Every way to set up a new run, in the order New Game lists them. Modes are the game; Tests are arenas built to check
// one system each, listed only in editor and development builds.
public static class Scenarios
{
public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() };
public static readonly Scenario[] Tests =
{
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul()
};
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12f60a9ad25c4aa0a9b8b098d385ea0e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,29 @@
using static CultMath.math;
using static ItemRotation;
public sealed class StarvedReactor : Scenario
{
public override string Name => "Starved Reactor";
public override string Brief => "Two Spectras on a MoveOnPro, which can fall to a quarter of Core Power's charge, and a bare hull " +
"to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter.";
public override uint Seed => 102;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var starved = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("MoveOnPro", int2(2, 1), None),
("Spectra", int2(1, 8), None),
("Spectra", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
stage.Player(starved, float2(0, 0));
stage.Place(stage.Bare("LonginusX"), float2(0, 400));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91a81ac4784c4066969cac11ac1937b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,16 @@
using float2 = CultMath.float2;
// New Game as it has always been: a prelude galaxy, entered in the starting hull of the protagonist faction.
public sealed class TutorialGalaxy : Scenario
{
public override string Name => "Tutorial Galaxy";
public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction.";
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var protagonist = stage.Galaxy.ResolveFaction(stage.TutorialGenerationSettings.ProtagonistFaction);
stage.Player(stage.Generated(stage.StartingHull, protagonist), float2.zero);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 94d688b37c5044dd9b67865c706d8918
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -206,7 +206,7 @@ public static class ZoneGenerator
var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxyZone]);
var nearestFactionHomeZone = galaxy.HomeZones[nearestFaction];
var factionPresence = nearestFaction.InfluenceDistance - nearestFactionHomeZone.Distance[galaxyZone] + 1;
var factionPresence = galaxy.InfluenceOf(nearestFaction) - nearestFactionHomeZone.Distance[galaxyZone] + 1;
var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray();
var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length;

View file

@ -5,22 +5,18 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using static CultMath.math;
using float2 = CultMath.float2;
using Random = UnityEngine.Random;
public class MainMenu : MonoBehaviour
{
public VolumeCloudRenderer CloudRenderer;
public GameSettings Settings;
public ConfirmationDialog Dialog;
public bool InGame;
public Prototype PanelPrototype;
public float FadeTime = .5f;
public float FadeDistance = 512;
@ -95,9 +91,10 @@ public class MainMenu : MonoBehaviour
{
_nextMenu.panel.Clear();
_nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus");
if (!InGame)
if (ActionGameManager.CurrentGalaxy == null)
{
// A run exists only as the run store's SavedGame global.
// No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as
// the run store's SavedGame global.
var cache = ActionGameManager.CultCache;
if (ActionGameManager.ModExclusions.Length > 0 && !_exclusionsShown)
{
@ -111,7 +108,6 @@ public class MainMenu : MonoBehaviour
{
try { RunSave.RequireDesigns(cache, saved); }
catch (InvalidOperationException error) { Refuse("Cannot continue this run", error.Message); return; }
ActionGameManager.IsTutorial = saved.IsTutorial;
ActionGameManager.CurrentGalaxy = new Galaxy(cache, saved, Debug.Log);
EnterGame();
});
@ -119,69 +115,8 @@ public class MainMenu : MonoBehaviour
_nextMenu.panel.AddButton("New Game",
() =>
{
RunSave.Clear(ActionGameManager.CultCache);
var generatorState = "Loading Database Contents";
Action<string> setState = s => generatorState = s;
ShowScenarios();
Fade(true);
_nextMenu.panel.gameObject.SetActive(false);
Dialog.Clear();
Dialog.Title.text = "Generating Galaxy";
Dialog.AddProperty(() => generatorState);
Dialog.Show();
if (ActionGameManager.PlayerSettings.TutorialPassed)
{
var backgroundSettings = MessagePackSerializer.Deserialize<SectorBackgroundSettings>(
MessagePackSerializer.Serialize(Settings.SectorBackgroundSettings));
backgroundSettings.NoisePosition = Random.value * 1000;
ActionGameManager.IsTutorial = false;
Task.Run(() =>
{
var sector = new Galaxy(
Settings.SectorGenerationSettings,
backgroundSettings,
Settings.NameGeneratorSettings,
ActionGameManager.CultCache,
Debug.Log,
setState);
Observable.NextFrame().Subscribe(_ =>
{
ActionGameManager.CurrentGalaxy = sector;
EnterGame();
});
}).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
}
else
{
var backgroundSettings = MessagePackSerializer.Deserialize<SectorBackgroundSettings>(
MessagePackSerializer.Serialize(Settings.TutorialBackgroundSettings));
int iteration = 1;
do
{
backgroundSettings.NoisePosition = Random.value * 1000;
setState($"Finding Galaxy Position: iteration {iteration++}");
} while (backgroundSettings.CloudDensity(float2(0.5f)) < .5f);
ActionGameManager.IsTutorial = true;
Task.Run(() =>
{
var sector = new Galaxy(
Settings.TutorialGenerationSettings,
backgroundSettings,
Settings.NameGeneratorSettings,
ActionGameManager.CultCache,
ActionGameManager.PlayerSettings,
ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"),
Debug.Log,
setState);
Observable.NextFrame().Subscribe(_ =>
{
ActionGameManager.CurrentGalaxy = sector;
EnterGame();
});
}).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
}
});
_nextMenu.panel.AddButton("Settings",
() =>
@ -192,6 +127,77 @@ public class MainMenu : MonoBehaviour
_nextMenu.panel.AddButton("Quit", Application.Quit);
}
// New Game: every way to set up a run, each with its brief. The test arenas are listed only in editor and
// development builds.
private void ShowScenarios()
{
_nextMenu.panel.Clear();
_nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios");
AddScenarios(Scenarios.Modes);
if (Debug.isDebugBuild)
{
_nextMenu.panel.AddSection("test arenas");
AddScenarios(Scenarios.Tests);
}
_nextMenu.panel.AddButton("Back",
() =>
{
ShowMain();
Fade(false);
});
}
private void AddScenarios(IEnumerable<Scenario> scenarios)
{
foreach (var scenario in scenarios)
{
_nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario));
_nextMenu.panel.AddProperty(() => scenario.Brief);
}
}
// The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where
// StartGame has RunStart stage it and only then replaces the saved run. A galaxy that cannot generate is refused
// here, with the saved run untouched.
private void Launch(Scenario scenario)
{
var generatorState = "Loading Database Contents";
Fade(true);
_nextMenu.panel.gameObject.SetActive(false);
Dialog.Clear();
Dialog.Title.text = "Generating Galaxy";
Dialog.AddProperty(() => generatorState);
Dialog.Show();
var stage = new GalaxyStage(
Settings.SectorGenerationSettings,
Settings.SectorBackgroundSettings,
Settings.TutorialGenerationSettings,
Settings.TutorialBackgroundSettings,
Settings.NameGeneratorSettings,
ActionGameManager.CultCache,
ActionGameManager.PlayerSettings,
ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"),
Debug.Log,
state => generatorState = state);
Task.Run(() => RunStart.Generate(scenario, stage)).ContinueWith(generation =>
Observable.NextFrame().Subscribe(_ =>
{
if (generation.IsFaulted)
{
Debug.LogException(generation.Exception);
ShowMain();
Fade(false);
Refuse($"{scenario.Name} could not generate its galaxy", generation.Exception.GetBaseException().Message);
return;
}
ActionGameManager.CurrentGalaxy = generation.Result;
ActionGameManager.PendingScenario = scenario;
EnterGame();
}));
}
// The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them,
// and a preload that could not run at all stops here with its reason rather than in the first zone.
private void EnterGame() => StartCoroutine(EnterGameWhenModShipsAreReady());
@ -208,7 +214,7 @@ public class MainMenu : MonoBehaviour
SceneManager.LoadScene("ARPG");
}
private void Refuse(string title, string reason)
public void Refuse(string title, string reason)
{
Debug.LogError($"{title}: {reason}");
Dialog.Clear();

View file

@ -229,7 +229,7 @@ public sealed partial class RunStartTests
var low = Heater("P30 Low Heater", 260, 10);
var high = Heater("P30 High Heater", 320, 7);
var hull = Hull("Djinni");
var zone = Arena(new Scenario { Ambient = false });
var zone = Arena(false);
List<float> Bills(params GearData[] order)
{

View file

@ -282,25 +282,14 @@ public sealed class RestoredHullsTests
var background = TutorialBackgroundSettings();
var names = TutorialNameSettings();
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
// Galaxy zone-graph generation has a pre-existing, seed-dependent flakiness of its own (a disconnected
// zone graph throws inside the Galaxy constructor on roughly 1 seed in 15, confirmed unrelated to this
// cut: it reproduces identically against the pre-fix-batch catalog). That failure belongs to Galaxy.cs,
// not to ZoneGenerator's entrance-station override this test is pinning, so it is skipped rather than
// asserted on; every seed whose Galaxy actually builds must still get a docked entrance station.
var succeeded = 0;
var sawGenuineLagrangeOrbit = false;
for (uint seed = 1; seed <= 25; seed++)
{
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc");
try
{
// Galaxy mutates Faction.InfluenceDistance in place; a fresh catalog open per seed keeps runs independent.
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
Galaxy galaxy;
try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); }
catch (Exception) { continue; }
succeeded++;
var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true);
@ -317,8 +306,6 @@ public sealed class RestoredHullsTests
if (File.Exists(scratchRun)) File.Delete(scratchRun);
}
}
Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result.");
Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit.");
}
@ -336,8 +323,6 @@ public sealed class RestoredHullsTests
var background = TutorialBackgroundSettings();
var names = TutorialNameSettings();
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
var succeeded = 0;
var sawGenuineCandidate = false;
for (uint seed = 1; seed <= 25; seed++)
{
@ -345,10 +330,7 @@ public sealed class RestoredHullsTests
try
{
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
Galaxy galaxy;
try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); }
catch (Exception) { continue; }
succeeded++;
var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true);
@ -369,8 +351,6 @@ public sealed class RestoredHullsTests
if (File.Exists(scratchRun)) File.Delete(scratchRun);
}
}
Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result.");
Assert.True(sawGenuineCandidate, "none of the 25 seeds had a genuine Lagrange candidate at all; too few runs to trust this result.");
}
@ -477,9 +457,7 @@ public sealed class RestoredHullsTests
try
{
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
Galaxy galaxy;
try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); }
catch (Exception) { continue; }
var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true);

View file

@ -91,7 +91,7 @@ public sealed class RunSaveTests : IDisposable
var galaxy = new Galaxy(cache, cache.GetGlobal<SavedGame>(), _ => { });
var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { });
galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy);
var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, false, new SavedActionBarBinding[0]);
var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0]);
RunSave.Commit(cache, game, zones, RunSave.Lots(cache));
}
@ -126,6 +126,62 @@ public sealed class RunSaveTests : IDisposable
Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray());
}
// A new run's start writes its arena's orbits and bodies while the saved run is still stored. A start that fails,
// by returning nothing or by throwing, takes away only what it wrote; one that starts takes away the saved run.
[Fact]
public void ANewRunReplacesTheSavedRunOnlyOnceItHasStarted()
{
using (var cache = Open())
RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0), new ProvenanceLedger());
string[] saved;
using (var cache = Open())
saved = RunKeys(cache);
Assert.Equal(8, saved.Length);
using (var cache = Open())
{
Assert.Null(RunSave.Replace<ZonePack>(cache, () =>
{
WriteAnOrbitTheSavedRunLacks(cache);
return null;
}));
Assert.Equal(saved, RunKeys(cache));
}
using (var cache = Open())
{
Assert.Equal(saved, RunKeys(cache));
Assert.NotNull(cache.GetGlobal<SavedGame>());
}
using (var cache = Open())
{
Assert.Throws<InvalidOperationException>(() => RunSave.Replace<ZonePack>(cache, () =>
{
WriteAnOrbitTheSavedRunLacks(cache);
throw new InvalidOperationException("the arena would not generate");
}));
Assert.Equal(saved, RunKeys(cache));
}
using (var cache = Open())
Assert.Equal(saved, RunKeys(cache));
ZonePack started;
using (var cache = Open())
started = RunSave.Replace(cache, () => StageZoneContents(cache));
using (var cache = Open())
{
Assert.Null(cache.GetGlobal<SavedGame>());
Assert.Equal(started.Orbits.Select(orbit => orbit.Key.Value).Concat(started.Planets.Select(body => body.Key.Value))
.OrderBy(key => key, StringComparer.Ordinal), RunKeys(cache));
}
}
private static string[] RunKeys(CultCache cache) => cache.AllStoredDocuments
.Where(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType))
.Select(stored => stored.Key.Value)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
// Lot 1 sits on the root ship's hull; lot 3 (Produced from facility 4, input 5) sits in a docked child's
// docking-bay contents; lots 2 and 6 are minted but referenced by nothing packed. Only the reachable closure
// {1, 3, 4, 5} survives into the written copy; the live ledger keeps every lot.
@ -532,6 +588,9 @@ public sealed class RunSaveTests : IDisposable
QualityPriceModifier = new ExponentialLerp()
};
// What a start that fails leaves behind: a record of its own, which the saved run does not hold.
private static void WriteAnOrbitTheSavedRunLacks(CultCache cache) => cache.Upsert(new OrbitData { Distance = 99 });
// Two orbits and a body upserted into the run store and left staged, as zone generation leaves them.
private static ZonePack StageZoneContents(CultCache cache)
{

View file

@ -16,10 +16,9 @@ using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Scenarios Cut 1 (docs/scenarios-cut.md, 2.2): RunStart owns what a new run starts with, and Zone.Admit is the one
// admission into a zone. Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed,
// built from the shipped catalog and the authored settings (Assets/Resources/Settings.asset). The presets and
// scenarios live in a scratch copy of the catalog, because the shipped one holds none.
// RunStart owns what a new run starts with, and Zone.Admit is the one admission into a zone (docs/scenarios-cut.md,
// 2.2 and R). Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, built from a
// scratch copy of the shipped catalog and the authored settings (Assets/Resources/Settings.asset).
public sealed partial class RunStartTests : IDisposable
{
private const uint GalaxySeed = 1;
@ -32,6 +31,7 @@ public sealed partial class RunStartTests : IDisposable
private readonly PlanetSettings _planetSettings;
private readonly string _startingHull;
private readonly Faction _protagonist;
private readonly TutorialGenerationSettings _tutorialSettings;
public RunStartTests()
{
@ -49,7 +49,7 @@ public sealed partial class RunStartTests : IDisposable
var authored = AuthoredSettings.Load(repo);
_zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
_planetSettings = authored.Read<PlanetSettings>("PlanetSettings");
var tutorial = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
var tutorial = _tutorialSettings = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
_galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), _cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed);
@ -80,8 +80,9 @@ public sealed partial class RunStartTests : IDisposable
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
}
private Zone Arena(Scenario scenario) =>
new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, scenario), _galaxy.Entrance, _galaxy);
// The fixture galaxy's arena, with or without its generated ships and turrets.
private Zone Arena(bool ambient) =>
new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(ambient, _ => { })), _galaxy.Entrance, _galaxy);
private HullData Hull(string name) => _cache.GetAll<HullData>().Single(hull => hull.Name == name);
@ -92,314 +93,12 @@ public sealed partial class RunStartTests : IDisposable
.OrderBy(hull => hull.Name, StringComparer.Ordinal)
.First();
private CultRecordRef<Loadout> Preset(Loadout loadout)
{
var existing = _cache.GetAll<Loadout>().FirstOrDefault(other => other.Name == loadout.Name);
if (existing != null) return _cache.RefOf(existing);
_cache.Upsert(loadout);
return _cache.RefOf(loadout);
}
private CultRecordRef<Loadout> Bare(HullData hull) =>
Preset(new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] });
// A fully fitted preset: a generated ship of this hull, captured.
private Loadout Fitted(string hullName)
{
var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2);
var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == hullName));
Assert.NotEmpty(ship.CargoBays);
return Loadouts.Capture(_items, ship, "fitted " + hullName);
}
private static ScenarioShip At(CultRecordRef<Loadout> preset, float x, float z) =>
new ScenarioShip { Loadout = preset, Position = float2(x, z) };
private static ScenarioEntity Placed(CultRecordRef<Loadout> preset, float x, float z, ScenarioStance stance, bool piloted) =>
new ScenarioEntity { Loadout = preset, Position = float2(x, z), Stance = stance, Piloted = piloted };
private Scenario Quiet(ScenarioShip player, params ScenarioEntity[] entities) =>
new Scenario { Name = "test", Seed = GalaxySeed, Ambient = false, Player = player, Entities = entities.ToList() };
private RunStart.Staged Stage(Zone zone, Scenario scenario)
{
var failures = new List<string>();
var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures);
Assert.Empty(failures);
Assert.NotNull(staged);
return staged;
}
// A loadout with only its hull, in memory.
private Loadout BareLoadout(HullData hull) =>
new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] };
private static int AgentsOf(Zone zone, Entity entity) => zone.Agents.Count(agent => agent.Ship == entity);
// New Game is the absent-scenario case: the authored starting hull, flagged as the player's, admitted at the
// origin, for the protagonist faction, with no agent. The hull name is an input: staging honours another one too.
[Fact]
public void WithNoScenarioThePlayerIsTheStartingHullAtTheOrigin()
{
Assert.Equal("LonginusX", _startingHull);
foreach (var hullName in new[] { _startingHull, "Djinni" })
{
var zone = Arena(null);
var failures = new List<string>();
var staged = RunStart.Stage(_items, zone, null, hullName, _protagonist, failures);
Assert.Empty(failures);
var player = staged.Player;
Assert.Equal(hullName, player.HullData.Name);
Assert.True(player.IsPlayerShip);
SafeAssert.In(zone, player, "the player is admitted");
Assert.True(zone == player.Zone, "the player's zone is the arena");
Assert.Equal(float3.zero, player.Position);
Assert.Same(_protagonist, player.Faction);
Assert.Equal(0, AgentsOf(zone, player));
Assert.Empty(staged.Entities);
}
}
// A scenario's player is its preset, fitted slot for slot, at the authored position and heading, carrying its cargo.
[Fact]
public void AScenarioPlayerIsItsPresetAtItsPlaceWithItsCargo()
{
var fitted = Fitted("Djinni");
var preset = Preset(fitted);
// Two one-cell designs a manufacturer makes, by name, so the cargo is ordinary branded stock
var cargo = _cache.GetAll<GearData>()
.Where(design => design.Shape.Coordinates.Length == 1)
.Where(design => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key)))
.OrderBy(design => design.Name, StringComparer.Ordinal)
.Take(2)
.Select(design => new CultRecordRef<EquippableItemData>(_cache.RefOf(design).Key))
.ToList();
Assert.Equal(2, cargo.Count);
var zone = Arena(null);
var scenario = Quiet(new ScenarioShip { Loadout = preset, Position = float2(120, -40), Direction = float2(3, 4), Cargo = cargo });
var player = Stage(zone, scenario).Player;
Assert.Equal("Djinni", player.HullData.Name);
Assert.True(player.IsPlayerShip);
SafeAssert.In(zone, player, "the player is admitted");
Assert.Equal(float3(120, 0, -40), player.Position);
Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}");
Assert.Equal(
fitted.Slots.Select(slot => (slot.Position, slot.Design.Key)).OrderBy(s => s.ToString()),
player.Equipment.Concat<EquippedItem>(player.CargoBays).Concat(player.DockingBays)
.Where(item => item != player.EquippedHull)
.Select(item => (item.Position, _cache.RefOf(_items.GetData(item.EquippableItem)).Key))
.OrderBy(s => s.ToString()));
Assert.Equal(cargo.Select(design => design.Key.Value).OrderBy(k => k),
player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k));
}
// A piloted entity gets exactly one agent; an unpiloted one gets none, and nothing steers it. (It is not frozen:
// the zone's gravity still moves it, as it moves any ship, so the rule is observed on its steering inputs.)
[Fact]
public void APilotedEntityHasOneAgentAndAnUnpilotedOneNoneAndStaysPut()
{
var zone = Arena(null);
var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(Preset(Fitted("LonginusX")), 300, 0, ScenarioStance.Neutral, piloted: true),
Placed(Bare(Hull("LonginusX")), -300, 0, ScenarioStance.Neutral, piloted: false)));
var piloted = staged.Entities[0];
var unpiloted = staged.Entities[1];
Assert.Equal(1, AgentsOf(zone, piloted));
Assert.Equal(0, AgentsOf(zone, unpiloted));
Assert.Equal(0, AgentsOf(zone, staged.Player));
SafeAssert.In(zone, piloted, "the piloted entity is admitted");
SafeAssert.In(zone, unpiloted, "the unpiloted entity is admitted");
var look = unpiloted.LookDirection;
for (var tick = 0; tick < 50; tick++)
{
zone.Update(.1f);
Assert.Equal(float2.zero, ((Ship) unpiloted).MovementDirection);
Assert.Equal(look, unpiloted.LookDirection);
}
Assert.Equal(0, AgentsOf(zone, unpiloted));
}
// A stance holds both ways between the entity and the player.
[Fact]
public void AStanceIsSetBothWays()
{
var longinus = Bare(Hull("LonginusX"));
var staged = Stage(Arena(null), Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false),
Placed(longinus, -300, 0, ScenarioStance.Neutral, piloted: false)));
var player = staged.Player;
var hostile = staged.Entities[0];
var neutral = staged.Entities[1];
Assert.True(player.IsHostileTo(hostile));
Assert.True(hostile.IsHostileTo(player));
Assert.False(player.IsHostileTo(neutral));
Assert.False(neutral.IsHostileTo(player));
// Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees.
Assert.True(player.EntityHostility[hostile]);
Assert.True(hostile.EntityHostility[player]);
Assert.False(player.EntityHostility[neutral]);
Assert.False(neutral.EntityHostility[player]);
}
// A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity
// gone for good: once an entity is destroyed, the player holds no stance toward it.
[Fact]
public void AStanceSurvivesDockingButNotDestruction()
{
var zone = Arena(null);
var longinus = Bare(Hull("LonginusX"));
var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false),
Placed(longinus, -300, 0, ScenarioStance.Hostile, piloted: false)));
var player = staged.Player;
var hostile = staged.Entities[0];
var doomed = staged.Entities[1];
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
Assert.NotNull(station.TryDock(player));
SafeAssert.NotIn(zone, player, "a docked ship is out of the zone");
Assert.True(station.TryUndock(player));
Assert.True(player.IsHostileTo(hostile), "the player's stance was lost over docking");
Assert.True(hostile.IsHostileTo(player), "the entity's stance was lost over docking");
Assert.True(player.EntityHostility[hostile], "the player's live hostility was lost over docking");
Assert.True(hostile.EntityHostility[player], "the entity's live hostility was lost over docking");
// Presets carry no faction, so without its stance the derived rule is not hostile
Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless");
Assert.True(player.IsHostileTo(doomed));
doomed.Hull.Durability = 0;
doomed.HullDamage.OnNext(1);
SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone");
Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it");
}
// Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent.
[Fact]
public void UndockingAdmitsWithNoAgent()
{
var zone = Arena(null);
var player = Stage(zone, null).Player;
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var agents = zone.Agents.Count;
Assert.NotNull(station.TryDock(player));
SafeAssert.NotIn(zone, player, "a docked ship is out of the zone");
Assert.True(station.TryUndock(player));
SafeAssert.In(zone, player, "an undocked ship is back in the zone");
Assert.Equal(agents, zone.Agents.Count);
Assert.Equal(0, AgentsOf(zone, player));
Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active");
}
// Ambient: false keeps the stations and drops every generated ship and turret; ambient, and a plain New Game, keep
// them. The arena is the galaxy's entrance pack. The entrance holds a story station, placed as the narrative would
// place one, so a story station's own turrets are dropped too.
[Fact]
public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations()
{
(int ships, int turrets, int stations) Census(ZonePack pack) => (
pack.Entities.OfType<ShipPack>().Count(),
pack.Entities.OfType<OrbitalEntityPack>().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret),
pack.Entities.OfType<OrbitalEntityPack>().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station));
_galaxy.Entrance.Locations.Add(new LocationStory
{
Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2
});
var plain = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null));
var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = true }));
var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = false });
var quiet = Census(quietPack);
Assert.Same(quietPack, _galaxy.Entrance.PackedContents);
Assert.True(plain.ships > 0 && plain.turrets > 0, $"a plain arena generated {plain}");
Assert.True(ambient.ships > 0 && ambient.turrets > 0, $"an ambient arena generated {ambient}");
Assert.Equal(0, quiet.ships);
Assert.Equal(0, quiet.turrets);
Assert.True(quiet.stations > 0);
Assert.Equal(ambient.stations, quiet.stations);
Assert.Contains(quietPack.Entities.OfType<OrbitalEntityPack>(), station => station.Story == 0);
}
// A turret preset stages as an orbital entity with no orbit, which stays where it is put.
[Fact]
public void ATurretPresetStagesAsAStationaryOrbitalEntity()
{
var zone = Arena(null);
var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(Bare(TurretHull()), 200, 50, ScenarioStance.Hostile, piloted: false)));
var turret = Assert.IsType<OrbitalEntity>(staged.Entities[0]);
Assert.False(turret.OrbitData.IsSet());
SafeAssert.In(zone, turret, "the turret is admitted");
for (var tick = 0; tick < 20; tick++) zone.Update(.1f);
Assert.Equal(float2(200, 50), turret.Position.xz);
}
// A station preset is refused, and so is a turret as the player or a piloted turret: each is a staging failure
// naming the hull, with nothing admitted.
[Fact]
public void AStationPresetOrAMisplacedTurretIsAStagingFailure()
{
var station = _cache.GetAll<HullData>().First(hull => hull.HullType == HullType.Station);
var turret = TurretHull();
var djinni = Bare(Hull("Djinni"));
foreach (var (scenario, expected) in new[]
{
(Quiet(At(djinni, 0, 0), Placed(Bare(station), 200, 0, ScenarioStance.Neutral, false)), $"{station.Name} is a station hull"),
(Quiet(At(Bare(turret), 0, 0)), $"player: {turret.Name} is not a ship hull"),
(Quiet(At(djinni, 0, 0), Placed(Bare(turret), 200, 0, ScenarioStance.Neutral, true)), "cannot be piloted"),
})
{
var zone = Arena(null);
var entities = zone.Entities.ToList();
var agents = zone.Agents.ToList();
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures));
Assert.Contains(failures, failure => failure.Contains(expected));
SafeAssert.Unchanged(zone, entities, agents);
Assert.Contains(RunStart.Check(_items, scenario), failure => failure.Contains(expected));
}
}
// A scenario naming a missing design or preset fails with each named; the check entry finds the same failures with
// no zone and writes no document, and staging admits nothing, not even the parts that built: a player and a piloted
// entity that build cleanly stay out when a later entity fails.
[Fact]
public void AMissingDesignFailsNamedAndChangesNothing()
{
var djinni = Bare(Hull("Djinni"));
var scenario = Quiet(
new ScenarioShip { Loadout = djinni, Cargo = { new CultRecordRef<EquippableItemData>(new CultRecordKey("absent-design")) } },
Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true),
Placed(new CultRecordRef<Loadout>(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false));
var zone = Arena(null);
var entities = zone.Entities.ToList();
var agents = zone.Agents.ToList();
var documents = _cache.AllStoredDocuments.Count();
var checkFailures = RunStart.Check(_items, scenario);
Assert.Equal(2, checkFailures.Count);
Assert.Contains("absent-design", checkFailures[0]);
Assert.Contains("absent-preset", checkFailures[1]);
Assert.Equal(documents, _cache.AllStoredDocuments.Count());
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures));
Assert.Equal(checkFailures, failures);
SafeAssert.Unchanged(zone, entities, agents);
var lateFailure = Quiet(At(djinni, 0, 0),
Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true),
Placed(new CultRecordRef<Loadout>(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false));
failures.Clear();
Assert.Null(RunStart.Stage(_items, zone, lateFailure, _startingHull, _protagonist, failures));
Assert.Contains("absent-preset", Assert.Single(failures));
SafeAssert.Unchanged(zone, entities, agents);
Assert.Empty(RunStart.Check(_items, null));
}
// A heater is gear whose thermostat runs its heat only below a target: a low-pass thermotoggle, then heat.
private static bool IsHeater(EquippedItem item) =>
item.Behaviors.OfType<Thermotoggle>().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType<Heat>().Any();
@ -408,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void EveryGeneratedStationCarriesOneHeaterAndAReactor()
{
var zone = Arena(null);
var zone = Arena(true);
var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
for (var i = 0; i < 8; i++)
{
@ -422,7 +121,7 @@ public sealed partial class RunStartTests : IDisposable
// A main-sector galaxy (not the prelude, which offers every product): each station's gear must come from
// manufacturers its faction can reach, the galaxy's factions its allegiance names.
private Galaxy MainGalaxy()
private Galaxy MainSectorGalaxy()
{
var authored = AuthoredSettings.Load(FindRepoRoot());
return new Galaxy(authored.Read<SectorGenerationSettings>("SectorGenerationSettings"),
@ -443,9 +142,9 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor()
{
var galaxy = MainGalaxy();
var galaxy = MainSectorGalaxy();
Assert.False(galaxy.IsPrelude);
var zone = Arena(null);
var zone = Arena(true);
foreach (var faction in galaxy.Factions)
{
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f);
@ -466,7 +165,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers()
{
var galaxy = MainGalaxy();
var galaxy = MainSectorGalaxy();
var products = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
foreach (var faction in galaxy.Factions)
{
@ -483,7 +182,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing()
{
var galaxy = MainGalaxy();
var galaxy = MainSectorGalaxy();
Faction Named(string shortName) => _cache.GetAll<Faction>().Single(f => f.ShortName == shortName);
var coop = new Faction { Name = "Test Cooperative", ShortName = "Coop" };
_cache.Upsert(coop);
@ -497,7 +196,7 @@ public sealed partial class RunStartTests : IDisposable
var own = new FactionProductData { Name = "Cooperative Capacitor", Design = products.First().Design, Manufacturer = _cache.RefOf(coop) };
_cache.Upsert(own);
Assert.True(generator.IsAvailable(own), "a faction reaches its own manufacturer's gear");
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
Entity station = null;
for (var i = 0; i < 8; i++)
{
@ -525,7 +224,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AnIdleStationStaysAboveFreezing()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var coldest = float.MaxValue;
@ -542,7 +241,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AStationHeaterHeatsBelowItsTargetAndNotAbove()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
@ -565,7 +264,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AHeaterAboveItsTargetDrawsNoPower()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
@ -616,7 +315,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void NoOrbitStandsStillAgainstItsParent()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
Assert.True(zone.AsteroidBelts.Count > 0, "the arena has an asteroid belt to race against");
float2 Relative(Orbit orbit, bool previous) =>
(previous ? orbit.PreviousPosition : orbit.Position) -
@ -638,7 +337,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void EveryAsteroidInABeltIsPlacedForTheSameTime()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var (key, belt) = zone.AsteroidBelts.First();
var asteroids = ((AsteroidBeltData) zone.Planets[key]).Asteroids;
const float dt = .02f;
@ -668,7 +367,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void MiningWhileTheBeltUpdatesNeverRacesIt()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var (key, _) = zone.AsteroidBelts.First();
var data = (AsteroidBeltData) zone.Planets[key];
var count = data.Asteroids.Length;
@ -963,7 +662,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void TheBusBillsAHeaterExactlyWhenItsThermostatIsOpen()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var thermostat = heater.Behaviors.OfType<Thermotoggle>().Single();
@ -1007,7 +706,7 @@ public sealed partial class RunStartTests : IDisposable
{
Name = "Two Group Gear", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(gear).Key), Manufacturer = _cache.RefOf(_protagonist)
});
var zone = Arena(new Scenario { Ambient = false });
var zone = Arena(false);
var ship = BareHull(Hull("Djinni"));
ship.Zone = zone;
Assert.True(ship.TryEquip(Instance(gear)));
@ -1018,59 +717,6 @@ public sealed partial class RunStartTests : IDisposable
Assert.True(abs(ship.PowerBus.TotalDemand - 7) < .01f, $"billed {ship.PowerBus.TotalDemand} for a closed 10 and an ungated 7");
}
// Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a
// scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of
// it, or cargo it carries. These are the unsold designs no scenario places, by name.
private static List<string> UnsoldAndUnplaced(CultCache cache)
{
var sold = cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var placed = new HashSet<CultRecordKey>();
foreach (var scenario in cache.GetAll<Scenario>())
foreach (var ship in scenario.Entities.Prepend(scenario.Player).Where(ship => ship != null))
{
foreach (var cargo in ship.Cargo) placed.Add(cargo.Key);
var preset = cache.Get(ship.Loadout);
if (preset == null) continue;
placed.Add(preset.Hull.Key);
foreach (var slot in preset.Slots) placed.Add(slot.Design.Key);
}
return cache.GetAll<EquippableItemData>()
.Where(design => !sold.Contains(cache.RefOf(design).Key) && !placed.Contains(cache.RefOf(design).Key))
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList();
}
// The shipped catalog holds no unsold design outside a scenario (today, none unsold at all). A new unsold design is
// flagged until a scenario places it, in a preset's slot or as an entity's cargo.
[Fact]
public void EveryUnsoldDesignIsAScenarioTestDesign()
{
Assert.Empty(UnsoldAndUnplaced(_cache));
GearData Probe(string name)
{
var design = new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1 };
_cache.Upsert(design);
return design;
}
var fitted = Probe("Census Fitted");
var carried = Probe("Census Carried");
Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(_cache));
var preset = Preset(new Loadout
{
Name = "census preset", Hull = _cache.RefOf(Hull("Djinni")), WeaponGroups = new int[0][],
Slots = { new LoadoutSlot { Design = new CultRecordRef<EquippableItemData>(_cache.RefOf(fitted).Key) } }
});
var scenario = Quiet(At(Bare(Hull("Djinni")), 0, 0), Placed(preset, 100, 0, ScenarioStance.Neutral, false));
scenario.Name = "census";
_cache.Upsert(scenario);
Assert.Equal(new[] { "Census Carried" }, UnsoldAndUnplaced(_cache));
scenario.Entities[0].Cargo.Add(new CultRecordRef<EquippableItemData>(_cache.RefOf(carried).Key));
_cache.Upsert(scenario);
Assert.Empty(UnsoldAndUnplaced(_cache));
}
// The Q3 rule must not reach a real hull: the hulls scenarios rely on each have a product, and materialize bare,
// branded, under the prelude's availability.
[Fact]
@ -1080,7 +726,7 @@ public sealed partial class RunStartTests : IDisposable
foreach (var hull in new[] { Hull("Djinni"), Hull("LonginusX"), TurretHull() })
{
var failures = new List<string>();
var entity = Loadouts.Materialize(_items, null, _cache.Get(Bare(hull)), available.IsAvailable, failures);
var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.IsAvailable, failures);
Assert.Empty(failures);
Assert.NotNull(_items.Brand(entity.Hull).Maker);
}
@ -1091,7 +737,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AdmitIsTheOnlyAgentCreator()
{
var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null);
var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { }));
var packedPlayer = pack.Entities.OfType<ShipPack>().First();
packedPlayer.IsPlayerShip = true;
var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy);
@ -1099,44 +745,10 @@ public sealed partial class RunStartTests : IDisposable
Assert.True(ships.Count > 1);
foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship));
var arriving = (Ship) Loadouts.Materialize(_items, zone, _cache.Get(Bare(Hull("Djinni"))), _ => true, new List<string>());
var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List<string>());
var agents = zone.Agents.Count;
zone.Admit(arriving, piloted: false);
SafeAssert.In(zone, arriving, "the arrival is admitted");
Assert.Equal(agents, zone.Agents.Count);
}
// The scenario type is catalog data: it round-trips through the catalog file.
[Fact]
public void AScenarioRoundTripsThroughTheCatalog()
{
var djinni = Bare(Hull("Djinni"));
var cargo = new CultRecordRef<EquippableItemData>(_cache.RefOf(Hull("LonginusX")).Key);
var scenario = new Scenario
{
Name = "Round trip", Brief = "one of everything", Seed = 42, Ambient = true,
Player = new ScenarioShip { Loadout = djinni, Position = float2(1, 2), Direction = float2(0, -1), Cargo = { cargo } },
Entities = { Placed(djinni, 3, 4, ScenarioStance.Hostile, piloted: true) }
};
// Written through its own cache over a second copy of the catalog, disposed, then read back fresh
var copy = Path.Combine(_root, "roundtrip.cc");
File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), copy);
using (var writer = new CultCache(Registry()))
{
writer.AddBackingStore(new SingleFileMessagePackBackingStore(copy), AetheriaStores.CatalogTypes);
writer.Commit(batch => batch.Upsert(typeof(Loadout), _cache.Get(djinni), djinni.Key));
writer.Upsert(scenario);
writer.FlushAsync().Wait();
}
using var reopened = new CultCache(Registry());
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy, true), AetheriaStores.CatalogTypes);
var loaded = Assert.Single(reopened.GetAll<Scenario>());
Assert.Equal(("Round trip", "one of everything", 42u, true), (loaded.Name, loaded.Brief, loaded.Seed, loaded.Ambient));
Assert.Equal(djinni.Key, loaded.Player.Loadout.Key);
Assert.Equal((float2(1, 2), float2(0, -1)), (loaded.Player.Position, loaded.Player.Direction));
Assert.Equal(cargo.Key, Assert.Single(loaded.Player.Cargo).Key);
var entity = Assert.Single(loaded.Entities);
Assert.Equal((djinni.Key, float2(3, 4), ScenarioStance.Hostile, true), (entity.Loadout.Key, entity.Position, entity.Stance, entity.Piloted));
}
}

View file

@ -0,0 +1,568 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Scenarios S2 (docs/scenarios-cut.md, R): every new run is a scenario. The fixture is RunStartTests': a scratch copy
// of the shipped catalog, the authored settings and a prelude galaxy at a fixed seed.
public sealed partial class RunStartTests
{
// A test's own scenario: a script given inline, staged into the fixture's arena or launched whole.
private sealed class Scripted : Scenario
{
private readonly bool _ambient;
private readonly Action<ScenarioStage> _stage;
private readonly uint _seed;
private readonly Func<GalaxyStage, Galaxy> _generate;
public Scripted(bool ambient, Action<ScenarioStage> stage, uint seed = GalaxySeed, Func<GalaxyStage, Galaxy> generate = null)
{
_ambient = ambient;
_stage = stage;
_seed = seed;
_generate = generate ?? (galaxy => galaxy.Prelude());
}
public override string Name => "Scripted";
public override string Brief => "A test's own scenario.";
public override uint Seed => _seed;
public override bool Ambient => _ambient;
public override Galaxy Generate(GalaxyStage stage) => _generate(stage);
public override void Stage(ScenarioStage stage) => _stage(stage);
}
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests);
// The galaxy inputs the menu hands to RunStart, read from the authored settings.
private GalaxyStage Inputs(Func<uint> clock = null)
{
var authored = AuthoredSettings.Load(FindRepoRoot());
return new GalaxyStage(
authored.Read<SectorGenerationSettings>("SectorGenerationSettings"),
authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"),
authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings"),
authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"),
_cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, clock);
}
// What MainMenu.Launch and StartGame do with a scenario: its galaxy, its arena, then staging. beforeStaging stands in
// for whatever the game runs between the arena and staging.
private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List<string> failures) Launch(Scenario scenario, GalaxyStage inputs = null, Action beforeStaging = null)
{
var galaxy = RunStart.Generate(scenario, inputs ?? Inputs());
var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, galaxy, scenario), galaxy.Entrance, galaxy);
beforeStaging?.Invoke();
var failures = new List<string>();
var staged = RunStart.Stage(_items, arena, scenario, _startingHull, _tutorialSettings, failures);
return (galaxy, arena, staged, failures);
}
private RunStart.Staged Stage(Zone zone, Scenario scenario)
{
var failures = new List<string>();
var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _tutorialSettings, failures);
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.NotNull(staged);
return staged;
}
// Staging that must fail: it returns null and admits nothing. Returns the failures.
private List<string> Refused(Zone zone, Scenario scenario)
{
var entities = zone.Entities.ToList();
var agents = zone.Agents.ToList();
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _tutorialSettings, failures));
SafeAssert.Unchanged(zone, entities, agents);
return failures;
}
// The rot test: every scenario New Game lists generates its galaxy and stages against the live catalog and the
// authored settings with no failure. A script naming a design the catalog lost fails here, by name.
[Fact]
public void EveryScenarioStages()
{
Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count());
foreach (var scenario in EveryScenario)
{
var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, $"{scenario.Name}: {string.Join("; ", failures)}");
Assert.True(staged.Player.IsPlayerShip, $"{scenario.Name}: the player is not flagged");
SafeAssert.In(arena, staged.Player, $"{scenario.Name}: the player is admitted");
foreach (var entity in staged.Entities) SafeAssert.In(arena, entity, $"{scenario.Name}: {entity.Name} is admitted");
Assert.False(string.IsNullOrWhiteSpace(scenario.Brief), $"{scenario.Name} has no brief");
}
}
// The two game modes are what New Game did: the tutorial a prelude galaxy and a starting-hull ship of the
// protagonist faction at the entrance's origin; the main galaxy a main galaxy and a starting-hull ship there. Both
// arenas keep their generated ships.
[Fact]
public void ModesMatchOldNewGame()
{
Assert.Equal("LonginusX", _startingHull);
var tutorial = Launch(new TutorialGalaxy(), Inputs(() => GalaxySeed));
Assert.Empty(tutorial.failures);
Assert.True(tutorial.galaxy.IsPrelude, "the tutorial is a prelude galaxy");
Assert.Equal(_startingHull, tutorial.staged.Player.HullData.Name);
Assert.Same(tutorial.galaxy.ResolveFaction(_tutorialSettings.ProtagonistFaction), tutorial.staged.Player.Faction);
Assert.Equal(float3.zero, tutorial.staged.Player.Position);
Assert.Empty(tutorial.staged.Entities);
Assert.Contains(tutorial.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false });
var main = Launch(new MainGalaxy(), Inputs(() => GalaxySeed));
Assert.Empty(main.failures);
Assert.False(main.galaxy.IsPrelude, "the main galaxy is not a prelude");
Assert.Equal(_startingHull, main.staged.Player.HullData.Name);
Assert.Equal(float3.zero, main.staged.Player.Position);
Assert.Empty(main.staged.Entities);
Assert.Contains(main.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false });
}
// Everything a galaxy's layout fixes: each zone's name, position, owner, factions and links, the homes, the
// entrance, the exit and the background.
private static string Layout(Galaxy galaxy) =>
string.Join(";", galaxy.Zones.Select(zone =>
$"{zone.Name}@{zone.Position}:{zone.Owner?.Name}:[{string.Join(",", zone.Factions.Select(faction => faction.Name))}]" +
$":[{string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(galaxy.Zones, adjacent)).OrderBy(i => i))}]")) +
$" homes {string.Join(",", galaxy.HomeZones.Select(home => $"{home.Key.Name}={Array.IndexOf(galaxy.Zones, home.Value)}").OrderBy(home => home))}" +
$" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} exit {Array.IndexOf(galaxy.Zones, galaxy.Exit)} noise {galaxy.Background.NoisePosition}";
// An arena's bodies and every entity in it, down to each item's design, maker, workmanship and role fills.
private string Layout(ZonePack arena) =>
$"{arena.Radius} {arena.Mass} planets [{string.Join(",", arena.Planets.Select(planet => _cache.Get(planet)).Select(body => $"{body.GetType().Name}:{body.Mass}"))}]" +
$" orbits [{string.Join(",", arena.Orbits.Select(orbit => _cache.Get(orbit).Distance))}]" +
$" entities [{string.Join(",", arena.Entities.Select(Gear))}]";
// What a launch staged: the player and everything placed, each where it is and with exactly what it carries.
private string Fits(RunStart.Staged staged) =>
string.Join(" | ", staged.Entities.Prepend(staged.Player).Select(entity => $"{entity.Name}@{entity.Position}:{Gear(EntitySerializer.Pack(entity))}"));
private string Gear(EntityPack pack) =>
$"{pack.GetType().Name}{(pack is ShipPack ship ? "@" + ship.Position : "")}:{string.Join("/", EntitySerializer.Items(pack).Select(Item))}";
private string Item(ItemInstance item)
{
var name = _items.GetData(item)?.Name;
if (!(item is CraftedItemInstance crafted)) return name;
var lot = _items.GetLot(crafted);
return $"{name}<{(lot.Origin as Attributed)?.Faction.Key.Value}>~{lot.Quality}" +
string.Concat((lot.Roles ?? new List<RoleFill>()).Select(fill => $"/{fill.Role}={fill.Quality}"));
}
// A fixed seed fixes the galaxy, its background, its arena and every fit, each item's maker and workmanship
// included, launch after launch in one session, whatever ran in between. Seed zero takes the clock, so two launches
// at two clock readings are two galaxies.
[Fact]
public void SeedRules()
{
foreach (var scenario in Scenarios.Tests)
{
Assert.NotEqual(0u, scenario.Seed);
var first = Launch(scenario);
Launch(new MainGalaxy(), Inputs(() => 4242));
_items.Random = new CultMath.Random(0xC0FFEE); // play draws from the run's item random
var second = Launch(scenario, beforeStaging: () => _items.Random = new CultMath.Random(0xBADC0DE));
Assert.True(first.failures.Count == 0, $"{scenario.Name}: {string.Join("; ", first.failures)}");
Assert.Equal(Layout(first.galaxy), Layout(second.galaxy));
Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack));
Assert.Equal(Fits(first.staged), Fits(second.staged));
}
var clock = new Queue<uint>(new[] { 1000u, 2000u });
var inputs = Inputs(() => clock.Dequeue());
var everyLaunchNew = new Scripted(true, _ => { }, seed: 0, generate: galaxy => galaxy.Main());
var a = RunStart.Generate(everyLaunchNew, inputs);
Assert.Equal(1000u, inputs.Seed);
var b = RunStart.Generate(everyLaunchNew, inputs);
Assert.Equal(2000u, inputs.Seed);
Assert.NotEqual(Layout(a), Layout(b));
}
// Generating a galaxy writes nothing a later one reads: a prelude leaves the catalog's faction influence as authored.
[Fact]
public void AGalaxyLeavesTheCatalogsFactionsAsAuthored()
{
var authored = _cache.GetAll<Faction>().ToDictionary(faction => faction, faction => faction.InfluenceDistance);
RunStart.Generate(new TutorialGalaxy(), Inputs());
RunStart.Generate(new MainGalaxy(), Inputs());
Assert.All(authored, pair => Assert.Equal(pair.Value, pair.Key.InfluenceDistance));
}
// Every zone is linked into one graph. These seeds left zones with no link at all (the vendored float-precision
// convex-hull triangulation dropped them), so the constructor threw KeyNotFoundException placing homes (prelude)
// or the entrance (main). Each galaxy's links are a subset of its zone positions' Delaunay edges, and the
// triangulation agrees with a brute-force empty-circumcircle oracle.
[Theory]
[InlineData(103u, true)]
[InlineData(134u, true)]
[InlineData(137u, true)]
[InlineData(173u, true)]
[InlineData(227u, true)]
[InlineData(285u, true)]
[InlineData(34u, false)]
public void EveryZoneIsLinked(uint seed, bool prelude)
{
var galaxy = RunStart.Generate(new Scripted(true, _ => { }, seed, stage => prelude ? stage.Prelude() : stage.Main()), Inputs());
Assert.All(galaxy.Zones, zone => Assert.Equal(galaxy.Zones.Length, zone.Distance.Count));
var positions = galaxy.Zones.Select(zone => zone.Position).ToArray();
var edges = Delaunay.Edges(positions);
Assert.Equal(BruteForceDelaunay(positions), edges);
var delaunay = new HashSet<(int, int)>(edges);
for (var i = 0; i < galaxy.Zones.Length; i++)
foreach (var adjacent in galaxy.Zones[i].AdjacentZones)
{
var j = Array.IndexOf(galaxy.Zones, adjacent);
Assert.Contains((Math.Min(i, j), Math.Max(i, j)), delaunay);
}
}
// Every triangle whose circumcircle holds no other point, as sorted edges.
private static List<(int a, int b)> BruteForceDelaunay(float2[] points)
{
var edges = new SortedSet<(int a, int b)>();
for (var i = 0; i < points.Length; i++)
for (var j = i + 1; j < points.Length; j++)
for (var k = j + 1; k < points.Length; k++)
{
double ax = points[i].x, ay = points[i].y, bx = points[j].x, by = points[j].y, cx = points[k].x, cy = points[k].y;
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (d == 0) continue;
double a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy;
var ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
var uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
var r2 = (ax - ux) * (ax - ux) + (ay - uy) * (ay - uy);
var empty = true;
for (var m = 0; m < points.Length && empty; m++)
if (m != i && m != j && m != k)
{
double dx = points[m].x - ux, dy = points[m].y - uy;
empty = dx * dx + dy * dy >= r2;
}
if (!empty) continue;
edges.Add((i, j));
edges.Add((j, k));
edges.Add((i, k));
}
return edges.ToList();
}
// A scenario naming one unknown design admits nothing, not even the parts that built, and names the design.
[Fact]
public void StagingIsAllOrNothing()
{
var failures = Refused(Arena(true), new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Generated("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true);
stage.Place(stage.Fit("LonginusX", ("No Such Gun", int2(1, 8), ItemRotation.None)), float2(-300, 0));
}));
Assert.Contains("No Such Gun", Assert.Single(failures));
}
// A stance holds both ways between an entity and the player. A piloted entity has exactly one agent; an unpiloted
// one has none, and nothing steers it. (It is not frozen: the zone's gravity still moves it, as it moves any ship,
// so the rule is observed on its steering inputs.)
[Fact]
public void StanceAndPilot()
{
var zone = Arena(true);
var staged = Stage(zone, new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Generated("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true);
stage.Place(stage.Bare("LonginusX"), float2(-300, 0), stance: ScenarioStance.Neutral, piloted: false);
}));
var player = staged.Player;
var hostile = staged.Entities[0];
var neutral = staged.Entities[1];
Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "hostile both ways");
Assert.False(player.IsHostileTo(neutral) || neutral.IsHostileTo(player), "neutral both ways");
// Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees.
Assert.True(player.EntityHostility[hostile] && hostile.EntityHostility[player]);
Assert.False(player.EntityHostility[neutral] || neutral.EntityHostility[player]);
Assert.Equal(1, AgentsOf(zone, hostile));
Assert.Equal(0, AgentsOf(zone, neutral));
Assert.Equal(0, AgentsOf(zone, player));
var look = neutral.LookDirection;
for (var tick = 0; tick < 50; tick++)
{
zone.Update(.1f);
Assert.Equal(float2.zero, ((Ship) neutral).MovementDirection);
Assert.Equal(look, neutral.LookDirection);
}
Assert.Equal(0, AgentsOf(zone, neutral));
}
// A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity
// gone for good: once an entity is destroyed, the player holds no stance toward it.
[Fact]
public void AStanceSurvivesDockingButNotDestruction()
{
var zone = Arena(true);
var staged = Stage(zone, new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Bare("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile);
stage.Place(stage.Bare("LonginusX"), float2(-300, 0), stance: ScenarioStance.Hostile);
}));
var player = staged.Player;
var hostile = staged.Entities[0];
var doomed = staged.Entities[1];
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
Assert.NotNull(station.TryDock(player));
SafeAssert.NotIn(zone, player, "a docked ship is out of the zone");
Assert.True(station.TryUndock(player));
Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "the stance was lost over docking");
Assert.True(player.EntityHostility[hostile] && hostile.EntityHostility[player], "the live hostility was lost over docking");
// Bare fits carry no faction, so without its stance the derived rule is not hostile
Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless");
Assert.True(player.IsHostileTo(doomed));
doomed.Hull.Durability = 0;
doomed.HullDamage.OnNext(1);
SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone");
Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it");
}
// Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent.
[Fact]
public void UndockingAdmitsWithNoAgent()
{
var zone = Arena(true);
var player = Stage(zone, new TutorialGalaxy()).Player;
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var agents = zone.Agents.Count;
Assert.NotNull(station.TryDock(player));
SafeAssert.NotIn(zone, player, "a docked ship is out of the zone");
Assert.True(station.TryUndock(player));
SafeAssert.In(zone, player, "an undocked ship is back in the zone");
Assert.Equal(agents, zone.Agents.Count);
Assert.Equal(0, AgentsOf(zone, player));
Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active");
}
// Ambient false keeps the stations and drops every generated ship and turret; ambient keeps them. The arena is the
// galaxy's entrance pack. The entrance holds a story station, placed as the narrative would place one, so a story
// station's own turrets are dropped too.
[Fact]
public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations()
{
(int ships, int turrets, int stations) Census(ZonePack pack) => (
pack.Entities.OfType<ShipPack>().Count(),
pack.Entities.OfType<OrbitalEntityPack>().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret),
pack.Entities.OfType<OrbitalEntityPack>().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station));
_galaxy.Entrance.Locations.Add(new LocationStory
{
Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2
});
var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { })));
var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(false, _ => { }));
var quiet = Census(quietPack);
Assert.Same(quietPack, _galaxy.Entrance.PackedContents);
Assert.True(ambient.ships > 0 && ambient.turrets > 0, $"an ambient arena generated {ambient}");
Assert.Equal(0, quiet.ships);
Assert.Equal(0, quiet.turrets);
Assert.True(quiet.stations > 0);
Assert.Equal(ambient.stations, quiet.stations);
Assert.Contains(quietPack.Entities.OfType<OrbitalEntityPack>(), station => station.Story == 0);
}
// A scenario's player is its fit (here a catalog preset), at the scripted place and heading, carrying its cargo.
[Fact]
public void AScenarioPlayerIsItsFitAtItsPlaceWithItsCargo()
{
var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2);
var preset = Loadouts.Capture(_items, (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Djinni")), "test djinni");
_cache.Upsert(preset);
// Two one-cell designs a manufacturer makes, by name, so the cargo is ordinary branded stock
var cargo = _cache.GetAll<GearData>()
.Where(design => design.Shape.Coordinates.Length == 1)
.Where(design => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key)))
.OrderBy(design => design.Name, StringComparer.Ordinal)
.Take(2)
.ToList();
Assert.Equal(2, cargo.Count);
var zone = Arena(true);
var player = Stage(zone, new Scripted(true, stage =>
stage.Cargo(stage.Player(stage.Preset("test djinni"), float2(120, -40), float2(3, 4)), cargo.Select(design => design.Name).ToArray()))).Player;
Assert.Equal("Djinni", player.HullData.Name);
SafeAssert.In(zone, player, "the player is admitted");
Assert.Equal(float3(120, 0, -40), player.Position);
Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}");
Assert.Equal(
preset.Slots.Select(slot => (slot.Position, slot.Design.Key)).OrderBy(s => s.ToString()),
player.Equipment.Concat<EquippedItem>(player.CargoBays).Concat(player.DockingBays)
.Where(item => item != player.EquippedHull)
.Select(item => (item.Position, _cache.RefOf(_items.GetData(item.EquippableItem)).Key))
.OrderBy(s => s.ToString()));
Assert.Equal(cargo.Select(design => _cache.RefOf(design).Key.Value).OrderBy(k => k),
player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k));
}
// A turret hull stages as an orbital entity with no orbit, which stays where it is put.
[Fact]
public void ATurretStagesAsAStationaryOrbitalEntity()
{
var zone = Arena(true);
var staged = Stage(zone, new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Bare(TurretHull().Name), float2(200, 50), stance: ScenarioStance.Hostile);
}));
var turret = Assert.IsType<OrbitalEntity>(staged.Entities[0]);
Assert.False(turret.OrbitData.IsSet());
SafeAssert.In(zone, turret, "the turret is admitted");
for (var tick = 0; tick < 20; tick++) zone.Update(.1f);
Assert.Equal(float2(200, 50), turret.Position.xz);
}
// A run has exactly one player, and it is a ship; a station is never staged, and a turret is never piloted. Each
// is a staging failure, named, with nothing admitted.
[Fact]
public void AMisplacedHullOrPlayerIsAStagingFailure()
{
var station = _cache.GetAll<HullData>().First(hull => hull.HullType == HullType.Station).Name;
var turret = TurretHull().Name;
foreach (var (script, expected) in new (Action<ScenarioStage>, string)[]
{
(stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Place(stage.Bare(station), float2(200, 0)); }, $"{station} is a station hull"),
(stage => stage.Player(stage.Bare(turret), float2(0, 0)), $"player: {turret} is not a ship hull"),
(stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Place(stage.Bare(turret), float2(200, 0), piloted: true); }, "cannot be piloted"),
(stage => stage.Place(stage.Bare("Djinni"), float2(200, 0)), "places no player"),
(stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Player(stage.Bare("Djinni"), float2(9, 0)); }, "a second player"),
})
{
var failures = Refused(Arena(true), new Scripted(true, script));
Assert.Contains(failures, failure => failure.Contains(expected));
}
}
// Arcs' geometry: the bow hull sits inside every one of the player's mount arcs, the stern hull outside all of them,
// and the turret is hostile.
[Fact]
public void ArcsGeometry()
{
var staged = Stage(Arena(false), new Arcs());
var player = staged.Player;
var bow = staged.Entities[0];
var stern = staged.Entities[1];
var turret = Assert.IsType<OrbitalEntity>(staged.Entities[2]);
Assert.Equal((Arcs.Bow, Arcs.Stern, Arcs.Turret), (bow.Position.xz, stern.Position.xz, turret.Position.xz));
var mounts = player.Equipment.Where(item => item.Behaviors.OfType<Weapon>().Any()).ToList();
Assert.NotEmpty(mounts);
foreach (var mount in mounts)
{
Assert.True(FireControl.InArc(mount, bow.Position - player.Position), $"{mount.Data.Name} cannot bear on the bow hull");
Assert.False(FireControl.InArc(mount, stern.Position - player.Position), $"{mount.Data.Name} bears on the stern hull");
}
Assert.True(player.IsHostileTo(turret) && turret.IsHostileTo(player), "the turret is hostile");
Assert.False(player.IsHostileTo(bow) || player.IsHostileTo(stern), "the bare hulls are neutral");
}
// Q3 (operator, 2026-09-30): a design no product sells is a scenario test design and nothing else. A test design
// is one some scenario's staged entities carry, fitted or in cargo. These are the unsold designs none carries.
private List<string> UnsoldAndUnplaced(IEnumerable<RunStart.Staged> runs)
{
var sold = _cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var placed = new HashSet<CultRecordKey>();
foreach (var entity in runs.SelectMany(run => run.Entities.Prepend(run.Player)))
{
foreach (var item in entity.Equipment.Concat<EquippedItem>(entity.CargoBays).Concat(entity.DockingBays))
placed.Add(_cache.RefOf(_items.GetData(item.EquippableItem)).Key);
foreach (var item in entity.CargoBays.SelectMany(bay => bay.Cargo.Keys)) placed.Add(item.Data.Key);
}
return _cache.GetAll<EquippableItemData>()
.Where(design => !sold.Contains(_cache.RefOf(design).Key) && !placed.Contains(_cache.RefOf(design).Key))
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList();
}
// The shipped catalog holds no unsold design that no scenario carries (today, none unsold at all). A new unsold
// design is flagged until a scenario fits it or carries it as cargo.
[Fact]
public void EveryUnsoldDesignIsAScenarioTestDesign()
{
var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList();
Assert.Empty(UnsoldAndUnplaced(runs));
void Probe(string name)
{
var shape = new Shape(1, 1);
shape[int2(0, 0)] = true;
_cache.Upsert(new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = shape, Durability = 1 });
}
Probe("Census Fitted");
Probe("Census Carried");
Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(runs));
runs.Add(Stage(Arena(false), new Scripted(false, stage =>
{
var ship = stage.Player(stage.Fit("Djinni", ("Census Fitted", int2(1, 6), ItemRotation.None), ("Store-All Plus", int2(3, 6), ItemRotation.None)), float2(0, 0));
stage.Cargo(ship, "Census Carried");
})));
Assert.Empty(UnsoldAndUnplaced(runs));
}
// Whether a run is the prelude is the galaxy's fact (Galaxy.IsPrelude); a save records it with no second owner.
[Fact]
public void SaveReadsPrelude()
{
var arena = Arena(true);
var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0]);
Assert.True(prelude.IsTutorial);
var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0]);
Assert.False(main.IsTutorial);
}
// PlayerSettings Key 2, the never-written tutorial flag, is retired: a player.cc written with it still loads, every other field intact.
[Fact]
public void OldPlayerSettingsLoad()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(PlayerSettings).Assembly);
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(7);
writer.Write("Pilot");
writer.WriteNil(); // Key 1, retired before this cut
writer.Write(true); // Key 2, the retired tutorial flag
MessagePackSerializer.Serialize(ref writer, new Dictionary<string, string> { ["story"] = "hash" }, options);
MessagePackSerializer.Serialize(ref writer, new PlayerGameplaySettings { TemperatureUnit = TemperatureUnit.Kelvin, SignificantDigits = 5 }, options);
MessagePackSerializer.Serialize(ref writer, new PlayerInputSettings { ActionBarInputs = { "<Keyboard>/1" } }, options);
MessagePackSerializer.Serialize(ref writer, new PlayerGraphicsSettings { NebulaQuality = Quality.High, ShowAsteroidsInMinimap = true }, options);
writer.Flush();
var settings = MessagePackSerializer.Deserialize<PlayerSettings>(buffer.WrittenMemory, options);
Assert.Equal("Pilot", settings.Name);
Assert.Equal("hash", settings.HashedStoryFiles["story"]);
Assert.Equal((TemperatureUnit.Kelvin, 5), (settings.GameplaySettings.TemperatureUnit, settings.GameplaySettings.SignificantDigits));
Assert.Equal("<Keyboard>/1", Assert.Single(settings.InputSettings.ActionBarInputs));
Assert.Equal((Quality.High, true), (settings.GraphicsSettings.NebulaQuality, settings.GraphicsSettings.ShowAsteroidsInMinimap));
}
}