Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4be037a42 | ||
|
|
152b9251a9 | ||
|
|
01bed8d447 | ||
|
|
3f3c59e27d | ||
|
|
36d9d94e81 | ||
|
|
26daac398a | ||
|
|
f3927e24db | ||
|
|
7a3c0c8fb8 | ||
|
|
ffdba39ac2 | ||
|
|
845f6c3aad | ||
|
|
e0c1daa4ec | ||
|
|
c6f7e0c8a4 | ||
|
|
633ae3353a | ||
|
|
5e2a9efc8a | ||
|
|
562ba5c8eb | ||
|
|
c4911a4c96 | ||
|
|
2e9d97661c | ||
|
|
aba02182b7 | ||
|
|
a9bbea5091 | ||
|
|
293e3a33fd | ||
|
|
257a34939e | ||
|
|
6e5be44613 | ||
|
|
28c076b1e6 | ||
|
|
c4a2ce36be | ||
|
|
8fa1fc21b2 | ||
|
|
2822a583ea | ||
|
|
97aa371872 | ||
|
|
9fd0fe96e7 | ||
|
|
0182570200 | ||
|
|
84acc9e563 | ||
|
|
5df9960343 | ||
|
|
4f93c0de68 | ||
|
|
bef6a90f58 | ||
|
|
674baa95ae | ||
|
|
3e98238cf0 | ||
|
|
30030af6e4 | ||
|
|
e93f531669 | ||
|
|
1efd8343da | ||
|
|
ccd9792d2f | ||
|
|
3fb5e0e0e6 | ||
|
|
1ed3579025 | ||
|
|
dc4838bd9e | ||
|
|
3c3ecd40da | ||
|
|
da067a34a2 | ||
|
|
6ba77aafe7 | ||
|
|
154b71160d | ||
|
|
4b7e5476c0 | ||
|
|
8e6fd43d23 | ||
|
|
8d23a378d6 | ||
|
|
1db250fb10 | ||
|
|
8774bd8a97 | ||
|
|
c81a83ef2c | ||
|
|
4848c5332f | ||
|
|
d92d77483f | ||
|
|
b375cfc8ec | ||
|
|
26bea2cb14 | ||
|
|
7df0a0943f | ||
|
|
c6a49e5568 | ||
|
|
dd1031ce6a | ||
|
|
b481363b9b | ||
|
|
e3824360aa | ||
|
|
e403905e12 | ||
|
|
78b176a0ff | ||
|
|
d5c0063b7e | ||
|
|
b1b5031e4b | ||
|
|
fce8c66284 | ||
|
|
fe7ea72c9b | ||
|
|
37677c4893 | ||
|
|
722e618cac | ||
|
|
9b56bf0136 | ||
|
|
780e68be71 | ||
|
|
8ca5425fcc | ||
|
|
163bdf9b79 | ||
|
|
3715c9edf0 | ||
|
|
d4bec37c6a | ||
|
|
d2c293b799 | ||
|
|
65b0e00cd6 | ||
|
|
848ff83cd7 | ||
|
|
5177154c17 | ||
|
|
bd7cf4b5d4 | ||
|
|
dd7f40b559 | ||
|
|
6befccce74 | ||
|
|
ae3e4a7c58 | ||
|
|
fe7c5226cc | ||
|
|
2da2afd468 | ||
|
|
f403485dc0 | ||
|
|
e5099c0363 | ||
|
|
ade0b0fbf3 | ||
|
|
352a94fe21 | ||
|
|
a90ec3d450 | ||
|
|
9036d39f30 | ||
|
|
62a641bf15 | ||
|
|
1f7b971966 | ||
|
|
e92c2140b4 | ||
|
|
d3da985290 | ||
|
|
5367c899a6 | ||
|
|
35ef321ff3 | ||
|
|
0d8457bc34 | ||
|
|
1aceaa576d | ||
|
|
43042177d6 | ||
|
|
46912a20c8 | ||
|
|
37a7bb76f7 | ||
|
|
99f5820565 | ||
|
|
2b09e87733 | ||
|
|
aeac4e2958 | ||
|
|
b679a38edc | ||
|
|
7c9a970c81 | ||
|
|
c1f9c05c48 | ||
|
|
b1380204a3 | ||
|
|
d038ee393a | ||
|
|
a1519c9b2b | ||
|
|
c00be8b35c | ||
|
|
93f69ecb80 | ||
|
|
1870e23bfd | ||
|
|
04128e2f81 | ||
|
|
3b466f8c9b | ||
|
|
de9e1ba2f9 | ||
|
|
95b7aeb7a2 | ||
|
|
3d4508d196 | ||
|
|
5adaef4697 | ||
|
|
ed5dd92283 | ||
|
|
48ba0a6204 | ||
|
|
549437536a | ||
|
|
b9e0e5b5eb | ||
|
|
f0362025d9 | ||
|
|
e2f0fc9dc4 | ||
|
|
a19d0eb73b | ||
|
|
7f3b06222d | ||
|
|
dc69f5dc5b | ||
|
|
3092ce81cd | ||
|
|
98aef507f1 | ||
|
|
0394e33ed8 | ||
|
|
b31595add2 | ||
|
|
7367f21c8a | ||
|
|
b4510d5737 | ||
|
|
94a14b8dec | ||
|
|
dccac5047d | ||
|
|
88a36fff4e | ||
|
|
a487f6c0a4 | ||
|
|
e2e01b2266 | ||
|
|
084c77fb12 | ||
|
|
79caa8b32d | ||
|
|
5584c0d7c8 | ||
|
|
6b89019c02 | ||
|
|
c6c8b4465b | ||
|
|
cdee1a27f8 | ||
|
|
3506d48b1f | ||
|
|
91f9b5553c | ||
|
|
263fa02a73 | ||
|
|
bacb56c613 | ||
|
|
d233194cb2 | ||
|
|
0e50c1d101 | ||
|
|
37de26291d | ||
|
|
48606e4253 | ||
|
|
f920cd7052 | ||
|
|
2fb8e88fcf | ||
|
|
9961a0df6d | ||
|
|
ca24a32028 | ||
|
|
169679dbc4 | ||
|
|
585942259d | ||
|
|
09de7e5eb2 | ||
|
|
79a3a62fc1 | ||
|
|
3bfac25027 | ||
|
|
80283d18a9 | ||
|
|
7f1337167d | ||
|
|
bd628de3bf | ||
|
|
02d60691dc | ||
|
|
b1ca2ead90 | ||
|
|
d9bc465355 | ||
|
|
4918fcd993 | ||
|
|
b878734158 | ||
|
|
d2f4b2c347 | ||
|
|
37a8110907 | ||
|
|
17e2da0092 | ||
|
|
d55ac30905 | ||
|
|
ea3e983aa2 | ||
|
|
0a468f24a8 | ||
|
|
ac4b771f5e | ||
|
|
353ca96b5e | ||
|
|
a7364ed13e | ||
|
|
fba7bd1b47 | ||
|
|
7886f0a231 | ||
|
|
e045af344f | ||
|
|
da5ba653cb | ||
|
|
f448957f3a | ||
|
|
e518a0b3d1 | ||
|
|
fbf88e85c5 | ||
|
|
fb8f2ea2fb | ||
|
|
bd0293d683 | ||
|
|
1dce24a226 | ||
|
|
8b0b50382a | ||
|
|
00e60344ab | ||
|
|
c986adcf20 | ||
|
|
9958fe55ab | ||
|
|
b027b36ab4 | ||
|
|
d66b2a2c3c | ||
|
|
fd50f02f1b | ||
|
|
f7ba043b13 | ||
|
|
fa5820f516 | ||
|
|
ddae55759d | ||
|
|
ffe43b41e8 | ||
|
|
708fbeee59 | ||
|
|
689dbfa9d0 | ||
|
|
4d462c771c | ||
|
|
f0d7c12cbd | ||
|
|
f9a09e0d0a | ||
|
|
66671cf036 | ||
|
|
7e2fff1ff1 | ||
|
|
c37fbdb3b9 | ||
|
|
b43b238d6a | ||
|
|
1691f47cc6 | ||
|
|
00d34e5ab5 | ||
|
|
3f265b6c73 | ||
|
|
dadb6f327d | ||
|
|
0267a191c4 | ||
|
|
488b3fa434 | ||
|
|
961bdafade | ||
|
|
d38ee7cdd6 | ||
|
|
70aa1ed037 | ||
|
|
c40a6e8229 | ||
|
|
19901c1a92 | ||
|
|
ae52857a91 | ||
|
|
5b599f66bf | ||
|
|
d3cd7813d7 | ||
|
|
66c38d9506 | ||
|
|
8d58bca27a | ||
|
|
98b4f4f946 | ||
|
|
71b12d594b | ||
|
|
dab415ad87 | ||
|
|
87dc0f013f | ||
|
|
bb38036e51 | ||
|
|
2b053b34c2 | ||
|
|
bcfc776513 | ||
|
|
4e559d822e | ||
|
|
c1aab929b1 | ||
|
|
7ac8f38901 | ||
|
|
cada2fde3e | ||
|
|
3fa4768d1c | ||
|
|
c1d55952eb | ||
|
|
4a6ddaa413 | ||
|
|
970155a8ac | ||
|
|
0cb78d2610 | ||
|
|
b064051efd | ||
|
|
0350255237 | ||
|
|
fd1188a797 | ||
|
|
66c69cdc90 | ||
|
|
1a2a24ceb1 | ||
|
|
5bb4ad5f0f | ||
|
|
d5d94fb483 | ||
|
|
905074defb | ||
|
|
b982fb9cd0 | ||
|
|
5c38029ec0 | ||
|
|
63e71a1f30 | ||
|
|
28ed24b28e | ||
|
|
bd343b2a4e | ||
|
|
a18d3b812f | ||
|
|
2ad52e7e9e | ||
|
|
6252c29efa | ||
|
|
9b4b46e243 | ||
|
|
33103ce5da | ||
|
|
8692cf8600 | ||
|
|
2f15c79176 | ||
|
|
cfbd044551 | ||
|
|
87f29cb0c8 | ||
|
|
a7477e679d | ||
|
|
72ad1b8036 | ||
|
|
bc05417450 | ||
|
|
25750c225b | ||
|
|
30bcabf172 | ||
|
|
b180fd732d | ||
|
|
d8a4e1567d | ||
|
|
76e4e6f58f | ||
|
|
06435bdefa | ||
|
|
bee079b1c8 | ||
|
|
6f4dbff317 | ||
|
|
394c13a099 | ||
|
|
76143e50d1 | ||
|
|
9d9a8ed031 | ||
|
|
7427a70971 | ||
|
|
b519fbd3d0 | ||
|
|
bbb1dc717e | ||
|
|
67e2647753 | ||
|
|
dc36b8400f | ||
|
|
a67711b3d3 | ||
|
|
c7e85676fe | ||
|
|
da77380a04 | ||
|
|
efeadec265 | ||
|
|
d76563dd5a | ||
|
|
8bfb06ed64 | ||
|
|
28496f27df | ||
|
|
2267d8beec | ||
|
|
0a70e0cf93 | ||
|
|
321e34a0da | ||
|
|
95903ab00e | ||
|
|
0e9b9c71e2 | ||
|
|
74257926b1 | ||
|
|
f7314b2661 | ||
|
|
07bd57d9e1 | ||
|
|
bc53361ff9 | ||
|
|
9ac8f23307 | ||
|
|
487c45f2bd | ||
|
|
2f71975194 | ||
|
|
3795bdb066 | ||
|
|
26a25fce3a | ||
|
|
9a2a9390e1 | ||
|
|
85e539af2b | ||
|
|
95f89a38a6 | ||
|
|
b13aa7385a | ||
|
|
7cb6fb0c9d | ||
|
|
0bccb395a4 | ||
|
|
2fd8cbe8e9 | ||
|
|
3efc8cddd7 | ||
|
|
ad05486388 | ||
|
|
2fe4129777 | ||
|
|
462adf2693 | ||
|
|
a46c0536cc | ||
|
|
25c2a18ab3 | ||
|
|
f102b082fd | ||
|
|
f7cdb766cd | ||
|
|
f2cd036890 | ||
|
|
749bdb09f5 | ||
|
|
d0fc8adb70 | ||
|
|
73d1cee9de | ||
|
|
51a3fd0bc9 | ||
|
|
aea0ce823d | ||
|
|
0c6f359cb5 | ||
|
|
bd5d27c52b | ||
|
|
3d005ab559 | ||
|
|
9cedb4efe6 | ||
|
|
9c50b7499b | ||
|
|
51e82624d5 | ||
|
|
6f12283dc0 | ||
|
|
ee78b85fd0 | ||
|
|
0524d3729d | ||
|
|
ced96568bc | ||
|
|
cc5d498b7e | ||
|
|
9379712c78 | ||
|
|
cc0c164574 | ||
|
|
c13bb2368e |
413 changed files with 16638 additions and 8069 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -95,7 +95,6 @@ Packages/packages-lock.json
|
|||
GameData/run.cc
|
||||
GameData/player.cc
|
||||
GameData/RemovedMods/
|
||||
Vault/Aetheria/.obsidian/plugins/copilot/data.json
|
||||
|
||||
# Agent scratch, sandboxed toolchain caches, and throwaway checkouts. These had
|
||||
# grown to 47 untracked entries; the worktree must stay legible, so they never
|
||||
|
|
|
|||
2
.lfsconfig
Normal file
2
.lfsconfig
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[lfs]
|
||||
url = https://eureka.gamecult.org/forge/GameCult/Aetheria.git/info/lfs
|
||||
|
|
@ -8,12 +8,12 @@ using UnityEditor.AddressableAssets.Settings;
|
|||
using UnityEditor.Build.Reporting;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
// The one way to make a Win64 player directory. Owns what the directory contains: Aetheria.exe and its data,
|
||||
// The one way to make a player directory, for Windows or Linux. Owns what the directory contains: the player and its data,
|
||||
// GameData/Aetheria.cc, GameData/Narrative/**, GameData/Mods/** and ModTools/AetherDb. Manual builds, smokes and
|
||||
// release cuts all call this and stage nothing themselves.
|
||||
//
|
||||
// Unity -batchmode -quit -projectPath <repo> -executeMethod AetheriaBuild.Build
|
||||
// -aetheriaVersion <semver> -aetheriaOut <dir> -logFile <log>
|
||||
// -aetheriaVersion <semver> -aetheriaOut <dir> [-aetheriaTarget win64|linux64] -logFile <log>
|
||||
//
|
||||
// AetherDb is published from tools/AetherDb, so the headless build's CULTLIB_ROOT / CULTMATH_ROOT (Directory.Build.props)
|
||||
// must be set for dotnet when the defaults do not hold the pinned revisions.
|
||||
|
|
@ -22,6 +22,14 @@ public static class AetheriaBuild
|
|||
{
|
||||
private static readonly Regex SemVer = new Regex(@"^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$");
|
||||
|
||||
// A target: the player Unity builds, its executable, and the runtime AetherDb is published for.
|
||||
private static (BuildTarget target, string player, string runtime, string aetherDb) Target(string name) => name switch
|
||||
{
|
||||
"win64" => (BuildTarget.StandaloneWindows64, "Aetheria.exe", "win-x64", "AetherDb.exe"),
|
||||
"linux64" => (BuildTarget.StandaloneLinux64, "Aetheria.x86_64", "linux-x64", "AetherDb"),
|
||||
_ => throw new InvalidOperationException($"-aetheriaTarget {name}: expected win64 or linux64"),
|
||||
};
|
||||
|
||||
public static void Build()
|
||||
{
|
||||
var step = "arguments";
|
||||
|
|
@ -30,6 +38,7 @@ public static class AetheriaBuild
|
|||
var version = Argument("-aetheriaVersion");
|
||||
if (!SemVer.IsMatch(version)) throw new InvalidOperationException("-aetheriaVersion is not a semver");
|
||||
var output = Path.GetFullPath(Argument("-aetheriaOut"));
|
||||
var target = Target(Argument("-aetheriaTarget", "win64"));
|
||||
var repoRoot = Directory.GetParent(UnityEngine.Application.dataPath).FullName;
|
||||
|
||||
step = "version";
|
||||
|
|
@ -46,8 +55,8 @@ public static class AetheriaBuild
|
|||
var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
|
||||
{
|
||||
scenes = scenes,
|
||||
locationPathName = Path.Combine(output, "Aetheria.exe"),
|
||||
target = BuildTarget.StandaloneWindows64,
|
||||
locationPathName = Path.Combine(output, target.player),
|
||||
target = target.target,
|
||||
options = BuildOptions.None,
|
||||
});
|
||||
if (report.summary.result != BuildResult.Succeeded || report.summary.totalErrors != 0)
|
||||
|
|
@ -57,7 +66,7 @@ public static class AetheriaBuild
|
|||
PlayerStaging.Stage(repoRoot, output);
|
||||
|
||||
step = "aetherdb";
|
||||
Publish(repoRoot, Path.Combine(output, "ModTools"));
|
||||
Publish(repoRoot, Path.Combine(output, "ModTools"), target.runtime, target.aetherDb);
|
||||
|
||||
step = "burst";
|
||||
var burst = Path.Combine(output, "Aetheria_BurstDebugInformation_DoNotShip");
|
||||
|
|
@ -74,11 +83,11 @@ public static class AetheriaBuild
|
|||
EditorApplication.Exit(0);
|
||||
}
|
||||
|
||||
private static void Publish(string repoRoot, string modTools)
|
||||
private static void Publish(string repoRoot, string modTools, string runtime, string aetherDb)
|
||||
{
|
||||
var info = new ProcessStartInfo("dotnet")
|
||||
{
|
||||
Arguments = $"publish tools/AetherDb -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o \"{modTools}\"",
|
||||
Arguments = $"publish tools/AetherDb -c Release -r {runtime} --self-contained -p:PublishSingleFile=true -o \"{modTools}\"",
|
||||
WorkingDirectory = repoRoot,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
|
|
@ -92,15 +101,16 @@ public static class AetheriaBuild
|
|||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException($"dotnet publish exited {process.ExitCode}:\n{text}\n{errors.Result}");
|
||||
}
|
||||
if (!File.Exists(Path.Combine(modTools, "AetherDb.exe")))
|
||||
throw new InvalidOperationException("dotnet publish produced no ModTools/AetherDb.exe");
|
||||
if (!File.Exists(Path.Combine(modTools, aetherDb)))
|
||||
throw new InvalidOperationException($"dotnet publish produced no ModTools/{aetherDb}");
|
||||
}
|
||||
|
||||
private static string Argument(string name)
|
||||
private static string Argument(string name, string fallback = null)
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
var at = Array.IndexOf(args, name);
|
||||
if (at < 0 || at + 1 >= args.Length) throw new InvalidOperationException(name + " is required");
|
||||
if (at < 0 || at + 1 >= args.Length)
|
||||
return fallback ?? throw new InvalidOperationException(name + " is required");
|
||||
return args[at + 1];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
Binary file not shown.
|
|
@ -122,7 +122,7 @@ namespace Aetheria.EditorTools
|
|||
var cache = AetheriaStores.Open(Path.Combine(_work, "belts.cc"), Path.Combine(_work, "belts-run.cc"), catalogWritable: true);
|
||||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
cache.FlushAsync().Wait();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
|
||||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
||||
|
||||
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public static class CapabilityPresentationSmoke
|
|||
cache.FlushAsync().Wait();
|
||||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null);
|
||||
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
|
||||
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ namespace Aetheria.EditorTools
|
|||
cache.Upsert(new SimpleCommodityData { Name = "Wiring Ore", Shape = oneCell, MaxStack = 100 });
|
||||
cache.FlushAsync().Wait();
|
||||
var ledger = new ProvenanceLedger();
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
|
||||
|
||||
// A sun and a planet on a moving rail round it, both near the bodies below: the field under a floater changes with the
|
||||
// drawn time, so a presenter that samples it at the step's time, drops it, or reads lead 0 for the height draws a different y.
|
||||
|
|
@ -159,7 +159,7 @@ namespace Aetheria.EditorTools
|
|||
MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f
|
||||
};
|
||||
var projectile = new GameObject("Wiring Round").AddComponent<Projectile>();
|
||||
projectile.Launch(zone, shot, new Vector3(0, 0, 0));
|
||||
projectile.Launch(null, zone, shot, new Vector3(0, 0, 0));
|
||||
yield return null;
|
||||
|
||||
zone.Update(.5f);
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ public static class FloatingItemsDrawnSmoke
|
|||
cache.FlushAsync().Wait();
|
||||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null);
|
||||
EquippableItem Make(string name, int lot) => new EquippableItem
|
||||
{
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ namespace Aetheria.EditorTools
|
|||
var cache = AetheriaStores.Open(Path.Combine(work, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
|
||||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
cache.FlushAsync().Wait();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack { Time = time }, new GalaxyZone { Name = "Parity", Owner = null }, null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ namespace Aetheria.EditorTools
|
|||
var cache = AetheriaStores.Open(Path.Combine(work, "catalog.cc"), Path.Combine(work, "run.cc"), catalogWritable: true);
|
||||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
cache.FlushAsync().Wait();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
var positions = new List<Vector2> { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) };
|
||||
var masses = new List<float> { 14152f, 273.9f, 29.2f, 0f };
|
||||
var parents = new List<int> { -1, 0, 1, -1 };
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public static class MapBeltPerf
|
|||
Planets = { new CultRecordRef<BodyData>(sunKey), new CultRecordRef<BodyData>(beltKey) },
|
||||
Radius = 5000f, Mass = 10000f, Time = 0
|
||||
};
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings
|
||||
{
|
||||
BodyRadius = new ExponentialCurve(), WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ public static class MapPresenterSmoke
|
|||
};
|
||||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings.GameplaySettings, _ => { });
|
||||
var items = new ItemManager(1u, cache, ledger, settings.GameplaySettings, _ => { });
|
||||
var zone = new Zone(items, planetSettings, pack, new GalaxyZone { Name = "Smoke", Owner = null }, null);
|
||||
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
|
||||
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
|
||||
|
|
|
|||
108
Assets/Scripts/Editor/ScenarioPickerCapture.cs
Normal file
108
Assets/Scripts/Editor/ScenarioPickerCapture.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// A render of the New Game screen as the game builds it: the real Main Menu scene's panel prototype, docked and filled
|
||||
// by ScenarioPicker.Dock and ScenarioPicker.Show with every scenario group, on a plain dark background (the cloud
|
||||
// backdrop is a play-mode renderer). Writes <project>/Temp/scenario-picker.png at 1920x1080, or the path after -pngOut (Unity empties Temp on exit).
|
||||
//
|
||||
// Unity -batchmode -projectPath <project> -executeMethod ScenarioPickerCapture.Run [-pngOut <file>] -logFile <log>
|
||||
//
|
||||
// Needs a GPU device; do not pass -nographics or -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
|
||||
public static class ScenarioPickerCapture
|
||||
{
|
||||
private const int Width = 1920, Height = 1080;
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
Capture();
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// The menu's two panes built the way MainMenu.Start builds them, against the Main Menu scene.
|
||||
internal static (MainMenu menu, PropertiesPanel list, PropertiesPanel detail) Build()
|
||||
{
|
||||
EditorSceneManager.OpenScene("Assets/Scenes/Main Menu.unity", OpenSceneMode.Single);
|
||||
var menu = UnityEngine.Object.FindObjectsByType<MainMenu>(FindObjectsInactive.Include, FindObjectsSortMode.None)[0];
|
||||
menu.PanelPrototype.gameObject.SetActive(false);
|
||||
var list = menu.PanelPrototype.Instantiate<PropertiesPanel>();
|
||||
var detail = menu.PanelPrototype.Instantiate<PropertiesPanel>();
|
||||
// Edit mode runs no Awake, which is what activates each row a panel adds.
|
||||
list.Awake();
|
||||
detail.Awake();
|
||||
ScenarioPicker.Dock(detail, list);
|
||||
return (menu, list, detail);
|
||||
}
|
||||
|
||||
internal static List<(string label, Scenario[] scenarios)> AllGroups() => new List<(string label, Scenario[] scenarios)>
|
||||
{
|
||||
("modes", Scenarios.Modes), ("development", Scenarios.Development), ("test arenas", Scenarios.Tests)
|
||||
};
|
||||
|
||||
private static void Capture()
|
||||
{
|
||||
var (menu, list, detail) = Build();
|
||||
menu.Dialog.gameObject.SetActive(false);
|
||||
list.Title.text = MainMenu.TitleSubtitle("new game", "scenarios");
|
||||
ScenarioPicker.Show(list, detail, AllGroups(), _ => { }, () => { }, menu.Dialog.Confirm);
|
||||
list.RefreshValues();
|
||||
detail.RefreshValues();
|
||||
|
||||
var canvas = menu.GetComponentInParent<Canvas>(true).rootCanvas;
|
||||
var target = new RenderTexture(Width, Height, 24, RenderTextureFormat.ARGB32);
|
||||
var cameraObject = new GameObject("ScenarioPickerCapture camera");
|
||||
var camera = cameraObject.AddComponent<Camera>();
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = 5;
|
||||
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||
camera.backgroundColor = new Color(.04f, .05f, .08f);
|
||||
camera.cullingMask = 1 << LayerMask.NameToLayer("UI");
|
||||
camera.targetTexture = target;
|
||||
canvas.renderMode = RenderMode.ScreenSpaceCamera;
|
||||
canvas.worldCamera = camera;
|
||||
canvas.planeDistance = 10;
|
||||
canvas.gameObject.SetActive(true);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) list.transform);
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) detail.transform);
|
||||
foreach (var text in list.transform.GetComponentsInChildren<TMPro.TMP_Text>())
|
||||
text.ForceMeshUpdate();
|
||||
Canvas.ForceUpdateCanvases();
|
||||
list.Content.GetComponentInParent<ScrollRect>().onValueChanged.Invoke(Vector2.zero);
|
||||
camera.Render();
|
||||
|
||||
var previous = RenderTexture.active;
|
||||
RenderTexture.active = target;
|
||||
var read = new Texture2D(Width, Height, TextureFormat.RGBA32, false);
|
||||
read.ReadPixels(new Rect(0, 0, Width, Height), 0, 0);
|
||||
RenderTexture.active = previous;
|
||||
|
||||
var background = read.GetPixel(0, 0);
|
||||
var differing = 0;
|
||||
foreach (var pixel in read.GetPixels32())
|
||||
if (Mathf.Abs(pixel.r / 255f - background.r) + Mathf.Abs(pixel.g / 255f - background.g) + Mathf.Abs(pixel.b / 255f - background.b) > .05f)
|
||||
differing++;
|
||||
if (differing < Width * Height / 100)
|
||||
throw new InvalidOperationException($"The capture is uniform: only {differing} pixels differ from the background.");
|
||||
|
||||
var path = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Temp", "scenario-picker.png"));
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
var outIndex = Array.IndexOf(args, "-pngOut");
|
||||
if (outIndex >= 0) path = Path.GetFullPath(args[outIndex + 1]);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllBytes(path, read.EncodeToPNG());
|
||||
Debug.Log($"[ScenarioPickerCapture] {path} ({differing} of {Width * Height} pixels differ from the background)");
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ScenarioPickerCapture.cs.meta
Normal file
2
Assets/Scripts/Editor/ScenarioPickerCapture.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2e79f280179150e4080250c45e07cd69
|
||||
265
Assets/Scripts/Editor/ScenarioPickerVerify.cs
Normal file
265
Assets/Scripts/Editor/ScenarioPickerVerify.cs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// The picker's selection and keyboard rules, driven through the events the game delivers: Move and Cancel reach the
|
||||
// focused Launch button through ExecuteEvents, as the Input System UI module sends them, and a click is Button.onClick.
|
||||
//
|
||||
// Unity -batchmode -projectPath <project> -executeMethod ScenarioPickerVerify.Run -logFile <log>
|
||||
//
|
||||
// Exits the Editor itself, 0 when every rule holds and 1 on the first that does not.
|
||||
public static class ScenarioPickerVerify
|
||||
{
|
||||
private sealed class Fake : Scenario
|
||||
{
|
||||
private readonly uint _seed;
|
||||
private readonly bool _ambient;
|
||||
public Fake(string name, uint seed = 0, bool ambient = true) { Name = name; _seed = seed; _ambient = ambient; }
|
||||
public override string Name { get; }
|
||||
public override string Id => Name.ToLowerInvariant();
|
||||
public override string Brief => $"brief of {Name}";
|
||||
public override uint Seed => _seed;
|
||||
public override bool Ambient => _ambient;
|
||||
public override Galaxy Generate(GalaxyStage stage) => throw new NotSupportedException();
|
||||
public override void Stage(ScenarioStage stage) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class Screen
|
||||
{
|
||||
public ScenarioPicker Picker;
|
||||
public PropertiesPanel List, Detail;
|
||||
public Button[] Rows;
|
||||
public Button Back, Launch;
|
||||
public readonly List<Scenario> Launched = new List<Scenario>();
|
||||
public int Backs;
|
||||
public Scenario[] A, B, C;
|
||||
}
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
SelectionFollowsClicksAndArrows();
|
||||
ArrowsStopAtTheEnds();
|
||||
LeftAndRightAreIgnored();
|
||||
LaunchIsOnePathAndFocusClears();
|
||||
CancelAndBackAreOnePath();
|
||||
DetailPaneFollowsSelection();
|
||||
OneGroupHasNoSectionLabel();
|
||||
LaunchesOnceHoweverItIsReached();
|
||||
ArrowingThroughTheRealListKeepsTheSelectionVisible();
|
||||
Debug.Log("[ScenarioPickerVerify] ok");
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string what)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException(what);
|
||||
}
|
||||
|
||||
private static Screen Build(bool oneGroup = false, bool real = false)
|
||||
{
|
||||
var (menu, list, detail) = ScenarioPickerCapture.Build();
|
||||
// Build opens the scene afresh, which destroys the previous EventSystem.
|
||||
var events = new GameObject("verify EventSystem").AddComponent<EventSystem>();
|
||||
// Edit mode never calls OnEnable, which is what registers an EventSystem as current.
|
||||
typeof(EventSystem).GetMethod("OnEnable", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(events, null);
|
||||
var screen = new Screen
|
||||
{
|
||||
List = list, Detail = detail,
|
||||
A = new Scenario[] { new Fake("Alpha", 7), new Fake("Beta") },
|
||||
B = new Scenario[] { new Fake("Gamma", ambient: false) },
|
||||
C = new Scenario[] { new Fake("Delta"), new Fake("Echo") }
|
||||
};
|
||||
var groups = real ? ScenarioPickerCapture.AllGroups() : oneGroup
|
||||
? new List<(string label, Scenario[] scenarios)> { ("modes", screen.A) }
|
||||
: new List<(string label, Scenario[] scenarios)> { ("modes", screen.A), ("development", screen.B), ("test arenas", screen.C) };
|
||||
// The menu builds a panel while it is inactive and shows it afterwards; the picker must find its scroll all the same.
|
||||
if (real) list.gameObject.SetActive(false);
|
||||
screen.Picker = ScenarioPicker.Show(list, detail, groups, screen.Launched.Add, () => screen.Backs++, menu.Dialog.Confirm);
|
||||
var buttons = list.GetComponentsInChildren<PropertyButton>(false).Where(button => !button.transform.IsChildOf(detail.transform)).Select(button => button.Button).ToArray();
|
||||
screen.Rows = buttons.Take(buttons.Length - 1).ToArray();
|
||||
screen.Back = buttons.Last();
|
||||
screen.Launch = detail.GetComponentsInChildren<Button>(false).Single();
|
||||
if (real) { list.gameObject.SetActive(true); Layout(menu, list, detail); }
|
||||
return screen;
|
||||
}
|
||||
|
||||
// A live 1920x1080 layout, as the game has one a frame after Show; edit mode has no frames, so it is forced here.
|
||||
private static void Layout(MainMenu menu, PropertiesPanel list, PropertiesPanel detail)
|
||||
{
|
||||
menu.Dialog.gameObject.SetActive(false);
|
||||
var canvas = menu.GetComponentInParent<Canvas>(true).rootCanvas;
|
||||
var camera = new GameObject("verify camera").AddComponent<Camera>();
|
||||
camera.targetTexture = new RenderTexture(1920, 1080, 24);
|
||||
canvas.renderMode = RenderMode.ScreenSpaceCamera;
|
||||
canvas.worldCamera = camera;
|
||||
canvas.planeDistance = 10;
|
||||
canvas.gameObject.SetActive(true);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) canvas.transform);
|
||||
foreach (var text in list.GetComponentsInChildren<TMP_Text>()) text.ForceMeshUpdate();
|
||||
Canvas.ForceUpdateCanvases();
|
||||
Scroll(list).onValueChanged.Invoke(Vector2.zero);
|
||||
}
|
||||
|
||||
private static ScrollRect Scroll(PropertiesPanel list) => list.Content.GetComponentInParent<ScrollRect>();
|
||||
|
||||
// Wholly inside the viewport, in world space, and not hidden by FitViewport's CanvasGroup.
|
||||
private static bool Shown(Button row, ScrollRect scroll)
|
||||
{
|
||||
var view = new Vector3[4];
|
||||
((RectTransform) scroll.viewport).GetWorldCorners(view);
|
||||
var rect = new Vector3[4];
|
||||
((RectTransform) row.transform).GetWorldCorners(rect);
|
||||
var alpha = 1f;
|
||||
for (var t = row.transform; t != null && t != scroll.content; t = t.parent)
|
||||
if (t.TryGetComponent<CanvasGroup>(out var group)) alpha = Mathf.Min(alpha, group.alpha);
|
||||
return rect[0].y >= view[0].y - .01f && rect[1].y <= view[1].y + .01f && alpha == 1;
|
||||
}
|
||||
|
||||
private static void Move(Screen screen, MoveDirection direction) =>
|
||||
ExecuteEvents.Execute(screen.Launch.gameObject, new AxisEventData(EventSystem.current) { moveDir = direction }, ExecuteEvents.moveHandler);
|
||||
|
||||
private static void Cancel(Screen screen) =>
|
||||
ExecuteEvents.Execute(screen.Launch.gameObject, new BaseEventData(EventSystem.current), ExecuteEvents.cancelHandler);
|
||||
|
||||
private static string Selected(Screen screen) => screen.Picker.Selected.Name;
|
||||
|
||||
private static void SelectionFollowsClicksAndArrows()
|
||||
{
|
||||
var screen = Build();
|
||||
Require(screen.Rows.Length == 5, $"Expected 5 scenario rows, found {screen.Rows.Length}.");
|
||||
Require(Selected(screen) == "Alpha", "The first scenario is not selected on Show.");
|
||||
Require(EventSystem.current.currentSelectedGameObject == screen.Launch.gameObject, "Launch is not focused on Show.");
|
||||
Move(screen, MoveDirection.Down);
|
||||
Require(Selected(screen) == "Beta", "Down did not select the next row.");
|
||||
Move(screen, MoveDirection.Down);
|
||||
Require(Selected(screen) == "Gamma", "Down did not cross into the next group.");
|
||||
Move(screen, MoveDirection.Up);
|
||||
Require(Selected(screen) == "Beta", "Up did not select the previous row.");
|
||||
screen.Rows[3].onClick.Invoke();
|
||||
Require(Selected(screen) == "Delta", "Clicking a name did not select it.");
|
||||
Require(screen.Launched.Count == 0, "Clicking a name launched it.");
|
||||
Require(EventSystem.current.currentSelectedGameObject == screen.Launch.gameObject, "A click on a name took focus off Launch.");
|
||||
Require(screen.Rows.All(row => row.navigation.mode == Navigation.Mode.None) && screen.Launch.navigation.mode == Navigation.Mode.None
|
||||
&& screen.Back.navigation.mode == Navigation.Mode.None, "A picker button still takes Unity navigation.");
|
||||
var accent = screen.Rows[3].targetGraphic.color;
|
||||
Require(screen.Rows.Where((row, i) => i != 3).All(row => row.targetGraphic.color != accent), "Only the selected row is drawn in the accent.");
|
||||
Move(screen, MoveDirection.Up);
|
||||
Require(screen.Rows[2].targetGraphic.color == accent && screen.Rows[3].targetGraphic.color != accent, "The accent did not follow the selection.");
|
||||
}
|
||||
|
||||
private static void ArrowsStopAtTheEnds()
|
||||
{
|
||||
var screen = Build();
|
||||
Move(screen, MoveDirection.Up);
|
||||
Require(Selected(screen) == "Alpha", "Up wrapped off the first row.");
|
||||
for (var i = 0; i < 10; i++) Move(screen, MoveDirection.Down);
|
||||
Require(Selected(screen) == "Echo", "Down wrapped or stopped short of the last row.");
|
||||
}
|
||||
|
||||
private static void LeftAndRightAreIgnored()
|
||||
{
|
||||
var screen = Build();
|
||||
Move(screen, MoveDirection.Down);
|
||||
Move(screen, MoveDirection.Left);
|
||||
Move(screen, MoveDirection.Right);
|
||||
Require(Selected(screen) == "Beta", "Left or Right moved the selection.");
|
||||
}
|
||||
|
||||
private static void LaunchIsOnePathAndFocusClears()
|
||||
{
|
||||
var screen = Build();
|
||||
Move(screen, MoveDirection.Down);
|
||||
screen.Launch.onClick.Invoke();
|
||||
Require(screen.Launched.Count == 1 && screen.Launched[0] == screen.A[1], "Launch did not launch the selected scenario once.");
|
||||
Require(EventSystem.current.currentSelectedGameObject == null, "Focus survived Launch, so a second Enter could launch again.");
|
||||
Require(screen.Backs == 0, "Launch went back.");
|
||||
}
|
||||
|
||||
private static void CancelAndBackAreOnePath()
|
||||
{
|
||||
var screen = Build();
|
||||
Cancel(screen);
|
||||
Require(screen.Backs == 1 && screen.Launched.Count == 0, "Cancel did not go back exactly once.");
|
||||
Require(EventSystem.current.currentSelectedGameObject == null, "Focus survived Cancel.");
|
||||
screen = Build();
|
||||
screen.Back.onClick.Invoke();
|
||||
Require(screen.Backs == 1 && screen.Launched.Count == 0, "The Back button did not go back exactly once.");
|
||||
Require(EventSystem.current.currentSelectedGameObject == null, "Focus survived Back.");
|
||||
}
|
||||
|
||||
private static string DetailText(Screen screen)
|
||||
{
|
||||
screen.Detail.RefreshValues();
|
||||
return string.Join("|", screen.Detail.GetComponentsInChildren<TextMeshProUGUI>(false).Select(text => text.text));
|
||||
}
|
||||
|
||||
private static void DetailPaneFollowsSelection()
|
||||
{
|
||||
var screen = Build();
|
||||
var first = DetailText(screen);
|
||||
Require(first.Contains("brief of Alpha") && first.Contains("7") && first.Contains("on"), $"Detail does not describe Alpha: {first}");
|
||||
Require(first.Contains("modes") && first.Contains("Alpha"), "Detail heading is not the group and name.");
|
||||
Move(screen, MoveDirection.Down);
|
||||
Move(screen, MoveDirection.Down);
|
||||
var third = DetailText(screen);
|
||||
Require(third.Contains("brief of Gamma") && third.Contains("new each launch") && third.Contains("off"), $"Detail does not describe Gamma: {third}");
|
||||
Require(third.Contains("development") && !third.Contains("Alpha"), "Detail heading did not follow the selection.");
|
||||
Require(screen.Detail.GetComponentsInChildren<Button>(false).Single() == screen.Launch, "Selecting rebuilt the Launch button.");
|
||||
}
|
||||
|
||||
private static void OneGroupHasNoSectionLabel()
|
||||
{
|
||||
var many = Build();
|
||||
var one = Build(oneGroup: true);
|
||||
Require(many.List.Children == 5 + 3 + 2 + 1, $"Three groups should list 5 rows, 3 section labels, 2 spacers and Back, not {many.List.Children} children.");
|
||||
Require(one.List.Children == 2 + 1, $"One group should list its 2 rows and Back, not {one.List.Children} children.");
|
||||
}
|
||||
|
||||
private static void LaunchesOnceHoweverItIsReached()
|
||||
{
|
||||
var screen = Build();
|
||||
screen.Launch.onClick.Invoke();
|
||||
screen.Launch.onClick.Invoke();
|
||||
ExecuteEvents.Execute(screen.Launch.gameObject, new BaseEventData(EventSystem.current), ExecuteEvents.submitHandler);
|
||||
Require(screen.Launched.Count == 1, $"Launch ran {screen.Launched.Count} times after a click, a second click and Submit.");
|
||||
Require(screen.Launch.navigation.mode == Navigation.Mode.None && Scroll(screen.List).verticalScrollbar.navigation.mode == Navigation.Mode.None,
|
||||
"The list scrollbar or Launch takes Unity navigation.");
|
||||
}
|
||||
|
||||
// The real scenario groups overflow the list's viewport; every row the arrows reach must end wholly visible.
|
||||
private static void ArrowingThroughTheRealListKeepsTheSelectionVisible()
|
||||
{
|
||||
var screen = Build(real: true);
|
||||
var scroll = Scroll(screen.List);
|
||||
var content = (RectTransform) scroll.content;
|
||||
var viewport = (RectTransform) scroll.viewport;
|
||||
Require(content.rect.height > viewport.rect.height + 1, $"The real list ({content.rect.height}) does not overflow its viewport ({viewport.rect.height}); the scroll rules are unmeasured.");
|
||||
Require(Shown(screen.Rows[0], scroll), "The first row is not shown on Show.");
|
||||
for (var i = 1; i < screen.Rows.Length; i++)
|
||||
{
|
||||
Move(screen, MoveDirection.Down);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
Require(Shown(screen.Rows[i], scroll), $"Down to row {i} ({Selected(screen)}) left it hidden or cut off.");
|
||||
}
|
||||
for (var i = screen.Rows.Length - 2; i >= 0; i--)
|
||||
{
|
||||
Move(screen, MoveDirection.Up);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
Require(Shown(screen.Rows[i], scroll), $"Up to row {i} ({Selected(screen)}) left it hidden or cut off.");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ScenarioPickerVerify.cs.meta
Normal file
2
Assets/Scripts/Editor/ScenarioPickerVerify.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7ca0b5db32f745a43a8fdc67dfac2996
|
||||
|
|
@ -75,7 +75,7 @@ public class ActionBarSlot : MonoBehaviour
|
|||
this,
|
||||
entity.Equipment[savedActionBarGearBinding.EquipmentIndex],
|
||||
entity.Equipment[savedActionBarGearBinding.EquipmentIndex]
|
||||
.Behaviors[savedActionBarGearBinding.BehaviorIndex] as IActivatedBehavior),
|
||||
.Behaviors[savedActionBarGearBinding.BehaviorIndex] as ActivatedBehavior),
|
||||
SavedActionBarWeaponGroupBinding savedActionBarWeaponGroupBinding =>
|
||||
new ActionBarWeaponGroupBinding(entity, this, savedActionBarWeaponGroupBinding.Group),
|
||||
_ => null
|
||||
|
|
@ -117,7 +117,7 @@ public class ActionBarConsumableBinding : ActionBarBinding
|
|||
|
||||
public override void Activate()
|
||||
{
|
||||
Entity.TryActivateConsumable(Target);
|
||||
Entity.Command.Use(Target);
|
||||
}
|
||||
|
||||
public override void Deactivate()
|
||||
|
|
@ -136,11 +136,11 @@ public class ActionBarConsumableBinding : ActionBarBinding
|
|||
public class ActionBarGearBinding : ActionBarBinding
|
||||
{
|
||||
public EquippedItem Item { get; }
|
||||
public IActivatedBehavior Behavior { get; }
|
||||
public ActivatedBehavior Behavior { get; }
|
||||
|
||||
public bool Active;
|
||||
|
||||
public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, IActivatedBehavior behavior) : base(entity, slot)
|
||||
public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, ActivatedBehavior behavior) : base(entity, slot)
|
||||
{
|
||||
Item = item;
|
||||
var data = Entity.ItemManager.GetData(item.EquippableItem);
|
||||
|
|
@ -155,16 +155,17 @@ public class ActionBarGearBinding : ActionBarBinding
|
|||
else Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(data.HardpointType).texture;
|
||||
}
|
||||
|
||||
// The press and the let-go are each one trigger edge, applied at the next step; Active is the slot's display.
|
||||
public override void Activate()
|
||||
{
|
||||
Active = true;
|
||||
Behavior.Activate();
|
||||
Entity.Command.Pull(Behavior);
|
||||
}
|
||||
|
||||
public override void Deactivate()
|
||||
{
|
||||
Active = false;
|
||||
Behavior.Deactivate();
|
||||
Entity.Command.Release(Behavior);
|
||||
}
|
||||
|
||||
public override void Update()
|
||||
|
|
@ -190,7 +191,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding
|
|||
{
|
||||
foreach (var weapon in Entity.WeaponGroups[Group].weapons)
|
||||
{
|
||||
weapon.Activate();
|
||||
Entity.Command.Pull(weapon);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +199,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding
|
|||
{
|
||||
foreach (var weapon in Entity.WeaponGroups[Group].weapons)
|
||||
{
|
||||
weapon.Deactivate();
|
||||
Entity.Command.Release(weapon);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
/* 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/. */
|
||||
|
||||
|
|
@ -201,6 +201,10 @@ public class ActionGameManager : MonoBehaviour
|
|||
private float3 _viewDirection;
|
||||
// The player's face-the-aim state (Left Shift held, Caps Lock latched); Ship.Turn is its only output.
|
||||
private readonly HelmInput _helm = new HelmInput();
|
||||
// Seconds a held turn key takes to reach full turn rate.
|
||||
public float KeyboardTurnRampTime = .4f;
|
||||
// Whether the turn axis was last driven by keys; kept while released so the ramp down still applies.
|
||||
private bool _turnDigital;
|
||||
private TextMeshProUGUI _faceAimMark;
|
||||
private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups;
|
||||
// The outgoing progress indicators on the player's target: one per lock weapon (its Lock, shown only on a hostile target)
|
||||
|
|
@ -224,6 +228,10 @@ public class ActionGameManager : MonoBehaviour
|
|||
private bool _menuShown;
|
||||
private List<ActionBarSlot> _actionBarSlots = new List<ActionBarSlot>();
|
||||
private List<InputAction> _actionBarActions = new List<InputAction>();
|
||||
// Each action bar slot's control path, index for index with _actionBarSlots.
|
||||
private List<string> _actionBarPaths = new List<string>();
|
||||
// Ships whose action bar has been set up: the default weapon-group binding runs once per ship, then the bar is the player's.
|
||||
private HashSet<Entity> _actionBarSetUp = new HashSet<Entity>();
|
||||
private float _hitMarkerTime;
|
||||
|
||||
public AetheriaInput Input { get; private set; }
|
||||
|
|
@ -248,7 +256,7 @@ public class ActionGameManager : MonoBehaviour
|
|||
|
||||
public ItemManager ItemManager { get; private set; }
|
||||
public Zone Zone { get; private set; }
|
||||
public static SimClock Clock { get; } = new SimClock(1f / 60f);
|
||||
public static SimClock Clock { get; } = SimClock.ForGame();
|
||||
private bool _zoneEntered;
|
||||
|
||||
private readonly (float2 direction, string name)[] _directions = {
|
||||
|
|
@ -294,7 +302,11 @@ public class ActionGameManager : MonoBehaviour
|
|||
ConsoleController.MessageReceiver = this;
|
||||
|
||||
// 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),
|
||||
// A new run is reseeded by RunStart; Continue's item draws come from the saved run's seed.
|
||||
ItemManager = new ItemManager(
|
||||
CurrentGalaxy == null ? RunStart.NoRunSeed
|
||||
: RunStart.ItemSeed(CurrentGalaxy, PendingScenario != null ? RunStart.ArenaStep : RunStart.ContinueStep),
|
||||
CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache),
|
||||
Settings.GameplaySettings, Debug.Log);
|
||||
ZoneRenderer.ItemManager = ItemManager;
|
||||
|
||||
|
|
@ -392,53 +404,34 @@ public class ActionGameManager : MonoBehaviour
|
|||
ActionBar.gameObject.SetActive(!_uiHidden);
|
||||
};
|
||||
|
||||
Input.Player.OverrideShutdown.performed += context =>
|
||||
{
|
||||
CurrentEntity.OverrideShutdown = !CurrentEntity.OverrideShutdown;
|
||||
};
|
||||
|
||||
Input.Player.Ping.performed += context =>
|
||||
{
|
||||
CurrentEntity.Sensor?.Ping();
|
||||
};
|
||||
|
||||
Input.Player.ToggleHeatsinks.performed += context =>
|
||||
{
|
||||
CurrentEntity.HeatsinksEnabled = !CurrentEntity.HeatsinksEnabled;
|
||||
// TODO: SFX: Success/Fail
|
||||
};
|
||||
|
||||
Input.Player.ToggleShield.performed += context =>
|
||||
{
|
||||
if (CurrentEntity.Shield != null)
|
||||
{
|
||||
CurrentEntity.Shield.Item.Enabled.Value = !CurrentEntity.Shield.Item.Enabled.Value;
|
||||
// TODO: SFX: Success/Fail
|
||||
}
|
||||
};
|
||||
// The switches and the ping are presses on the entity's command, flipped at the next step.
|
||||
Input.Player.OverrideShutdown.performed += context => CurrentEntity.Command.ToggleOverrideShutdown();
|
||||
Input.Player.Ping.performed += context => CurrentEntity.Command.Ping();
|
||||
// TODO: SFX: Success/Fail
|
||||
Input.Player.ToggleHeatsinks.performed += context => CurrentEntity.Command.ToggleHeatsinks();
|
||||
// TODO: SFX: Success/Fail
|
||||
Input.Player.ToggleShield.performed += context => CurrentEntity.Command.ToggleShield();
|
||||
|
||||
// Flips the player's own stance on the current target between hostile and neutral. Going
|
||||
// neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target
|
||||
// reads as non-hostile here, so the first press declares hostile.
|
||||
Input.Player.ToggleStance.performed += context =>
|
||||
{
|
||||
var target = CurrentEntity.Target.Value.Entity;
|
||||
if (target == null) return;
|
||||
CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target));
|
||||
};
|
||||
// reads as non-hostile, so the first press declares hostile. The flip reads the target and its stance when applied,
|
||||
// after the step's target presses, so two presses in a step cancel.
|
||||
Input.Player.ToggleStance.performed += context => CurrentEntity.Command.ToggleStance();
|
||||
|
||||
#region Targeting
|
||||
|
||||
// The reticle, nearest, next and previous presses each choose through Entity (mining target-queries cut):
|
||||
// the rule lives with the entity, so the player, the test agent and any director pick alike.
|
||||
Input.Player.TargetReticle.performed += context => CurrentEntity.TargetUnderReticle();
|
||||
Input.Player.TargetNearest.performed += context => CurrentEntity.TargetNearestEnemy();
|
||||
Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext();
|
||||
Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious();
|
||||
// The reticle, nearest, next and previous presses each command a verb; the applier chooses through Entity at the
|
||||
// next step (mining target-queries cut): the rule lives with the entity, so the player, the test agent and any
|
||||
// director pick alike.
|
||||
Input.Player.TargetReticle.performed += context => CurrentEntity.Command.Verb(TargetVerb.UnderReticle);
|
||||
Input.Player.TargetNearest.performed += context => CurrentEntity.Command.Verb(TargetVerb.NearestEnemy);
|
||||
Input.Player.TargetNext.performed += context => CurrentEntity.Command.Verb(TargetVerb.Next);
|
||||
Input.Player.TargetPrevious.performed += context => CurrentEntity.Command.Verb(TargetVerb.Previous);
|
||||
|
||||
// Cut 2 (docs/fire-control-cut.md): steps the aim through the target's schematic rows -- every mount this ship
|
||||
// has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle.
|
||||
Input.Player.CycleTargetItem.performed += context => CurrentEntity.CycleAim();
|
||||
// has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle, applied at
|
||||
// the next step.
|
||||
Input.Player.CycleTargetItem.performed += context => CurrentEntity.Command.CycleAim();
|
||||
|
||||
#endregion
|
||||
|
||||
|
|
@ -452,6 +445,7 @@ public class ActionGameManager : MonoBehaviour
|
|||
var slot = Instantiate(ActionBarSlot, ActionBar);
|
||||
slot.Binding = null;
|
||||
_actionBarSlots.Add(slot);
|
||||
_actionBarPaths.Add(controlPath);
|
||||
action.started += context => slot.Binding?.Activate();
|
||||
action.canceled += context => slot.Binding?.Deactivate();
|
||||
|
||||
|
|
@ -477,7 +471,7 @@ public class ActionGameManager : MonoBehaviour
|
|||
switch (dragAction)
|
||||
{
|
||||
case EquippedItemDragObject equippedItemDragAction:
|
||||
var trigger = equippedItemDragAction.EquippedItem.GetBehavior<IActivatedBehavior>();
|
||||
var trigger = equippedItemDragAction.EquippedItem.GetBehavior<ActivatedBehavior>();
|
||||
if (trigger == null) return false;
|
||||
slot.Binding = new ActionBarGearBinding(CurrentEntity, slot, equippedItemDragAction.EquippedItem, trigger);
|
||||
return true;
|
||||
|
|
@ -510,16 +504,6 @@ public class ActionGameManager : MonoBehaviour
|
|||
|
||||
StartGame();
|
||||
|
||||
//if (!PlayerSettings.InputSettings.ActionBarInputs.Any())
|
||||
{
|
||||
var newbinds = Enumerable.Range(0, 64)//CurrentEntity.WeaponGroups
|
||||
.Zip(
|
||||
bindings,
|
||||
(i, slot) =>
|
||||
slot.Binding = new ActionBarWeaponGroupBinding(CurrentEntity, slot, i)
|
||||
);
|
||||
}
|
||||
|
||||
ConsoleController.AddCommand("revealzones",
|
||||
_ =>
|
||||
{
|
||||
|
|
@ -593,6 +577,33 @@ public class ActionGameManager : MonoBehaviour
|
|||
ItemManager.Random.NextFloat2Direction() * ItemManager.Random.NextFloat(50, 500);
|
||||
Zone.Admit(turret, piloted: false);
|
||||
});
|
||||
// "fly Djinni": a freshly fitted ship of the named hull beside the current one, and you in it. The ship you leave
|
||||
// stays where it is, unpiloted.
|
||||
ConsoleController.AddCommand("fly",
|
||||
args =>
|
||||
{
|
||||
var hullName = string.Join(" ", args);
|
||||
if (_currentEntity == null || _currentEntity.Parent != null)
|
||||
{
|
||||
ConsoleController.Instance.AppendLogLine("Refused: undock first");
|
||||
return;
|
||||
}
|
||||
var generator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, null, 2);
|
||||
var pack = generator.GenerateShipLoadout(data => string.Equals(data.Name, hullName, StringComparison.InvariantCultureIgnoreCase));
|
||||
if (pack == null)
|
||||
{
|
||||
ConsoleController.Instance.AppendLogLine($"No ship hull named \"{hullName}\"");
|
||||
return;
|
||||
}
|
||||
var ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack);
|
||||
ship.IsPlayerShip = true;
|
||||
ship.Direction = _currentEntity.Direction;
|
||||
ship.Position.xz = _currentEntity.Position.xz + _currentEntity.Direction.Rotate(ItemRotation.Clockwise) * 30;
|
||||
ship.Velocity = _currentEntity.Velocity;
|
||||
Zone.Admit(ship, piloted: false);
|
||||
UnbindEntity(); // or the ship you leave still owns your death
|
||||
BindToEntity(ship);
|
||||
});
|
||||
//Temporary, or not
|
||||
ConsoleController.AddCommand("tow", _ => TowShip());
|
||||
|
||||
|
|
@ -609,19 +620,9 @@ public class ActionGameManager : MonoBehaviour
|
|||
console.AppendLogLine("usage: iff hostile|neutral|clear");
|
||||
return;
|
||||
}
|
||||
switch (mode)
|
||||
{
|
||||
case "hostile":
|
||||
_currentEntity.SetIff(target, true);
|
||||
break;
|
||||
case "neutral":
|
||||
_currentEntity.SetIff(target, false);
|
||||
break;
|
||||
case "clear":
|
||||
_currentEntity.SetIff(target, null);
|
||||
break;
|
||||
}
|
||||
console.AppendLogLine($"{target.Name}: {(_currentEntity.IsHostileTo(target) ? "hostile" : "neutral")}");
|
||||
bool? hostile = mode == "hostile" ? true : mode == "neutral" ? false : (bool?) null;
|
||||
_currentEntity.Command.Iff(target, hostile);
|
||||
console.AppendLogLine($"{target.Name}: {mode} queued; it applies at the next step");
|
||||
});
|
||||
|
||||
// Editor only: capturepreset "<name>" [replace] writes the piloted ship as a catalog preset. A multi-word name must
|
||||
|
|
@ -1020,6 +1021,35 @@ public class ActionGameManager : MonoBehaviour
|
|||
if (DebugInfoText != null) DebugInfoText.text = "";
|
||||
}
|
||||
|
||||
// The weapon group a slot's input fires by default: mouse left, right and middle fire groups 1-3, and number key N fires group N.
|
||||
private static int DefaultWeaponGroup(string controlPath) => controlPath switch
|
||||
{
|
||||
"<Mouse>/leftButton" => 0,
|
||||
"<Mouse>/rightButton" => 1,
|
||||
"<Mouse>/middleButton" => 2,
|
||||
_ when controlPath.StartsWith("<Keyboard>/") && int.TryParse(controlPath.Substring("<Keyboard>/".Length), out var n) && n >= 1 => n - 1,
|
||||
_ => -1
|
||||
};
|
||||
|
||||
// A new ship's first action bar: each slot's default weapon group when the group has weapons. Gear and consumables the
|
||||
// player placed are kept; a slot holding another ship's weapon group is rebound to this ship, or cleared when its default
|
||||
// group here is empty.
|
||||
private void AutoBindWeaponGroups()
|
||||
{
|
||||
if (CurrentEntity?.WeaponGroups == null) return;
|
||||
for (var i = 0; i < _actionBarSlots.Count; i++)
|
||||
{
|
||||
var slot = _actionBarSlots[i];
|
||||
if (slot.Binding != null && slot.Binding is not ActionBarWeaponGroupBinding) continue;
|
||||
var group = DefaultWeaponGroup(_actionBarPaths[i]);
|
||||
if (group < 0) continue;
|
||||
if (group < CurrentEntity.WeaponGroups.Length && CurrentEntity.WeaponGroups[group].weapons.Any())
|
||||
slot.Binding = new ActionBarWeaponGroupBinding(CurrentEntity, slot, group);
|
||||
else if (slot.Binding is ActionBarWeaponGroupBinding)
|
||||
slot.Binding = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void BindToEntity(Entity entity)
|
||||
{
|
||||
if (!ZoneRenderer.EntityInstances.ContainsKey(entity))
|
||||
|
|
@ -1070,6 +1100,10 @@ public class ActionGameManager : MonoBehaviour
|
|||
crosshair.gameObject.SetActive(false);
|
||||
foreach (var group in _articulationGroups)
|
||||
group.crosshair.gameObject.SetActive(true);
|
||||
|
||||
// A loaded run's bar is restored after this and replaces it, so a saved setup is never overwritten.
|
||||
if (_actionBarSetUp.Add(CurrentEntity))
|
||||
AutoBindWeaponGroups();
|
||||
|
||||
_shipSubscriptions.Add(CurrentEntity.Target.Subscribe(target =>
|
||||
{
|
||||
|
|
@ -1320,7 +1354,7 @@ public class ActionGameManager : MonoBehaviour
|
|||
var sensitivity = PlayerSettings.InputSettings.Sensitivity;
|
||||
_entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI));
|
||||
_viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ));
|
||||
CurrentEntity.Aim = _viewDirection;
|
||||
CurrentEntity.Command.Aim = _viewDirection;
|
||||
HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke));
|
||||
var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke));
|
||||
SevereHeatstrokePost.weight =
|
||||
|
|
@ -1330,9 +1364,12 @@ public class ActionGameManager : MonoBehaviour
|
|||
_faceAimMark.enabled = _helm.Latched;
|
||||
if(CurrentEntity is Ship ship)
|
||||
{
|
||||
ship.MovementDirection = Input.Player.Move.ReadValue<Vector2>().ToCultMath();
|
||||
ship.Command.Movement = Input.Player.Move.ReadValue<Vector2>().ToCultMath();
|
||||
_helm.Held = Input.Player.FaceAim.IsPressed();
|
||||
ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue<float>());
|
||||
if (Input.Player.Turn.activeControl is { } turnControl)
|
||||
_turnDigital = turnControl.device is UnityEngine.InputSystem.Keyboard;
|
||||
var turnAxis = _helm.TurnAxis(Input.Player.Turn.ReadValue<float>(), _turnDigital, Time.deltaTime, KeyboardTurnRampTime);
|
||||
ship.Command.Turn = _helm.Demand(_viewDirection.xz, ship.BandedTurn(turnAxis));
|
||||
}
|
||||
|
||||
UpdateMusic();
|
||||
|
|
@ -1347,7 +1384,7 @@ public class ActionGameManager : MonoBehaviour
|
|||
TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress);
|
||||
}
|
||||
|
||||
CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue<float>();
|
||||
CurrentEntity.Command.Tractor = Input.Player.TractorBeam.ReadValue<float>();
|
||||
}
|
||||
}
|
||||
// One clock steps the zone, paused or not: a paused frame feeds it no real time.
|
||||
|
|
@ -1460,11 +1497,18 @@ public class ActionGameManager : MonoBehaviour
|
|||
else if (target.Entity != null)
|
||||
TargetIndicator.Target = DrawAhead.Position(target.Entity, lead).ToUnity();
|
||||
var distance = length(ViewDot.Target.ToCultMath() - ownDrawn);
|
||||
// The aim's pitch, applied to each barrel's planar bearing: the crosshair sits where the guns point at the view's
|
||||
// elevation, not on the ship's level plane (which is the horizon).
|
||||
var aim = CurrentEntity.Aim;
|
||||
var aimLevel = length(aim.xz);
|
||||
foreach (var (_, barrels, crosshair) in _articulationGroups)
|
||||
{
|
||||
var averagePosition = Vector3.zero;
|
||||
foreach (var barrel in barrels)
|
||||
averagePosition += barrel.position + barrel.forward * distance;
|
||||
{
|
||||
var bearing = new Vector2(barrel.forward.x, barrel.forward.z).normalized;
|
||||
averagePosition += barrel.position + new Vector3(bearing.x * aimLevel, aim.y, bearing.y * aimLevel) * distance;
|
||||
}
|
||||
averagePosition /= barrels.Length;
|
||||
crosshair.Target = averagePosition;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,6 +408,9 @@ public class EntityInstance : MonoBehaviour
|
|||
var aimDistance = !Entity.Target.Value.IsNone
|
||||
? max(Entity.TargetRange, Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance)
|
||||
: 10000;
|
||||
// The aim's pitch: a planar firing bearing is drawn at the same elevation as the view, so a group aims where the
|
||||
// player looks, not at the horizon of the ship's plane.
|
||||
var aimLevel = length(Entity.Aim.xz);
|
||||
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() * aimDistance;
|
||||
foreach (var group in _groupAims)
|
||||
{
|
||||
|
|
@ -415,7 +418,7 @@ public class EntityInstance : MonoBehaviour
|
|||
if (weapon == null) { group.Value.position = LookAtPoint.position; continue; }
|
||||
var barrel = Barrels.TryGetValue(weapon.Item.Hardpoint, out var barrels) ? barrels[0].position : transform.position;
|
||||
var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction;
|
||||
group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance;
|
||||
group.Value.position = barrel + new Vector3(direction.x * aimLevel, Entity.Aim.y, direction.y * aimLevel) * aimDistance;
|
||||
}
|
||||
if (_influenceInstance)
|
||||
_influenceInstance.position = new Vector3(drawn.x, 0, drawn.z);
|
||||
|
|
|
|||
|
|
@ -24,19 +24,16 @@ public class Projectile : MonoBehaviour
|
|||
private CultMath.float3 _barrel;
|
||||
private ShotOutcome _known;
|
||||
private bool _alive;
|
||||
private CompositeDisposable _binding;
|
||||
private ProjectileManager _owner;
|
||||
|
||||
// Takes the shot as the sim holds it, the zone whose sim time it is drawn at, and where the barrel is as this round is first seen.
|
||||
public void Launch(Zone zone, PendingShot shot, Vector3 barrel)
|
||||
public void Launch(ProjectileManager owner, Zone zone, PendingShot shot, Vector3 barrel)
|
||||
{
|
||||
_owner = owner;
|
||||
_zone = zone;
|
||||
_shot = shot;
|
||||
_barrel = new CultMath.float3(barrel.x, barrel.y, barrel.z);
|
||||
_known = null;
|
||||
_binding?.Dispose();
|
||||
_binding = new CompositeDisposable(
|
||||
zone.ShotCommitted.Where(o => o.ShotId == shot.ShotId).Subscribe(Committed),
|
||||
zone.ShotResolved.Where(o => o.ShotId == shot.ShotId).Subscribe(Resolve));
|
||||
_alive = true;
|
||||
transform.forward = new Vector3(shot.TravelDirection.x, 0, shot.TravelDirection.y);
|
||||
Draw();
|
||||
|
|
@ -56,14 +53,14 @@ public class Projectile : MonoBehaviour
|
|||
}
|
||||
|
||||
// The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided.
|
||||
private void Committed(ShotOutcome outcome)
|
||||
internal void Committed(ShotOutcome outcome)
|
||||
{
|
||||
if (_zone.TryGetShot(outcome.ShotId, out var record)) _shot = record;
|
||||
_known = outcome;
|
||||
}
|
||||
|
||||
// The simulation's verdict. A round that hit or burst is drawn at its end and stops; a miss keeps flying.
|
||||
private void Resolve(ShotOutcome outcome)
|
||||
internal void Resolve(ShotOutcome outcome)
|
||||
{
|
||||
if (!_alive) return;
|
||||
_known = outcome;
|
||||
|
|
@ -78,16 +75,14 @@ public class Projectile : MonoBehaviour
|
|||
private void Finish()
|
||||
{
|
||||
_alive = false;
|
||||
_binding?.Dispose();
|
||||
_binding = null;
|
||||
_owner?.Forget(_shot.ShotId);
|
||||
StartCoroutine(Fade());
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
_alive = false;
|
||||
_binding?.Dispose();
|
||||
_binding = null;
|
||||
_owner?.Forget(_shot.ShotId);
|
||||
}
|
||||
|
||||
IEnumerator Fade()
|
||||
|
|
|
|||
|
|
@ -1,17 +1,46 @@
|
|||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
|
||||
public class ProjectileManager : InstantWeaponEffectManager
|
||||
{
|
||||
public Prototype ProjectilePrototype;
|
||||
|
||||
// The rounds this manager has in the air by shot id, told of the sim's commit and verdict by one subscription per zone: a
|
||||
// gun that fires hundreds of rounds in a step costs the dictionary an entry per round and nothing else (no subscription,
|
||||
// closure or disposable per round).
|
||||
private readonly Dictionary<int, Projectile> _live = new Dictionary<int, Projectile>();
|
||||
private readonly CompositeDisposable _listening = new CompositeDisposable();
|
||||
private Zone _zone;
|
||||
|
||||
// The round is drawn by DrawAhead from what the simulation publishes, so there is nothing to aim, jitter
|
||||
// or inherit here: spread is priced by FireControl, and the sim's round inherits nothing from its shooter.
|
||||
// A round the sim fired inside the step (rounds per step) is first seen where its shooter's barrel was at that moment:
|
||||
// the barrel as it is now, carried back along the shooter's motion for the time since.
|
||||
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
|
||||
{
|
||||
var zone = source.Entity.Zone;
|
||||
if (!zone.TryGetShot(shotId, out var shot)) return;
|
||||
Listen(zone);
|
||||
var age = (float) (zone.ExactTime - shot.FireTime);
|
||||
var velocity = source.Entity.Velocity;
|
||||
var p = ProjectilePrototype.Instantiate<Projectile>();
|
||||
p.Launch(zone, shot, source.GetBarrel(item.Hardpoint).position);
|
||||
_live[shotId] = p;
|
||||
p.Launch(this, zone, shot, source.GetBarrel(item.Hardpoint).position - new Vector3(velocity.x, 0, velocity.y) * age);
|
||||
p.Trail.Clear();
|
||||
}
|
||||
|
||||
internal void Forget(int shotId) => _live.Remove(shotId);
|
||||
|
||||
private void Listen(Zone zone)
|
||||
{
|
||||
if (_zone == zone) return;
|
||||
_listening.Clear();
|
||||
_live.Clear();
|
||||
_zone = zone;
|
||||
zone.ShotCommitted.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Committed(outcome); }).AddTo(_listening);
|
||||
zone.ShotResolved.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Resolve(outcome); }).AddTo(_listening);
|
||||
}
|
||||
|
||||
void OnDestroy() => _listening.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,19 @@ 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(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) };
|
||||
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(ProvinceData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData), typeof(RecipeData), typeof(OutsideMarketData) };
|
||||
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
|
||||
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
|
||||
|
||||
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
|
||||
// Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with
|
||||
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
|
||||
// A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog
|
||||
// is told the hull's name and goes on.
|
||||
// The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never
|
||||
// become catalog globals); the global check above is judged against whichever registry opened the cache.
|
||||
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false,
|
||||
CultDocumentRegistry registry = null)
|
||||
CultDocumentRegistry registry = null, Action<string> kindlessStation = null)
|
||||
{
|
||||
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
|
||||
if (!catalogWritable && !File.Exists(catalogPath))
|
||||
|
|
@ -42,6 +44,7 @@ public static class AetheriaStores
|
|||
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
|
||||
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
|
||||
ValidateBins(cache);
|
||||
StationKinds.Validate(cache, kindlessStation);
|
||||
return cache;
|
||||
}
|
||||
catch
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ public class Agent
|
|||
private VelocityLimit _velocityLimit;
|
||||
|
||||
public AgentTask Task { get; set; }
|
||||
// What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until
|
||||
// Flight.Join sets the flight's.
|
||||
public PilotView View { get; internal set; }
|
||||
// Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for
|
||||
// an agent built outside a zone's admission).
|
||||
public uint Id { get; set; }
|
||||
|
|
@ -36,6 +39,7 @@ public class Agent
|
|||
public Agent(Ship ship, RoleDoctrine doctrine)
|
||||
{
|
||||
Ship = ship;
|
||||
View = new PilotView(ship);
|
||||
Doctrine = doctrine;
|
||||
ItemManager = Ship.ItemManager;
|
||||
Settings = Ship.ItemManager.GameplaySettings;
|
||||
|
|
@ -55,9 +59,9 @@ public class Agent
|
|||
|
||||
public void Update(float delta)
|
||||
{
|
||||
// The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its
|
||||
// The pilot tick commands Turn every tick, in every state: a state that pilots overwrites this zero with its
|
||||
// demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate.
|
||||
Ship.Turn = 0;
|
||||
Ship.Command.Turn = TurnCommand.OfRate(0);
|
||||
VentOnTrack();
|
||||
_currentState.Update(delta);
|
||||
foreach (var transition in _currentState.Transitions)
|
||||
|
|
@ -84,7 +88,7 @@ public class Agent
|
|||
{
|
||||
if (Ship.HasActiveConsumable(kind)) return;
|
||||
var consumable = Ship.FirstCarriedConsumable(kind);
|
||||
if (consumable != null) Ship.TryActivateConsumable(consumable);
|
||||
if (consumable != null) Ship.Command.Use(consumable);
|
||||
}
|
||||
|
||||
public void Accelerate(float2 targetVelocity, bool noTurn = false)
|
||||
|
|
@ -95,18 +99,18 @@ public class Agent
|
|||
// If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters
|
||||
if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD)
|
||||
{
|
||||
Ship.Turn = Steering.Toward(Ship, deltaVDirection);
|
||||
Ship.MovementDirection = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2));
|
||||
Ship.Command.Turn = TurnCommand.Toward(deltaVDirection);
|
||||
Ship.Command.Movement = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2));
|
||||
}
|
||||
// If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target)
|
||||
else if(deltaVMag > THRUST_DELTA_THRESHOLD)
|
||||
{
|
||||
var right = Ship.Direction.Rotate(ItemRotation.Clockwise);
|
||||
Ship.MovementDirection = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection));
|
||||
Ship.Command.Movement = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection));
|
||||
}
|
||||
else
|
||||
{
|
||||
Ship.MovementDirection = float2.zero;
|
||||
Ship.Command.Movement = float2.zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using CultMath;
|
||||
using static CultMath.math;
|
||||
using ContactId = PilotView.ContactId;
|
||||
|
||||
// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone
|
||||
// One flight per (zone, side) of piloted ships; a side is a faction, or one unaffiliated ship. The flight alone
|
||||
// decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task).
|
||||
// The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers.
|
||||
//
|
||||
// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only:
|
||||
// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and
|
||||
// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never
|
||||
// lets it fire at what it cannot see.
|
||||
// The flight reads other ships only through its side's PilotView (ruling observation-sensors-only): it holds a contact
|
||||
// as a handle and never as the ship. The picture is the view's: the union of what the members' sensors hold hostile, never a member.
|
||||
// A track passes through phases the flight owns, runtime only: None, Hailing (grace running, one hail spoken), Complied
|
||||
// (slow enough at the grace's end: followed, not engaged) and Engaged. A member still fires only at what its own
|
||||
// sensors hold (fire control), so the shared picture never lets it fire at what it cannot see.
|
||||
public class Flight
|
||||
{
|
||||
public enum Phase { None, Hailing, Complied, Engaged }
|
||||
|
|
@ -31,7 +33,7 @@ public class Flight
|
|||
private readonly FactionDoctrine _doctrine;
|
||||
private readonly GameplaySettings _settings;
|
||||
private readonly List<Member> _members = new List<Member>();
|
||||
private readonly Dictionary<Entity, Track> _tracks = new Dictionary<Entity, Track>();
|
||||
private readonly Dictionary<ContactId, Track> _tracks = new Dictionary<ContactId, Track>();
|
||||
private double _time;
|
||||
|
||||
// No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default.
|
||||
|
|
@ -44,58 +46,64 @@ public class Flight
|
|||
}
|
||||
|
||||
public RoleDoctrine Combatant { get; }
|
||||
// What this side knows of other ships; every member's Agent.View.
|
||||
public PilotView View { get; } = new PilotView();
|
||||
public int Count => _members.Count;
|
||||
public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent);
|
||||
|
||||
public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None;
|
||||
public Phase PhaseOf(ContactId track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None;
|
||||
|
||||
// The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated.
|
||||
public void Join(Agent agent, AgentTask baseline)
|
||||
{
|
||||
_members.Add(new Member { Agent = agent, Baseline = baseline });
|
||||
View.Add(agent.Ship);
|
||||
agent.View = View;
|
||||
agent.Task = baseline;
|
||||
}
|
||||
|
||||
public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent);
|
||||
public void Leave(Agent agent)
|
||||
{
|
||||
if (_members.RemoveAll(m => m.Agent == agent) > 0) View.Remove(agent.Ship);
|
||||
}
|
||||
|
||||
public void Update(float dt)
|
||||
{
|
||||
_time += dt;
|
||||
var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList();
|
||||
var picture = View.Picture();
|
||||
foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone);
|
||||
|
||||
foreach (var entity in picture)
|
||||
foreach (var contact in picture)
|
||||
{
|
||||
if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track();
|
||||
Advance(entity, track);
|
||||
if (!_tracks.TryGetValue(contact, out var track)) _tracks[contact] = track = new Track();
|
||||
Advance(contact, track);
|
||||
}
|
||||
|
||||
foreach (var member in _members) Order(member, picture);
|
||||
}
|
||||
|
||||
// A grudge is a hostile IFF override that any member holds on the track.
|
||||
private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true);
|
||||
private bool Grudged(ContactId contact) => _members.Any(m => View.IffOverride(m.Agent.Ship, contact) == true);
|
||||
|
||||
private bool Engageable(Entity entity, bool grudge)
|
||||
private bool Engageable(ContactId contact, bool grudge)
|
||||
{
|
||||
switch (_doctrine.EngageOn)
|
||||
{
|
||||
case EngageOn.Never: return false;
|
||||
case EngageOn.Detection: return true;
|
||||
case EngageOn.Identified:
|
||||
return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) &&
|
||||
info >= _settings.TargetGearInfoThreshold);
|
||||
return grudge || _members.Any(m => View.Info(m.Agent.Ship, contact) >= _settings.TargetGearInfoThreshold);
|
||||
case EngageOn.Provoked: return grudge;
|
||||
case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity));
|
||||
case EngageOn.Trespass: return grudge || _members.Any(m => View.BelievesTrespassing(m.Agent.Ship, contact));
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Advance(Entity entity, Track track)
|
||||
private void Advance(ContactId contact, Track track)
|
||||
{
|
||||
if (track.Phase == Phase.Engaged) return;
|
||||
var grudge = Grudged(entity);
|
||||
if (!Engageable(entity, grudge))
|
||||
var grudge = Grudged(contact);
|
||||
if (!Engageable(contact, grudge))
|
||||
{
|
||||
track.Phase = Phase.None;
|
||||
return;
|
||||
|
|
@ -115,56 +123,59 @@ public class Flight
|
|||
{
|
||||
track.Phase = Phase.Hailing;
|
||||
track.Start = _time;
|
||||
if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail);
|
||||
if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(contact)?.Agent.Ship.Command.Hail(_doctrine.Hail);
|
||||
}
|
||||
else track.Phase = Phase.Engaged;
|
||||
break;
|
||||
case Phase.Hailing:
|
||||
if (_time >= track.Start + _doctrine.Grace)
|
||||
track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed
|
||||
track.Phase = _doctrine.ComplySpeed > 0 && length(View.SideTrack(contact).Value.Velocity) < _doctrine.ComplySpeed
|
||||
? Phase.Complied
|
||||
: Phase.Engaged;
|
||||
break;
|
||||
case Phase.Complied:
|
||||
if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged;
|
||||
if (length(View.SideTrack(contact).Value.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Member Nearest(Entity entity) =>
|
||||
_members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault();
|
||||
private Member Nearest(ContactId contact)
|
||||
{
|
||||
var at = View.SideTrack(contact).Value.Position.xz;
|
||||
return _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - at)).FirstOrDefault();
|
||||
}
|
||||
|
||||
private void Order(Member member, List<Entity> picture)
|
||||
private void Order(Member member, List<ContactId> picture)
|
||||
{
|
||||
var ship = member.Agent.Ship;
|
||||
var seen = ship.VisibleEnemies;
|
||||
|
||||
// Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see.
|
||||
var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList();
|
||||
var engaged = picture.Where(c => _tracks[c].Phase == Phase.Engaged && View.Sees(ship, c)).ToList();
|
||||
if (engaged.Count > 0)
|
||||
{
|
||||
var current = ship.Target.Value.Entity;
|
||||
var pick = current != null && engaged.Contains(current)
|
||||
? current
|
||||
: engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First();
|
||||
Aim(ship, pick);
|
||||
var current = View.AppliedTargetOf(ship);
|
||||
var pick = current != null && engaged.Contains(current.Value)
|
||||
? current.Value
|
||||
: engaged.OrderBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First();
|
||||
View.Designate(ship, pick);
|
||||
return;
|
||||
}
|
||||
|
||||
// No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting
|
||||
// data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees.
|
||||
Aim(ship, null);
|
||||
View.Designate(ship, null);
|
||||
// Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly.
|
||||
var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList();
|
||||
var held = picture.Where(c => _tracks[c].Phase != Phase.None && View.IffOverride(ship, c) != false).ToList();
|
||||
if (held.Count > 0)
|
||||
{
|
||||
// An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it.
|
||||
var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged);
|
||||
var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor)
|
||||
&& (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged)
|
||||
? follow.Anchor
|
||||
: held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1)
|
||||
.ThenBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First();
|
||||
var anyEngaged = held.Any(c => _tracks[c].Phase == Phase.Engaged);
|
||||
var heldAnchor = (member.Agent.Task as FollowTask)?.Anchor;
|
||||
var anchor = heldAnchor != null && held.Contains(heldAnchor.Value)
|
||||
&& (!anyEngaged || _tracks[heldAnchor.Value].Phase == Phase.Engaged)
|
||||
? heldAnchor.Value
|
||||
: held.OrderBy(c => _tracks[c].Phase == Phase.Engaged ? 0 : 1)
|
||||
.ThenBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First();
|
||||
if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor)
|
||||
member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) };
|
||||
}
|
||||
|
|
@ -172,12 +183,6 @@ public class Flight
|
|||
member.Agent.Task = member.Baseline;
|
||||
}
|
||||
|
||||
private static void Aim(Ship ship, Entity target)
|
||||
{
|
||||
var wanted = target == null ? TargetRef.None : new TargetRef(target);
|
||||
if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted);
|
||||
}
|
||||
|
||||
private float Standoff(Ship ship)
|
||||
{
|
||||
if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff;
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ public class Minion : Agent
|
|||
|
||||
var combatState = new CombatState(this);
|
||||
_rootState.AddTransition(combatState,
|
||||
() => Ship.Target.Value.Entity != null, null, true, _rootState);
|
||||
() => View.TargetOf(Ship) != null, null, true, _rootState);
|
||||
|
||||
combatState.AddTransition(_rootState,
|
||||
() => Ship.Target.Value.Entity == null, null, true, _rootState);
|
||||
() => View.TargetOf(Ship) == null, null, true, _rootState);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
206
Assets/Scripts/ServerShared/Agents/PilotView.cs
Normal file
206
Assets/Scripts/ServerShared/Agents/PilotView.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CultMath;
|
||||
|
||||
// What one side's brain may know of other ships. A side is a faction, or one unaffiliated ship (brain-one-per-side-r2);
|
||||
// its view reads only that side's members: their sensors (VisibleEntities, VisibleEnemies, EntityInfoGathered), their
|
||||
// IFF overrides and reveal books (ruling observation-sensors-only). A brain never holds another ship: it holds a
|
||||
// ContactId or a RevealId, which only the view that issued it can resolve, so there is no path from a handle to the
|
||||
// ship's truth that does not pass the sensors.
|
||||
//
|
||||
// A contact's Track (position, velocity, facing) exists only while a member tracks it (its VisibleEntities holds it):
|
||||
// within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The
|
||||
// fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the
|
||||
// brains price one model (A-one-sim). Own-ship reads stay the brain's own.
|
||||
//
|
||||
// The view enforces its own rules; it does not trust its callers. Every per-member read answers for a member the view
|
||||
// holds and nothing else, and every per-contact fire-control readout (Intercept, WeaponReveals, Designated, Refuses,
|
||||
// Fires) answers only while that member itself tracks the contact: a sidemate's track gives a member no aim, no reveals
|
||||
// and no shot (per-shooter-fire-control). Only Flight builds a view and sets it on its pilots, and the side-of-one view an
|
||||
// Agent starts with is its own; the constructor and the setter are internal.
|
||||
public sealed class PilotView
|
||||
{
|
||||
private readonly List<Ship> _members = new List<Ship>();
|
||||
private static Func<ContactId, Entity> _openContact;
|
||||
private static Func<RevealId, EquippedItem> _openReveal;
|
||||
|
||||
internal PilotView(params Ship[] members) => _members.AddRange(members);
|
||||
|
||||
private bool Holds(Ship member) => _members.Contains(member);
|
||||
private bool Tracks(Ship member, Entity subject) => Holds(member) && subject != null && member.VisibleEntities.Contains(subject);
|
||||
|
||||
internal void Add(Ship member) => _members.Add(member);
|
||||
internal void Remove(Ship member) => _members.Remove(member);
|
||||
|
||||
// A contact of the view, equal to itself across absences from a member's picture: equality is by reference.
|
||||
public readonly struct ContactId : IEquatable<ContactId>
|
||||
{
|
||||
private readonly Entity _subject;
|
||||
|
||||
static ContactId() => _openContact = id => id._subject;
|
||||
internal ContactId(Entity subject) => _subject = subject;
|
||||
|
||||
public bool Equals(ContactId other) => ReferenceEquals(_subject, other._subject);
|
||||
public override bool Equals(object obj) => obj is ContactId other && Equals(other);
|
||||
public override int GetHashCode() => RuntimeHelpers.GetHashCode(_subject);
|
||||
public static bool operator ==(ContactId a, ContactId b) => a.Equals(b);
|
||||
public static bool operator !=(ContactId a, ContactId b) => !a.Equals(b);
|
||||
}
|
||||
|
||||
// A revealed item of a contact, to aim at; the live item stays behind the handle (ruling contact-reads-book-aim-reads-live).
|
||||
public readonly struct RevealId : IEquatable<RevealId>
|
||||
{
|
||||
private readonly EquippedItem _item;
|
||||
|
||||
static RevealId() => _openReveal = id => id._item;
|
||||
internal RevealId(EquippedItem item) => _item = item;
|
||||
|
||||
public bool Equals(RevealId other) => ReferenceEquals(_item, other._item);
|
||||
public override bool Equals(object obj) => obj is RevealId other && Equals(other);
|
||||
public override int GetHashCode() => RuntimeHelpers.GetHashCode(_item);
|
||||
public static bool operator ==(RevealId a, RevealId b) => a.Equals(b);
|
||||
public static bool operator !=(RevealId a, RevealId b) => !a.Equals(b);
|
||||
}
|
||||
|
||||
public readonly struct Track
|
||||
{
|
||||
public readonly float3 Position;
|
||||
public readonly float2 Velocity;
|
||||
public readonly float2 Direction;
|
||||
|
||||
public Track(float3 position, float2 velocity, float2 direction)
|
||||
{
|
||||
Position = position;
|
||||
Velocity = velocity;
|
||||
Direction = direction;
|
||||
}
|
||||
}
|
||||
|
||||
// For fixtures, and for the one place the stage states what a pilot already knows (ScenarioStage.Approach seeds an
|
||||
// anchor handle for a FollowTask before the zone exists: staged knowledge, ruling stage-states-pilot-knowledge). A
|
||||
// brain never calls this; the sensors, not the handle, decide whether the view answers about the contact.
|
||||
internal static ContactId Handle(Entity subject) => new ContactId(subject);
|
||||
|
||||
private static Entity Subject(ContactId id)
|
||||
{
|
||||
if (_openContact == null) RuntimeHelpers.RunClassConstructor(typeof(ContactId).TypeHandle);
|
||||
return _openContact(id);
|
||||
}
|
||||
|
||||
private static EquippedItem Item(RevealId id)
|
||||
{
|
||||
if (_openReveal == null) RuntimeHelpers.RunClassConstructor(typeof(RevealId).TypeHandle);
|
||||
return _openReveal(id);
|
||||
}
|
||||
|
||||
// The side's contacts: its members' VisibleEnemies in member order, each once, never a member of the side.
|
||||
public List<ContactId> Picture()
|
||||
{
|
||||
var picture = new List<ContactId>();
|
||||
var seen = new HashSet<Entity>();
|
||||
foreach (var member in _members)
|
||||
foreach (var enemy in member.VisibleEnemies)
|
||||
if (!_members.Any(m => ReferenceEquals(m, enemy)) && seen.Add(enemy)) picture.Add(new ContactId(enemy));
|
||||
return picture;
|
||||
}
|
||||
|
||||
public bool Sees(Ship member, ContactId contact) => Holds(member) && member.VisibleEnemies.Contains(Subject(contact));
|
||||
|
||||
// The member's own track of the contact, or null while it does not track it.
|
||||
public Track? TrackOf(Ship member, ContactId contact)
|
||||
{
|
||||
var subject = Subject(contact);
|
||||
if (!Tracks(member, subject)) return null;
|
||||
return new Track(subject.Position, subject.Velocity, subject.Direction);
|
||||
}
|
||||
|
||||
// The track of the first member, in order, that tracks the contact (question side-shared-kinematics).
|
||||
public Track? SideTrack(ContactId contact)
|
||||
{
|
||||
foreach (var member in _members)
|
||||
{
|
||||
var track = TrackOf(member, contact);
|
||||
if (track != null) return track;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null;
|
||||
|
||||
public float? Info(Ship member, ContactId contact) =>
|
||||
Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null;
|
||||
|
||||
public bool BelievesTrespassing(Ship member, ContactId contact) => Holds(member) && member.BelievesTrespassing(Subject(contact));
|
||||
|
||||
// The member's pending designation when set, else last step's applied target.
|
||||
public ContactId? TargetOf(Ship member)
|
||||
{
|
||||
if (!Holds(member)) return null;
|
||||
var subject = (member.Command.Designated ?? member.Target.Value).Entity;
|
||||
return subject == null ? (ContactId?) null : new ContactId(subject);
|
||||
}
|
||||
|
||||
public ContactId? AppliedTargetOf(Ship member)
|
||||
{
|
||||
if (!Holds(member)) return null;
|
||||
var subject = member.Target.Value.Entity;
|
||||
return subject == null ? (ContactId?) null : new ContactId(subject);
|
||||
}
|
||||
|
||||
// The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed.
|
||||
// Empty unless the member tracks the contact.
|
||||
public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact)
|
||||
{
|
||||
var subject = Subject(contact);
|
||||
if (!Tracks(member, subject)) return new List<(RevealId, EquippableItemData, bool)>();
|
||||
return member.RevealedWeapons(subject)
|
||||
.Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList();
|
||||
}
|
||||
|
||||
// The next four answer for a member that tracks the contact: otherwise not designated, refused, no shot, no intercept.
|
||||
public bool Designated(Ship member, Weapon weapon, ContactId contact)
|
||||
{
|
||||
var subject = Subject(contact);
|
||||
return Tracks(member, subject) && FireControl.Designated(weapon, member, subject, out _);
|
||||
}
|
||||
|
||||
public bool Refuses(Ship member, Weapon weapon, ContactId contact)
|
||||
{
|
||||
var subject = Subject(contact);
|
||||
return !Tracks(member, subject) || FireControl.Refuses(weapon, member, subject);
|
||||
}
|
||||
|
||||
public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim)
|
||||
{
|
||||
var subject = Subject(contact);
|
||||
return Tracks(member, subject) && FireControl.AgentFires(weapon, member, subject, minHitProbability,
|
||||
aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value)));
|
||||
}
|
||||
|
||||
// Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides; null
|
||||
// unless the member tracks the contact.
|
||||
public float3? Intercept(Ship member, Weapon weapon, ContactId contact)
|
||||
{
|
||||
var subject = Subject(contact);
|
||||
if (!Tracks(member, subject)) return null;
|
||||
var predicted = FireControl.PredictedIntercept(weapon, member, subject);
|
||||
predicted.y = subject.RideHeight(predicted.xz);
|
||||
return predicted;
|
||||
}
|
||||
|
||||
// Designate the contact, or nothing for null, when that is not already the member's applied target.
|
||||
public void Designate(Ship member, ContactId? contact)
|
||||
{
|
||||
if (!Holds(member)) return;
|
||||
var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value));
|
||||
if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted);
|
||||
}
|
||||
|
||||
public void SelectItem(Ship member, RevealId? reveal)
|
||||
{
|
||||
if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value));
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Agents/PilotView.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Agents/PilotView.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -43,34 +43,39 @@ public class CombatState : BaseState
|
|||
_optimumStale = false;
|
||||
}
|
||||
|
||||
var target = _agent.Ship.Target.Value.Entity;
|
||||
if (target == null) return;
|
||||
// The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target; and
|
||||
// only a contact a member tracks has a track to fly on, so an untracked target is no target here.
|
||||
var view = _agent.View;
|
||||
var target = view.TargetOf(_agent.Ship);
|
||||
if (target == null) { ReleaseUnwanted(); return; }
|
||||
var track = view.TrackOf(_agent.Ship, target.Value);
|
||||
if (track == null) { ReleaseUnwanted(); return; }
|
||||
|
||||
_availableGroups.Clear();
|
||||
_availableLockingWeapons.Clear();
|
||||
|
||||
var toTarget = target.Position - _agent.Ship.Position;
|
||||
var toTarget = track.Value.Position - _agent.Ship.Position;
|
||||
var targetDistance = length(toTarget.xz);
|
||||
|
||||
// Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at
|
||||
// this range, or clear the aim point when nothing qualifies -- the same predicate
|
||||
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
|
||||
// (the view's Aimable) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
|
||||
// believes the target has, and how hard each hits, come from its book's claims, not the target's live gear
|
||||
// (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the
|
||||
// reveal's Item is the aim and stays the truth.
|
||||
EquippedItem bestTargetItem = null;
|
||||
PilotView.RevealId? bestTargetItem = null;
|
||||
var bestTargetDps = float.MinValue;
|
||||
foreach (var reveal in _agent.Ship.RevealedWeapons(target))
|
||||
foreach (var reveal in view.WeaponReveals(_agent.Ship, target.Value))
|
||||
{
|
||||
if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue;
|
||||
if (!reveal.Aimable) continue;
|
||||
var dps = reveal.Claimed.Behaviors.OfType<WeaponData>().First().RatedRangeDamagePerSecond(targetDistance);
|
||||
if (dps > bestTargetDps)
|
||||
{
|
||||
bestTargetDps = dps;
|
||||
bestTargetItem = reveal.Item;
|
||||
bestTargetItem = reveal.Id;
|
||||
}
|
||||
}
|
||||
_agent.Ship.TrySelectTargetItem(bestTargetItem);
|
||||
view.SelectItem(_agent.Ship, bestTargetItem);
|
||||
|
||||
for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++)
|
||||
{
|
||||
|
|
@ -80,10 +85,12 @@ public class CombatState : BaseState
|
|||
// The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's
|
||||
// damage.
|
||||
if (weapon.Item.Online.Value &&
|
||||
FireControl.Designated(weapon, _agent.Ship, target, out _) &&
|
||||
!FireControl.Refuses(weapon, _agent.Ship) &&
|
||||
view.Designated(_agent.Ship, weapon, target.Value) &&
|
||||
!view.Refuses(_agent.Ship, weapon, target.Value) &&
|
||||
(weapon is ConstantWeapon ||
|
||||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
|
||||
// A held AutoWeapon stays a candidate between its rounds (it is cooling because it is firing), so the
|
||||
// group it belongs to is judged by the same Fires verdict below, not dropped for cooling down.
|
||||
weapon is InstantWeapon instantWeapon && (instantWeapon.CanFire || weapon is AutoWeapon && weapon.Firing)))
|
||||
{
|
||||
if(weapon is LockWeapon lockWeapon)
|
||||
_availableLockingWeapons.Add(lockWeapon);
|
||||
|
|
@ -94,7 +101,7 @@ public class CombatState : BaseState
|
|||
if (dps > .1) _availableGroups.Add((i, dps));
|
||||
}
|
||||
|
||||
foreach(var w in _availableLockingWeapons) w.Activate();
|
||||
foreach(var w in _availableLockingWeapons) _agent.Ship.Command.Pull(w);
|
||||
|
||||
UseConsumables(toTarget, targetDistance);
|
||||
|
||||
|
|
@ -109,11 +116,11 @@ public class CombatState : BaseState
|
|||
}
|
||||
}
|
||||
|
||||
var targetRight = target.Direction.Rotate(ItemRotation.Clockwise);
|
||||
var targetRight = track.Value.Direction.Rotate(ItemRotation.Clockwise);
|
||||
var optimumRangeDelta = abs(_optimumRange - targetDistance);
|
||||
var directionToTarget = normalize(toTarget.xz);
|
||||
var targetPortAlignment = dot(targetRight, directionToTarget);
|
||||
var targetForeAlignment = dot(-target.Direction, directionToTarget);
|
||||
var targetForeAlignment = dot(-track.Value.Direction, directionToTarget);
|
||||
var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp;
|
||||
if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2));
|
||||
|
||||
|
|
@ -126,32 +133,47 @@ public class CombatState : BaseState
|
|||
var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First();
|
||||
if(testWeapon.Velocity > 1)
|
||||
{
|
||||
// Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's
|
||||
// Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's
|
||||
// own snapshot and the roll's deviation measurement -- two separate predictions here and in the
|
||||
// roll would let an AI aim at one point and get judged for missing a different one.
|
||||
var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target);
|
||||
predictedPosition.y = target.RideHeight(predictedPosition.xz);
|
||||
toTarget = normalize(predictedPosition - _agent.Ship.Position);
|
||||
// The target is tracked here (the guard above), so the intercept is never null.
|
||||
var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value);
|
||||
if (predictedPosition != null) toTarget = normalize(predictedPosition.Value - _agent.Ship.Position);
|
||||
}
|
||||
|
||||
// Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a
|
||||
// verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it
|
||||
// verdict. The view's Fires is the AI's "worth it" heuristic for a weapon that hits what it
|
||||
// fires at, and "the target is designated" for a fused one.
|
||||
foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons)
|
||||
{
|
||||
if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability))
|
||||
weapon.Activate();
|
||||
else if (weapon.Firing)
|
||||
weapon.Deactivate();
|
||||
if (view.Fires(_agent.Ship, weapon, target.Value, _agent.Doctrine.MinHitProbability, bestTargetItem))
|
||||
_agent.Ship.Command.Pull(weapon);
|
||||
// A lock pulled above this step counts as firing, as it did when it was activated in place.
|
||||
else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon))
|
||||
_agent.Ship.Command.Release(weapon);
|
||||
}
|
||||
}
|
||||
_agent.Ship.Aim = toTarget;
|
||||
_agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz);
|
||||
ReleaseUnwanted();
|
||||
_agent.Ship.Command.Aim = toTarget;
|
||||
_agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz);
|
||||
_agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0);
|
||||
|
||||
// Fire charged guns!
|
||||
}
|
||||
|
||||
public override void OnExitState() => ReleaseUnwanted(ending: true);
|
||||
|
||||
// The AI's trigger level is what it commands this step: a weapon holds a down trigger until it is released (a gun, a lock,
|
||||
// a beam), so every held one this step has no command for is let go, whether the target is gone, untracked, out of reach,
|
||||
// its group is not the pick, or Combat has ended. Ending lets go of every held one, a weapon pulled this very step
|
||||
// (held once the pull is applied) included, since nothing after Combat will command it.
|
||||
private void ReleaseUnwanted(bool ending = false)
|
||||
{
|
||||
var command = _agent.Ship.Command;
|
||||
foreach (var weapon in _agent.Ship.Weapons)
|
||||
if ((weapon.Firing || command.Pulls(weapon)) && (ending || !command.Commands(weapon))) command.Release(weapon);
|
||||
}
|
||||
|
||||
// Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack).
|
||||
private void UseConsumables(float3 toTarget, float targetDistance)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,16 +11,18 @@ public class FollowState : BaseState
|
|||
{
|
||||
var task = _agent.Task as FollowTask;
|
||||
if (task?.Anchor == null) return;
|
||||
var anchor = _agent.View.SideTrack(task.Anchor.Value);
|
||||
if (anchor == null) return;
|
||||
|
||||
var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz;
|
||||
var toAnchor = anchor.Value.Position.xz - _agent.Ship.Position.xz;
|
||||
var distance = length(toAnchor);
|
||||
var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction;
|
||||
// Positive error: too far, close. Negative: too near, open.
|
||||
var error = distance - task.Standoff;
|
||||
var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f));
|
||||
var desired = task.Anchor.Velocity + pull * _agent.TopSpeed;
|
||||
var desired = anchor.Value.Velocity + pull * _agent.TopSpeed;
|
||||
|
||||
_agent.Ship.Aim = float3(direction.x, 0, direction.y);
|
||||
_agent.Ship.Command.Aim = float3(direction.x, 0, direction.y);
|
||||
_agent.Accelerate(desired);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ public abstract class MoveToState : BaseState
|
|||
|
||||
// We want to go top speed in the direction of our target
|
||||
var desiredVelocity = dir * _agent.TopSpeed;
|
||||
_agent.Ship.Aim = float3(dir.x, 0, dir.y);
|
||||
_agent.Ship.Turn = Steering.Toward(_agent.Ship, dir);
|
||||
_agent.Ship.Command.Aim = float3(dir.x, 0, dir.y);
|
||||
_agent.Ship.Command.Turn = TurnCommand.Toward(dir);
|
||||
_agent.Accelerate(desiredVelocity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using MessagePack;
|
|||
// Runtime only, like every agent task.
|
||||
public class FollowTask : AgentTask
|
||||
{
|
||||
[IgnoreMember] public Entity Anchor;
|
||||
[IgnoreMember] public PilotView.ContactId? Anchor;
|
||||
[IgnoreMember] public float Standoff;
|
||||
[IgnoreMember] public override TaskType Type => TaskType.Follow;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,23 +9,17 @@ using static CultMath.math;
|
|||
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
||||
public class AutoWeaponData : InstantWeaponData
|
||||
{
|
||||
public override Behavior CreateInstance(EquippedItem item)
|
||||
{
|
||||
return new AutoWeapon(this, item);
|
||||
}
|
||||
[IgnoreMember]
|
||||
public override bool Automatic => true;
|
||||
}
|
||||
|
||||
// A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate)
|
||||
// to the let-go (Deactivate). A plain InstantWeapon is semi-automatic.
|
||||
public class AutoWeapon : InstantWeapon
|
||||
{
|
||||
|
||||
protected override bool Repeats => _firing;
|
||||
|
||||
public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { }
|
||||
public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { }
|
||||
|
||||
public override bool Execute(float dt)
|
||||
{
|
||||
if(_firing && _burstRemaining == 0 && _cooldown < 0)
|
||||
Trigger();
|
||||
return base.Execute(dt);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,10 +87,15 @@ public abstract class Behavior
|
|||
}
|
||||
}
|
||||
|
||||
public interface IActivatedBehavior
|
||||
// A behaviour its pilot triggers. Only PilotApplier pulls and releases it, from the pilot's PilotCommand (Pull, Release),
|
||||
// so Activate and Deactivate are internal to the simulation: no script outside it triggers one in place.
|
||||
public abstract class ActivatedBehavior : Behavior
|
||||
{
|
||||
void Activate();
|
||||
void Deactivate();
|
||||
protected ActivatedBehavior(BehaviorData data, EquippedItem item) : base(data, item) { }
|
||||
protected ActivatedBehavior(BehaviorData data, ConsumableItemEffect consumable) : base(data, consumable) { }
|
||||
|
||||
internal abstract void Activate();
|
||||
internal abstract void Deactivate();
|
||||
}
|
||||
|
||||
public interface IEventBehavior
|
||||
|
|
@ -193,6 +198,7 @@ public interface IPowerConsumer
|
|||
Union(49, typeof(LaunchSuppressorData)),
|
||||
Union(50, typeof(ListenerData)),
|
||||
Union(51, typeof(ScannerData)),
|
||||
Union(52, typeof(LockWeaponData)),
|
||||
JsonObject(MemberSerialization.OptIn)]
|
||||
public abstract class BehaviorData
|
||||
{
|
||||
|
|
|
|||
|
|
@ -157,8 +157,7 @@ public class ChargedWeapon : InstantWeapon
|
|||
if (_data.FailureCharge > 1 && _charge > _data.FailureCharge)
|
||||
{
|
||||
_charging = false;
|
||||
_cooldown = 1;
|
||||
_coolingDown = true;
|
||||
StartCountdown();
|
||||
_charge = 0;
|
||||
OnFailed?.Invoke();
|
||||
CauseDamage(_data.FailureDamage);
|
||||
|
|
@ -167,7 +166,7 @@ public class ChargedWeapon : InstantWeapon
|
|||
return base.Execute(dt);
|
||||
}
|
||||
|
||||
public override void Activate()
|
||||
internal override void Activate()
|
||||
{
|
||||
// CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round,
|
||||
// so a charge started before then would overlap it.
|
||||
|
|
@ -180,7 +179,7 @@ public class ChargedWeapon : InstantWeapon
|
|||
}
|
||||
}
|
||||
|
||||
public override void Deactivate()
|
||||
internal override void Deactivate()
|
||||
{
|
||||
if (_charging)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -146,9 +146,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
}
|
||||
|
||||
_ammoInterval -= dt / _data.AmmoInterval;
|
||||
if (_ammoInterval < 0)
|
||||
// An interval shorter than the step spends every round it spans.
|
||||
while (_ammoInterval < 0)
|
||||
{
|
||||
_ammoInterval = 1;
|
||||
_ammoInterval += 1;
|
||||
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
|
||||
else
|
||||
{
|
||||
|
|
@ -198,7 +199,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
return true;
|
||||
}
|
||||
|
||||
public override void Activate()
|
||||
internal override void Activate()
|
||||
{
|
||||
// A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and
|
||||
// audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing.
|
||||
|
|
@ -209,7 +210,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
}
|
||||
}
|
||||
|
||||
public override void Deactivate()
|
||||
internal override void Deactivate()
|
||||
{
|
||||
if (_firing)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
|
|||
{
|
||||
if (_cooldown < 0)
|
||||
{
|
||||
_cooldown = 1;
|
||||
_cooldown += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +57,8 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
|
|||
|
||||
public void Update(float delta)
|
||||
{
|
||||
_cooldown -= delta / Evaluate(_data.Cooldown);
|
||||
// A finished timer keeps what the last step overshot, at most one step, so the restart in Execute is exact.
|
||||
var step = delta / Evaluate(_data.Cooldown);
|
||||
_cooldown = max(_cooldown - step, -step);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,18 @@ public class InputCapacitor
|
|||
|
||||
public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);
|
||||
|
||||
// A weapon held through a step may fire several rounds in it (ruling rounds-per-step): the charge it holds and what flows in
|
||||
// during the step pay for them, so for that step its buffer is a full activation plus the step's inflow; Settle trims what
|
||||
// the step did not spend back to Capacity. The fill rate is unchanged, so the energy a gun draws is still exactly its
|
||||
// rounds' cost. A sensor or a shield, and a weapon not held, keep the one-activation buffer above.
|
||||
private float StepReach(float dt) => Capacity + Rate * dt;
|
||||
|
||||
public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge));
|
||||
|
||||
public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt));
|
||||
|
||||
public void Settle() => Charge = min(Charge, Capacity);
|
||||
|
||||
public bool CanSpend(float cost) => Charge >= cost;
|
||||
|
||||
// Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the
|
||||
|
|
|
|||
|
|
@ -21,14 +21,39 @@ public class InstantWeaponData : WeaponData
|
|||
[Inspectable, JsonProperty("ammoInterval"), Key(20)]
|
||||
public bool SingleAmmoBurst;
|
||||
|
||||
// The fire mode, the one fact creation and the semi-auto catalog check both read: an automatic gun fires every round its
|
||||
// cooldown allows while its trigger is held, every other kind is semi-automatic (a press fires one burst).
|
||||
[IgnoreMember]
|
||||
public virtual bool Automatic => false;
|
||||
|
||||
public override Behavior CreateInstance(EquippedItem item)
|
||||
{
|
||||
return new InstantWeapon(this, item);
|
||||
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
|
||||
}
|
||||
|
||||
public override Behavior CreateInstance(ConsumableItemEffect item)
|
||||
{
|
||||
return new InstantWeapon(this, item);
|
||||
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
|
||||
}
|
||||
|
||||
// The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one
|
||||
// is a fire-as-fast-as-you-can-press mechanic, unfriendly to accessibility and a special case for the AI (ruling
|
||||
// fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon.
|
||||
public const float ShortestSemiAutoCooldown = .1f;
|
||||
|
||||
// The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine,
|
||||
// launcher, charged) that cycle faster than a finger should have to: one fault line per weapon.
|
||||
public static IEnumerable<string> SemiAutoFaults(IEnumerable<ItemData> catalog)
|
||||
{
|
||||
foreach (var item in catalog)
|
||||
{
|
||||
var behaviors = (item as GearData)?.Behaviors ?? (item as ConsumableItemData)?.Behaviors;
|
||||
if (behaviors == null) continue;
|
||||
foreach (var behavior in behaviors)
|
||||
if (behavior is InstantWeaponData weapon && !weapon.Automatic &&
|
||||
Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max) < ShortestSemiAutoCooldown)
|
||||
yield return $"{item.Name}: a {weapon.GetType().Name} with a {Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it";
|
||||
}
|
||||
}
|
||||
|
||||
// The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated
|
||||
|
|
@ -58,6 +83,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
protected float _cooldown; // Normalized
|
||||
private int _ammo = 0;
|
||||
protected bool _coolingDown;
|
||||
// Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held
|
||||
// trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart.
|
||||
private float _overshoot;
|
||||
// The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step.
|
||||
private const float ShortestCycle = .0005f;
|
||||
|
||||
public float BurstCount { get; protected set; }
|
||||
public float BurstTime { get; protected set; }
|
||||
|
|
@ -87,6 +117,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// manager can bind its presentation to the one shot it belongs to instead of applying damage itself.
|
||||
public event Action<int> OnFire;
|
||||
|
||||
// Whether a down trigger keeps firing: a plain gun is semi-automatic, one burst per press (a held key does not repeat, and
|
||||
// an agent presses it again each time it is ready); an AutoWeapon repeats while its trigger is held, every round its
|
||||
// cooldown allows at any step length. Fire mode is authored by the kind of the weapon (ruling fire-mode-is-the-design).
|
||||
protected virtual bool Repeats => false;
|
||||
|
||||
public virtual void ResetEvents()
|
||||
{
|
||||
OnReloadBegin = null;
|
||||
|
|
@ -165,11 +200,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
_capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride);
|
||||
}
|
||||
|
||||
// What the capacitor asks the bus for this step: a weapon held through it may fire several rounds in it, so it asks for
|
||||
// the step's whole inflow; any other tops up to one activation.
|
||||
private float Fill(float dt) => Repeats ? _capacitor.RequestedStepFill(dt) : _capacitor.RequestedFill(dt);
|
||||
|
||||
// Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern.
|
||||
public float PowerRequest(float dt)
|
||||
{
|
||||
RefreshInputCapacitor();
|
||||
return _capacitor.RequestedFill(dt);
|
||||
return Fill(dt);
|
||||
}
|
||||
|
||||
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what
|
||||
|
|
@ -183,7 +222,40 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever.
|
||||
protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);
|
||||
|
||||
private bool UseAmmo()
|
||||
// Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one.
|
||||
// True when it ended the countdown now.
|
||||
private bool FinishCountdown(float dt)
|
||||
{
|
||||
if (!_coolingDown || !(_cooldown < 0)) return false;
|
||||
_overshoot = min(-_cooldown * CountdownSpan, dt);
|
||||
_coolingDown = false;
|
||||
if (_data.MagazineSize > 0 && _ammo == 0)
|
||||
{
|
||||
_ammo = _data.MagazineSize;
|
||||
OnReloadComplete?.Invoke();
|
||||
}
|
||||
else
|
||||
OnCooldownComplete?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
|
||||
|
||||
// A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart.
|
||||
protected void StartCountdown()
|
||||
{
|
||||
_cooldown = 1 - TakeOvershoot() / CountdownSpan;
|
||||
_coolingDown = true;
|
||||
}
|
||||
|
||||
private float TakeOvershoot()
|
||||
{
|
||||
var overshoot = _overshoot;
|
||||
_overshoot = 0;
|
||||
return overshoot;
|
||||
}
|
||||
|
||||
private bool UseAmmo(float dt)
|
||||
{
|
||||
if (_data.MagazineSize <= 1) return true;
|
||||
|
||||
|
|
@ -208,7 +280,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if(hasAmmo)
|
||||
{
|
||||
OnReloadBegin?.Invoke();
|
||||
_cooldown = 1;
|
||||
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime;
|
||||
_coolingDown = true;
|
||||
_firing = false;
|
||||
}
|
||||
|
|
@ -218,29 +290,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
}
|
||||
|
||||
public override bool Execute(float dt)
|
||||
{
|
||||
try { return Step(dt); }
|
||||
finally { _capacitor.Settle(); }
|
||||
}
|
||||
|
||||
// One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a
|
||||
// cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires
|
||||
// every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step.
|
||||
private bool Step(float dt)
|
||||
{
|
||||
base.Execute(dt);
|
||||
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
|
||||
// instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge.
|
||||
if (Item != null)
|
||||
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
|
||||
_capacitor.AddStepCharge(Fill(dt) * Item.PowerSupply, dt);
|
||||
var ended = false;
|
||||
var retriggered = false;
|
||||
if (_coolingDown)
|
||||
{
|
||||
_cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
|
||||
if (_cooldown < 0)
|
||||
{
|
||||
_coolingDown = false;
|
||||
if (_data.MagazineSize > 0 && _ammo == 0)
|
||||
{
|
||||
_ammo = _data.MagazineSize;
|
||||
OnReloadComplete?.Invoke();
|
||||
}
|
||||
else
|
||||
OnCooldownComplete?.Invoke();
|
||||
}
|
||||
_cooldown -= dt / CountdownSpan;
|
||||
ended = FinishCountdown(dt);
|
||||
}
|
||||
|
||||
_burstTimer += dt;
|
||||
while (true)
|
||||
{
|
||||
while (_burstRemaining > 0 && _burstTimer > 0)
|
||||
{
|
||||
// A fused round whose arming distance Range cannot reach is refused, per round, before anything is
|
||||
|
|
@ -249,7 +324,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
var refused = FireControl.Refuses(this, Entity);
|
||||
// Ammo and energy are consumed here, per round, or once per burst at its first unrefused round
|
||||
// (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case
|
||||
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo()))
|
||||
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt)))
|
||||
{
|
||||
_burstRemaining = 0;
|
||||
return false;
|
||||
|
|
@ -260,8 +335,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if (!_burstStarted)
|
||||
{
|
||||
_burstStarted = true;
|
||||
_cooldown = 1 - dt / Cooldown;
|
||||
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
|
||||
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / CountdownSpan;
|
||||
// The round starts the cooldown after this tick's decrement above: charge the tick and the last
|
||||
// countdown's overshoot to it, so the cadence stays Cooldown
|
||||
_coolingDown = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -277,16 +353,31 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// whole expression, argument included, whenever OnFire has no subscriber (a headless run with no
|
||||
// Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired.
|
||||
// Evaluate it into a local first.
|
||||
var shotId = FireControl.Fire(this, Item, Entity);
|
||||
var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval);
|
||||
OnFire?.Invoke(shotId);
|
||||
CauseWearDamage(1);
|
||||
AddHeat(Heat);
|
||||
Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item);
|
||||
}
|
||||
// A cooldown shorter than the step it started in is over in that step.
|
||||
ended |= FinishCountdown(dt);
|
||||
// The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it
|
||||
// ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready.
|
||||
if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break;
|
||||
retriggered = true;
|
||||
Trigger();
|
||||
if (_burstRemaining == 0) break;
|
||||
if (ended)
|
||||
{
|
||||
_burstTimer = TakeOvershoot();
|
||||
ended = false;
|
||||
}
|
||||
else _burstTimer = dt;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void Activate()
|
||||
internal override void Activate()
|
||||
{
|
||||
if(CanFire)
|
||||
Trigger();
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else.
|
||||
public int DefaultPowerTier => PowerTiers.Utility;
|
||||
|
||||
public void Ping()
|
||||
internal void Ping()
|
||||
{
|
||||
// Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator
|
||||
// ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so
|
||||
|
|
@ -145,7 +145,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
{
|
||||
_pinging = true;
|
||||
_pingZone = Entity.Zone;
|
||||
_pingCooldown = 1;
|
||||
_pingCooldown += 1;
|
||||
_pingLerp = 0;
|
||||
_pingRadius = 0;
|
||||
_reached.Clear();
|
||||
|
|
@ -180,8 +180,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
if (_pingLerp > 1) EndPing();
|
||||
}
|
||||
|
||||
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
|
||||
// A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact.
|
||||
var pingStep = dt / Evaluate(_data.PingCooldown);
|
||||
_pingCooldown = max(_pingCooldown - pingStep, -pingStep);
|
||||
|
||||
var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay;
|
||||
var decay = exp(-infoDecay * dt);
|
||||
var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt;
|
||||
|
||||
var forward = Direction.xz;
|
||||
foreach (var entity in Entity.Zone.Entities)
|
||||
{
|
||||
|
|
@ -221,14 +227,16 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration));
|
||||
}
|
||||
|
||||
// The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second,
|
||||
// which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first.
|
||||
if (heard)
|
||||
next = saturate(previous + credit);
|
||||
next = saturate(previous + credit) * decay;
|
||||
else
|
||||
{
|
||||
var angle = acos(dot(forward, normalize(diff)));
|
||||
next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
|
||||
var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration);
|
||||
next = saturate(previous * decay + passive * settle);
|
||||
}
|
||||
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
|
||||
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
|
||||
Entity.EntityInfoGathered[entity] = next;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ public class SwitchData : BehaviorData
|
|||
}
|
||||
}
|
||||
|
||||
public class Switch : Behavior, IActivatedBehavior
|
||||
public class Switch : ActivatedBehavior
|
||||
{
|
||||
private SwitchData _data;
|
||||
|
||||
public bool Activated { get; set; }
|
||||
public bool Activated { get; private set; }
|
||||
|
||||
public Switch(SwitchData data, EquippedItem item) : base(data, item)
|
||||
{
|
||||
|
|
@ -40,12 +40,12 @@ public class Switch : Behavior, IActivatedBehavior
|
|||
return Activated;
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
internal override void Activate()
|
||||
{
|
||||
Activated = true;
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
internal override void Deactivate()
|
||||
{
|
||||
Activated = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ public class Thruster : Behavior, IPowerConsumer
|
|||
public float Thrust => Evaluate(_data.Thrust);
|
||||
// Thrust at a full grant, the same stat read without the power-supply term: what a column promises. Live, like Thrust.
|
||||
public float NominalThrust => EvaluateNominalPower(_data.Thrust);
|
||||
// GameplaySettings.ThrustScale scales the push alone: it tunes how hard ships fight gravity, and turning does not.
|
||||
public float Torque { get; }
|
||||
|
||||
// This thruster's effect per unit throttle at the given thrust, in the body frame: xy is the push (x starboard, y
|
||||
|
|
@ -56,7 +57,7 @@ public class Thruster : Behavior, IPowerConsumer
|
|||
public float3 Column(float thrust)
|
||||
{
|
||||
if (Item == null || !Item.Active.Value) return default;
|
||||
var push = -float2(0, 1).Rotate(Item.EquippableItem.Rotation) * thrust / Entity.Mass;
|
||||
var push = -float2(0, 1).Rotate(Item.EquippableItem.Rotation) * thrust * ItemManager.GameplaySettings.ThrustScale / Entity.Mass;
|
||||
return float3(push.x, push.y, Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass);
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +118,7 @@ public class Thruster : Behavior, IPowerConsumer
|
|||
if(_input > 0f && Item.PowerSupply > 1e-4f)
|
||||
{
|
||||
var thrust = Thrust;
|
||||
Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt;
|
||||
Entity.Velocity -= Direction.xz * _input * thrust * ItemManager.GameplaySettings.ThrustScale / Entity.Mass * dt;
|
||||
var turnRate = _input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass;
|
||||
Entity.Direction = mul(Entity.Direction, CultMath.float2x2.Rotate(turnRate * dt));
|
||||
Entity.TurnRate += turnRate;
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ public class TriggerData : BehaviorData
|
|||
}
|
||||
}
|
||||
|
||||
public class Trigger : Behavior, IActivatedBehavior
|
||||
public class Trigger : ActivatedBehavior
|
||||
{
|
||||
public bool _pulled;
|
||||
private bool _pulled;
|
||||
|
||||
public Trigger(TriggerData data, EquippedItem item) : base(data, item) { }
|
||||
public Trigger(TriggerData data, ConsumableItemEffect item) : base(data, item) { }
|
||||
|
|
@ -38,12 +38,12 @@ public class Trigger : Behavior, IActivatedBehavior
|
|||
return false;
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
internal override void Activate()
|
||||
{
|
||||
_pulled = true;
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
internal override void Deactivate()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -11,12 +11,7 @@ using Newtonsoft.Json;
|
|||
using CultMath;
|
||||
using static CultMath.math;
|
||||
|
||||
[Inspectable,
|
||||
Union(0, typeof(InstantWeaponData)),
|
||||
Union(1, typeof(LauncherData)),
|
||||
Union(2, typeof(ConstantWeaponData)),
|
||||
Union(3, typeof(ChargedWeaponData)),
|
||||
RuntimeInspectable]
|
||||
[Inspectable, RuntimeInspectable]
|
||||
public abstract class WeaponData : BehaviorData
|
||||
{
|
||||
[Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable]
|
||||
|
|
@ -100,7 +95,7 @@ public abstract class WeaponData : BehaviorData
|
|||
public float RatedRange => Range.Max;
|
||||
}
|
||||
|
||||
public abstract class Weapon : Behavior, IActivatedBehavior
|
||||
public abstract class Weapon : ActivatedBehavior
|
||||
{
|
||||
private WeaponData _data;
|
||||
|
||||
|
|
@ -179,12 +174,12 @@ public abstract class Weapon : Behavior, IActivatedBehavior
|
|||
return true;
|
||||
}
|
||||
|
||||
public virtual void Activate()
|
||||
internal override void Activate()
|
||||
{
|
||||
_firing = true;
|
||||
}
|
||||
|
||||
public virtual void Deactivate()
|
||||
internal override void Deactivate()
|
||||
{
|
||||
_firing = false;
|
||||
}
|
||||
|
|
|
|||
125
Assets/Scripts/ServerShared/CargoTransfer.cs
Normal file
125
Assets/Scripts/ServerShared/CargoTransfer.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/* 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.Linq;
|
||||
using CultMath;
|
||||
using static CultMath.math;
|
||||
|
||||
// Why a give ended. Notification only: nothing reads it to decide anything.
|
||||
public enum GiveEnd
|
||||
{
|
||||
Done,
|
||||
Stopped,
|
||||
OutOfRange,
|
||||
ReceiverGone,
|
||||
ReceiverFull,
|
||||
NothingLeft,
|
||||
GiverDocked,
|
||||
GiverGone
|
||||
}
|
||||
|
||||
// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved.
|
||||
public sealed class GiveOrder
|
||||
{
|
||||
public readonly Entity Receiver;
|
||||
// A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance.
|
||||
public readonly ItemInstance Item;
|
||||
// Units still to move.
|
||||
public int Remaining { get; internal set; }
|
||||
// Units earned and not yet moved, fractional between steps.
|
||||
internal double Budget;
|
||||
|
||||
internal GiveOrder(Entity receiver, ItemInstance item, int quantity)
|
||||
{
|
||||
Receiver = receiver;
|
||||
Item = item;
|
||||
Remaining = quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through
|
||||
// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling
|
||||
// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit.
|
||||
public static class CargoTransfer
|
||||
{
|
||||
internal static void Begin(Entity giver, GiveOrder order)
|
||||
{
|
||||
End(giver, GiveEnd.Stopped);
|
||||
giver.Giving = order;
|
||||
}
|
||||
|
||||
// Ends the giver's give, if it has one, for the reason.
|
||||
internal static void End(Entity giver, GiveEnd reason)
|
||||
{
|
||||
if (giver.Giving == null) return;
|
||||
giver.Giving = null;
|
||||
giver.LastGiveEnd = reason;
|
||||
}
|
||||
|
||||
// One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved
|
||||
// into the first receiver bay that takes it.
|
||||
public static void Step(Zone zone, float deltaTime)
|
||||
{
|
||||
foreach (var giver in zone.Entities.ToArray())
|
||||
{
|
||||
var order = giver.Giving;
|
||||
if (order == null) continue;
|
||||
var settings = giver.ItemManager.GameplaySettings;
|
||||
var range = settings.DockingDistance;
|
||||
if (!zone.Entities.Contains(order.Receiver))
|
||||
{
|
||||
End(giver, GiveEnd.ReceiverGone);
|
||||
continue;
|
||||
}
|
||||
if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range)
|
||||
{
|
||||
End(giver, GiveEnd.OutOfRange);
|
||||
continue;
|
||||
}
|
||||
|
||||
order.Budget += (double) settings.TransferRate * deltaTime;
|
||||
while (order.Budget >= 1 && order.Remaining > 0)
|
||||
{
|
||||
var source = Source(giver, order);
|
||||
if (source.Bay == null)
|
||||
{
|
||||
End(giver, GiveEnd.NothingLeft);
|
||||
break;
|
||||
}
|
||||
if (!Move(source.Bay, source.Item, order.Receiver))
|
||||
{
|
||||
End(giver, GiveEnd.ReceiverFull);
|
||||
break;
|
||||
}
|
||||
order.Budget -= 1;
|
||||
order.Remaining--;
|
||||
}
|
||||
if (order.Remaining == 0) End(giver, GiveEnd.Done);
|
||||
}
|
||||
}
|
||||
|
||||
// Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance.
|
||||
private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order)
|
||||
{
|
||||
foreach (var bay in giver.CargoBays)
|
||||
{
|
||||
if (order.Item is CraftedItemInstance crafted)
|
||||
{
|
||||
if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted);
|
||||
}
|
||||
else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks))
|
||||
{
|
||||
var stack = stacks.OfType<SimpleCommodity>().FirstOrDefault();
|
||||
if (stack != null) return (bay, stack);
|
||||
}
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// One unit of a commodity, or the crafted item, into the first receiver bay that takes it.
|
||||
private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) =>
|
||||
item is SimpleCommodity commodity
|
||||
? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1))
|
||||
: receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item));
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/CargoTransfer.cs.meta
Normal file
11
Assets/Scripts/ServerShared/CargoTransfer.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7be41c09d3a84f5e9a206d18c4f3b5a2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
469
Assets/Scripts/ServerShared/EconomyChecks.cs
Normal file
469
Assets/Scripts/ServerShared/EconomyChecks.cs
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
|
||||
// What the economy checks read beyond the catalog: the setting's factors on recipe terms (each at least 1) and the units of a
|
||||
// commodity one worker consumes per second of work. Neutral by default; later cuts pass real values.
|
||||
public sealed class EconomyLoad
|
||||
{
|
||||
public Dictionary<string, double> LabourNeeds = new Dictionary<string, double>();
|
||||
public double InputFactor = 1;
|
||||
public double WorkforceFactor = 1;
|
||||
public double UpkeepFactor = 1;
|
||||
}
|
||||
|
||||
public sealed class EconomyFault
|
||||
{
|
||||
public readonly string Invariant;
|
||||
public readonly string Record;
|
||||
public readonly string Message;
|
||||
|
||||
// A fault only because the chain has not written the record yet (a commodity with no recipe). Reported, never a refusal.
|
||||
public readonly bool Pending;
|
||||
|
||||
// L-productive only: every good the carrying cycle holds (sorted) and the spectral radius. The fault's identity is the member
|
||||
// set; a radius that rises is a worse fault of the same identity.
|
||||
public readonly IReadOnlyList<string> Members;
|
||||
public readonly double Radius;
|
||||
|
||||
public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList<string> members = null, double radius = 0)
|
||||
{
|
||||
Members = members;
|
||||
Radius = radius;
|
||||
Pending = pending;
|
||||
Invariant = invariant;
|
||||
Record = record;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Invariant} {Record}: {Message}";
|
||||
}
|
||||
|
||||
public sealed class EconomyReport
|
||||
{
|
||||
public readonly IReadOnlyList<EconomyFault> Faults;
|
||||
// Production steps to each good, by the good's record key; null when no finite chain reaches it. Read by reviewers, never a refusal.
|
||||
public readonly IReadOnlyDictionary<string, int?> Depth;
|
||||
|
||||
public EconomyReport(IReadOnlyList<EconomyFault> faults, IReadOnlyDictionary<string, int?> depth)
|
||||
{
|
||||
Faults = faults;
|
||||
Depth = depth;
|
||||
}
|
||||
}
|
||||
|
||||
// The one owner of the catalog's economy invariants. Pure: draws nothing, reads no clock, writes nothing. One method per rule.
|
||||
public static class EconomyChecks
|
||||
{
|
||||
public const string LRecipe = "L-recipe", LOneKindList = "L-one-kind-list", LLore = "L-lore", LSource = "L-source-sink", LProductive = "L-productive", LDemand = "L-consumer-demand";
|
||||
private const double Margin = 1e-9;
|
||||
|
||||
public static EconomyReport Check(CultCache catalog, EconomyLoad load = null)
|
||||
{
|
||||
var faults = new List<EconomyFault>();
|
||||
faults.AddRange(Recipe(catalog));
|
||||
faults.AddRange(Lore(catalog));
|
||||
faults.AddRange(SourceSink(catalog));
|
||||
faults.AddRange(Productive(catalog, load));
|
||||
faults.AddRange(ConsumerDemand(catalog));
|
||||
return new EconomyReport(faults, Deep(catalog));
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Key, RecipeData Recipe)> StoredRecipes(CultCache catalog) =>
|
||||
catalog.GetAll<RecipeData>().Select(recipe => (catalog.RefOf(recipe).Key.Value, recipe)).OrderBy(pair => pair.Item1, StringComparer.Ordinal);
|
||||
|
||||
// Every rule but Recipe reads recipes through here, so a list a record holds as null (or with a null entry) reads as the list
|
||||
// without it; Recipe alone names the malformation. The stored document is never changed.
|
||||
private static IEnumerable<(string Key, RecipeData Recipe)> AllRecipes(CultCache catalog) =>
|
||||
StoredRecipes(catalog).Select(pair => (pair.Key, Sound(pair.Recipe)));
|
||||
|
||||
private static RecipeData Sound(RecipeData recipe) =>
|
||||
recipe.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && recipe.RoleInputs != null
|
||||
&& !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) && !recipe.Fills.Contains(null) && !recipe.RoleInputs.Contains(null)
|
||||
? recipe
|
||||
: new RecipeData
|
||||
{
|
||||
Output = recipe.Output, OutputCount = recipe.OutputCount, Cycle = recipe.Cycle, Workforce = recipe.Workforce,
|
||||
StationKind = recipe.StationKind, LoreHook = recipe.LoreHook,
|
||||
Inputs = (recipe.Inputs ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
|
||||
Upkeep = (recipe.Upkeep ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
|
||||
Fills = (recipe.Fills ?? new List<string>()).Where(f => f != null).ToList(),
|
||||
RoleInputs = (recipe.RoleInputs ?? new List<ItemRole>()).Where(r => r != null).ToList()
|
||||
};
|
||||
|
||||
// The designs some faction offers, but a Station or Turret hull, distinct and ordered by key: the designs that need a recipe.
|
||||
private static List<ItemData> OfferedDesigns(CultCache catalog)
|
||||
{
|
||||
var designs = new Dictionary<string, ItemData>(StringComparer.Ordinal);
|
||||
foreach (var product in catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p)))
|
||||
{
|
||||
var design = catalog.Get(product.Design);
|
||||
if (design == null || (design is HullData hull && (hull.HullType == HullType.Station || hull.HullType == HullType.Turret))) continue;
|
||||
designs.TryAdd(catalog.RefOf(design).Key.Value, design);
|
||||
}
|
||||
return designs.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => pair.Value).ToList();
|
||||
}
|
||||
|
||||
// The roles some design demands: those of every offered design and of every design a recipe makes, recipe or no.
|
||||
private static HashSet<string> DesignRoles(CultCache catalog) =>
|
||||
new HashSet<string>(OfferedDesigns(catalog).Concat(AllRecipes(catalog).Select(p => catalog.Get(p.Recipe.Output)).Where(o => o != null && !Recipes.IsCommodity(o)))
|
||||
.SelectMany(design => Recipes.RolesOf(design)).Select(role => role.Name), StringComparer.Ordinal);
|
||||
|
||||
private static IEnumerable<ItemData> Commodities(CultCache catalog) =>
|
||||
catalog.GetAll<ItemData>().Where(Recipes.IsCommodity).OrderBy(item => catalog.RefOf(item).Key.Value, StringComparer.Ordinal);
|
||||
|
||||
// Every commodity and every offered design (but a Station or Turret hull) has a recipe, and each recipe is well formed.
|
||||
public static List<EconomyFault> Recipe(CultCache catalog)
|
||||
{
|
||||
var faults = new List<EconomyFault>();
|
||||
void Add(string record, string message) => faults.Add(new EconomyFault(LRecipe, record, message));
|
||||
foreach (var item in Commodities(catalog))
|
||||
if (Recipes.RecipeOf(catalog, item) == null)
|
||||
faults.Add(new EconomyFault(LRecipe, catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe.", pending: true));
|
||||
foreach (var design in OfferedDesigns(catalog))
|
||||
if (Recipes.RecipeOf(catalog, design) == null)
|
||||
faults.Add(new EconomyFault(LRecipe, catalog.RefOf(design).Key.Value, $"Offered design '{design.Name}' has no recipe.", pending: true));
|
||||
foreach (var market in catalog.GetAll<OutsideMarketData>())
|
||||
{
|
||||
var marketKey = catalog.RefOf(market).Key.Value;
|
||||
if (market.Rows == null) Add(marketKey, "Rows is null; an outside market holds a list, empty when it trades nothing.");
|
||||
else if (market.Rows.Contains(null)) Add(marketKey, "Rows holds a null entry.");
|
||||
}
|
||||
var kindsPlaced = new HashSet<string>(catalog.GetAll<Faction>().SelectMany(f => StationKinds.KindsOf(catalog, f)).Select(k => k.Key.Value), StringComparer.Ordinal);
|
||||
foreach (var (key, stored) in StoredRecipes(catalog))
|
||||
{
|
||||
var recipe = Sound(stored);
|
||||
foreach (var (list, name) in new[] { (stored.Inputs, "Inputs"), (stored.Upkeep, "Upkeep") })
|
||||
if (list == null) Add(key, $"{name} is null; a recipe holds a list, empty when it takes nothing.");
|
||||
else if (list.Contains(null)) Add(key, $"{name} holds a null entry.");
|
||||
if (stored.Fills == null) Add(key, "Fills is null; a recipe holds a list, empty when it fills no role.");
|
||||
else if (stored.Fills.Contains(null)) Add(key, "Fills holds a null entry.");
|
||||
if (stored.RoleInputs == null) Add(key, "RoleInputs is null; a recipe holds a list, empty when it takes no role.");
|
||||
else if (stored.RoleInputs.Contains(null)) Add(key, "RoleInputs holds a null entry.");
|
||||
var output = catalog.Get(recipe.Output);
|
||||
if (output == null) { Add(key, "Output names no item this catalog holds."); continue; }
|
||||
if (key != RecipeData.KeyOf(recipe.Output.Key).Value)
|
||||
Add(key, "The recipe's key is not recipe:<output key>; one recipe per output is its key.");
|
||||
if (recipe.OutputCount <= 0) Add(key, "OutputCount is not above zero.");
|
||||
if (!float.IsFinite(recipe.Cycle) || recipe.Cycle <= 0) Add(key, "Cycle is not a finite number above zero.");
|
||||
if (recipe.Workforce < 0) Add(key, "Workforce is below zero.");
|
||||
foreach (var (list, name) in new[] { (recipe.Inputs, "Inputs"), (recipe.Upkeep, "Upkeep") })
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (list[i].Count <= 0) Add(key, $"{name}[{i}] count is not above zero.");
|
||||
if (!Recipes.IsCommodity(catalog.Get(list[i].Commodity))) Add(key, $"{name}[{i}] names no commodity; a recipe takes only commodities.");
|
||||
}
|
||||
var kind = catalog.Get(recipe.StationKind);
|
||||
if (kind == null) Add(key, "StationKind names no station kind this catalog holds.");
|
||||
else if (!kindsPlaced.Contains(recipe.StationKind.Key.Value))
|
||||
faults.Add(new EconomyFault(LOneKindList, key, $"No faction places station kind '{kind.Name}', so nothing can run this recipe."));
|
||||
var roles = Recipes.RolesOf(output);
|
||||
if (!Recipes.IsCommodity(output))
|
||||
{
|
||||
if (recipe.Fills.Count > 0) Add(key, "Fills is set on a design; only a commodity fills a role.");
|
||||
if (roles.Count == 0) Add(key, "A design's recipe needs a design with at least one role.");
|
||||
}
|
||||
if (roles.Count > 0 && recipe.Inputs.Count > 0) Add(key, "A role recipe has no Inputs; its inputs are its design's roles.");
|
||||
if (roles.Count > 0 && recipe.RoleInputs.Count > 0) Add(key, "A role recipe has no RoleInputs; its role inputs are its design's roles.");
|
||||
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(role.Name)) { Add(key, "A role input has an empty name."); continue; }
|
||||
if (role.Count < 1) Add(key, $"Role '{role.Name}' count is below one.");
|
||||
if (Recipes.Fillers(catalog, role.Name).Length == 0) Add(key, $"Role '{role.Name}' has no commodity whose recipe fills it.");
|
||||
}
|
||||
}
|
||||
return faults;
|
||||
}
|
||||
|
||||
// A commodity's lore hook is present. What it says is the operator's review.
|
||||
public static List<EconomyFault> Lore(CultCache catalog) =>
|
||||
AllRecipes(catalog).Where(pair => Recipes.IsCommodity(catalog.Get(pair.Recipe.Output)) && string.IsNullOrWhiteSpace(pair.Recipe.LoreHook))
|
||||
.Select(pair => new EconomyFault(LLore, pair.Key, "A commodity's recipe needs a lore hook.")).ToList();
|
||||
|
||||
private static (HashSet<string> Sells, HashSet<string> Buys) Gate(CultCache catalog)
|
||||
{
|
||||
var rows = catalog.GetAll<OutsideMarketData>().SelectMany(market => market.Rows ?? new List<OutsideMarketRow>()).Where(r => r != null).ToList();
|
||||
return (new HashSet<string>(rows.Where(r => r.Sells).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal),
|
||||
new HashSet<string>(rows.Where(r => r.Buys).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
// The commodities an offered design's weapon draws as ammunition. One reader, shared by the sink and the consumer-demand rules.
|
||||
private static HashSet<string> Drawn(CultCache catalog) =>
|
||||
new HashSet<string>(catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p))
|
||||
.Select(p => catalog.Get(p.Design) as EquippableItemData).Where(design => design?.Behaviors != null)
|
||||
.SelectMany(design => design.Behaviors.OfType<WeaponData>()).Where(weapon => weapon.AmmoType.IsSet())
|
||||
.Select(weapon => weapon.AmmoType.Key.Value), StringComparer.Ordinal);
|
||||
|
||||
// Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe takes it or a role it fills, a role
|
||||
// some design (offered, or made by a recipe) names, a weapon some offered design carries draws it, or a gate that buys it). A consumer good's sink is judged once needs
|
||||
// tables exist.
|
||||
public static List<EconomyFault> SourceSink(CultCache catalog)
|
||||
{
|
||||
var faults = new List<EconomyFault>();
|
||||
var (sells, buys) = Gate(catalog);
|
||||
var drawn = Drawn(catalog);
|
||||
var grounded = Depths(catalog, sells);
|
||||
var recipes = AllRecipes(catalog).ToList();
|
||||
var used = new HashSet<string>(recipes.SelectMany(p => p.Recipe.Inputs.Concat(p.Recipe.Upkeep)).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
|
||||
var rolesNamed = DesignRoles(catalog);
|
||||
rolesNamed.UnionWith(recipes.SelectMany(p => Recipes.RoleInputsOf(catalog, p.Recipe)).Select(r => r.Name));
|
||||
foreach (var (key, recipe) in recipes)
|
||||
{
|
||||
var output = catalog.Get(recipe.Output);
|
||||
if (!Recipes.IsCommodity(output)) continue;
|
||||
var outputKey = recipe.Output.Key.Value;
|
||||
if (!grounded.TryGetValue(outputKey, out var depth) || depth == null)
|
||||
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no source: no chain of recipes reaches raw goods and no gate sells it."));
|
||||
if (Recipes.IsConsumerGood(catalog, recipe)) continue;
|
||||
if (!used.Contains(outputKey) && !recipe.Fills.Any(rolesNamed.Contains) && !drawn.Contains(outputKey) && !buys.Contains(outputKey))
|
||||
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no sink: no recipe takes it or a role it fills, no design names a role it fills, no weapon draws it, no gate buys it."));
|
||||
}
|
||||
return faults;
|
||||
}
|
||||
|
||||
// Production steps to each good: raw goods 0, a recipe's output one more than its deepest input or role, a role as deep as
|
||||
// its shallowest filler (Knuth's least fixpoint over AND and OR nodes). Null for a good no finite chain reaches. Reported for
|
||||
// review; it refuses nothing.
|
||||
public static IReadOnlyDictionary<string, int?> Deep(CultCache catalog) => Depths(catalog, new HashSet<string>());
|
||||
|
||||
private static Dictionary<string, int?> Depths(CultCache catalog, ISet<string> seeds)
|
||||
{
|
||||
var depth = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var seed in seeds) depth[seed] = 0;
|
||||
var recipes = AllRecipes(catalog).Where(p => catalog.Get(p.Recipe.Output) != null).ToList();
|
||||
bool changed;
|
||||
do
|
||||
{
|
||||
changed = false;
|
||||
foreach (var (_, recipe) in recipes)
|
||||
{
|
||||
var candidate = Candidate(catalog, recipe, depth);
|
||||
var key = recipe.Output.Key.Value;
|
||||
if (candidate == null || (depth.TryGetValue(key, out var known) && known <= candidate)) continue;
|
||||
depth[key] = candidate.Value;
|
||||
changed = true;
|
||||
}
|
||||
} while (changed);
|
||||
var result = new Dictionary<string, int?>(StringComparer.Ordinal);
|
||||
foreach (var item in Commodities(catalog)) result[catalog.RefOf(item).Key.Value] = null;
|
||||
foreach (var (_, recipe) in recipes) result[recipe.Output.Key.Value] = null;
|
||||
foreach (var pair in depth) result[pair.Key] = pair.Value;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int? Candidate(CultCache catalog, RecipeData recipe, Dictionary<string, int> depth)
|
||||
{
|
||||
if (Recipes.IsRaw(catalog, recipe)) return 0;
|
||||
var deepest = 0;
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
if (!depth.TryGetValue(input.Commodity.Key.Value, out var d)) return null;
|
||||
deepest = Math.Max(deepest, d);
|
||||
}
|
||||
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
|
||||
{
|
||||
int? shallowest = null;
|
||||
foreach (var filler in Recipes.Fillers(catalog, role.Name))
|
||||
if (depth.TryGetValue(filler.Value, out var d) && (shallowest == null || d < shallowest)) shallowest = d;
|
||||
if (shallowest == null) return null;
|
||||
deepest = Math.Max(deepest, shallowest.Value);
|
||||
}
|
||||
return deepest + 1;
|
||||
}
|
||||
|
||||
// Hawkins-Simon over commodities plus one labour good, at the worst single filler of every role slot (a recipe's role inputs): the spectral radius
|
||||
// of the requirements matrix stays below one however the roles are filled. The worst fill is the spectral simplex
|
||||
// (Nesterov and Protasov 2013) on the matrix nudged strictly positive, then the radius of the chosen matrix is taken exactly.
|
||||
public static List<EconomyFault> Productive(CultCache catalog, EconomyLoad load = null)
|
||||
{
|
||||
load = load ?? new EconomyLoad();
|
||||
if (!(load.InputFactor >= 1) || !(load.WorkforceFactor >= 1) || !(load.UpkeepFactor >= 1))
|
||||
throw new ArgumentException("Setting factors are at least 1.", nameof(load));
|
||||
var columns = AllRecipes(catalog).Where(p => Recipes.IsCommodity(catalog.Get(p.Recipe.Output)) && p.Recipe.OutputCount > 0 && float.IsFinite(p.Recipe.Cycle)).ToList();
|
||||
var names = columns.Select(p => p.Recipe.Output.Key.Value).ToList();
|
||||
var index = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < names.Count; i++) index[names[i]] = i;
|
||||
var n = names.Count + 1;
|
||||
var labour = names.Count;
|
||||
var fixedTerms = new double[n, n];
|
||||
var slots = new List<(int Column, double Amount, int[] Fillers)>();
|
||||
for (var j = 0; j < columns.Count; j++)
|
||||
{
|
||||
var recipe = columns[j].Recipe;
|
||||
foreach (var input in recipe.Inputs)
|
||||
if (index.TryGetValue(input.Commodity.Key.Value, out var row) && input.Count > 0) fixedTerms[row, j] += input.Count * load.InputFactor / recipe.OutputCount;
|
||||
foreach (var upkeep in recipe.Upkeep)
|
||||
if (index.TryGetValue(upkeep.Commodity.Key.Value, out var row) && upkeep.Count > 0) fixedTerms[row, j] += upkeep.Count * load.UpkeepFactor / recipe.OutputCount;
|
||||
fixedTerms[labour, j] = recipe.Workforce * (double) recipe.Cycle * load.WorkforceFactor / recipe.OutputCount;
|
||||
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
|
||||
{
|
||||
var fillers = Recipes.Fillers(catalog, role.Name).Where(k => index.ContainsKey(k.Value)).Select(k => index[k.Value]).ToArray();
|
||||
if (fillers.Length > 0 && role.Count > 0) slots.Add((j, role.Count * load.InputFactor / recipe.OutputCount, fillers));
|
||||
}
|
||||
}
|
||||
foreach (var need in load.LabourNeeds)
|
||||
if (index.TryGetValue(need.Key, out var row)) fixedTerms[row, labour] = need.Value;
|
||||
|
||||
double[,] Build(int[] choice, double nudge)
|
||||
{
|
||||
var a = new double[n, n];
|
||||
for (var r = 0; r < n; r++)
|
||||
for (var c = 0; c < n; c++)
|
||||
a[r, c] = fixedTerms[r, c] + nudge;
|
||||
for (var s = 0; s < slots.Count; s++) a[slots[s].Fillers[choice[s]], slots[s].Column] += slots[s].Amount;
|
||||
return a;
|
||||
}
|
||||
|
||||
var pick = new int[slots.Count];
|
||||
for (var round = 0; round < 500; round++)
|
||||
{
|
||||
var left = LeftPerron(Build(pick, 1e-12), n);
|
||||
var moved = false;
|
||||
for (var s = 0; s < slots.Count; s++)
|
||||
{
|
||||
var best = pick[s];
|
||||
for (var f = 0; f < slots[s].Fillers.Length; f++)
|
||||
if (left[slots[s].Fillers[f]] > left[slots[s].Fillers[best]] * (1 + 1e-9)) best = f;
|
||||
if (best != pick[s]) { pick[s] = best; moved = true; }
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
var final = Build(pick, 0);
|
||||
var faults = new List<EconomyFault>();
|
||||
string Name(int i) => i == labour ? "labour" : names[i];
|
||||
foreach (var (rho, component) in Radii(final, n))
|
||||
{
|
||||
if (rho < 1 - Margin) continue;
|
||||
var order = CycleOrder(final, n, component);
|
||||
faults.Add(new EconomyFault(LProductive, Name(order[0]),
|
||||
$"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.",
|
||||
members: order.Select(Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(), radius: rho));
|
||||
}
|
||||
return faults;
|
||||
}
|
||||
|
||||
// Left Perron vector of a strictly positive matrix by power iteration on A + I (aperiodic, so a pure cycle converges too).
|
||||
private static double[] LeftPerron(double[,] a, int n)
|
||||
{
|
||||
var u = Enumerable.Repeat(1.0 / n, n).ToArray();
|
||||
for (var iteration = 0; iteration < 20000; iteration++)
|
||||
{
|
||||
var next = new double[n];
|
||||
for (var c = 0; c < n; c++)
|
||||
for (var r = 0; r < n; r++)
|
||||
next[c] += u[r] * (a[r, c] + (r == c ? 1 : 0));
|
||||
var norm = next.Sum();
|
||||
var delta = 0.0;
|
||||
for (var c = 0; c < n; c++) { next[c] /= norm; delta = Math.Max(delta, Math.Abs(next[c] - u[c])); }
|
||||
u = next;
|
||||
if (delta < 1e-15) break;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
// The spectral radius of every strongly connected component of a nonnegative matrix, each by power iteration on A + I
|
||||
// (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. A component with no cycle has radius 0.
|
||||
private static List<(double Rho, List<int> Component)> Radii(double[,] a, int n)
|
||||
{
|
||||
var reach = new bool[n, n];
|
||||
for (var i = 0; i < n; i++)
|
||||
for (var j = 0; j < n; j++)
|
||||
reach[i, j] = i == j || a[j, i] > 0;
|
||||
for (var k = 0; k < n; k++)
|
||||
for (var i = 0; i < n; i++)
|
||||
if (reach[i, k])
|
||||
for (var j = 0; j < n; j++)
|
||||
if (reach[k, j]) reach[i, j] = true;
|
||||
var done = new bool[n];
|
||||
var radii = new List<(double, List<int>)>();
|
||||
for (var start = 0; start < n; start++)
|
||||
{
|
||||
if (done[start]) continue;
|
||||
var component = Enumerable.Range(0, n).Where(i => reach[start, i] && reach[i, start]).ToList();
|
||||
foreach (var i in component) done[i] = true;
|
||||
var rho = Irreducible(a, component);
|
||||
if (double.IsNaN(rho)) rho = double.PositiveInfinity;
|
||||
radii.Add((rho, component));
|
||||
}
|
||||
return radii;
|
||||
}
|
||||
|
||||
private static double Irreducible(double[,] a, List<int> nodes)
|
||||
{
|
||||
var m = nodes.Count;
|
||||
var x = Enumerable.Repeat(1.0, m).ToArray();
|
||||
var lambda = 0.0;
|
||||
for (var iteration = 0; iteration < 200000; iteration++)
|
||||
{
|
||||
var next = new double[m];
|
||||
for (var r = 0; r < m; r++)
|
||||
{
|
||||
next[r] = x[r];
|
||||
for (var c = 0; c < m; c++) next[r] += a[nodes[r], nodes[c]] * x[c];
|
||||
}
|
||||
var norm = next.Max();
|
||||
var moved = 0.0;
|
||||
for (var r = 0; r < m; r++) { next[r] /= norm; moved = Math.Max(moved, Math.Abs(next[r] - x[r])); }
|
||||
lambda = norm;
|
||||
x = next;
|
||||
if (moved < 1e-15) break;
|
||||
}
|
||||
return lambda - 1;
|
||||
}
|
||||
|
||||
// The component's goods in cycle order: breadth-first from its first good along "needs" edges, so a simple cycle reads
|
||||
// around once and a tangled component reads outward from where it starts.
|
||||
private static List<int> CycleOrder(double[,] a, int n, List<int> component)
|
||||
{
|
||||
var inside = new HashSet<int>(component);
|
||||
var order = new List<int> { component[0] };
|
||||
var seen = new HashSet<int> { component[0] };
|
||||
for (var at = 0; at < order.Count; at++)
|
||||
for (var i = 0; i < n; i++)
|
||||
if (a[i, order[at]] > 0 && inside.Contains(i) && seen.Add(i)) order.Add(i);
|
||||
return order;
|
||||
}
|
||||
|
||||
// A processed good owes a consumer use when it is shared (rulings consumer-demand-shared-only, draw-is-one-use). Its uses are
|
||||
// the gear or hull roles, and the rounds some weapon draws, that the goods downstream of it (itself included) fill or are; a
|
||||
// good used as upkeep is shared too. Shared means two or more such uses, or upkeep. A shared good must lead, through the
|
||||
// chain (an input, upkeep or role-input edge: a role input is an edge from each of its fillers), into a consumer good or a role one names. A good whose every use is one gear role is exempt like its final assembly.
|
||||
// Derived from the recipe graph on every check; depth and distance play no part. Raw goods and the gate count for nothing.
|
||||
public static List<EconomyFault> ConsumerDemand(CultCache catalog)
|
||||
{
|
||||
var recipes = AllRecipes(catalog).Where(p => catalog.Get(p.Recipe.Output) != null).ToDictionary(p => p.Recipe.Output.Key.Value, p => p.Recipe, StringComparer.Ordinal);
|
||||
var drawn = Drawn(catalog);
|
||||
bool IsCommodityRecipe(RecipeData r) => Recipes.IsCommodity(catalog.Get(r.Output));
|
||||
var gearRoles = DesignRoles(catalog);
|
||||
var upkeep = new HashSet<string>(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
|
||||
var takenBy = recipes.Values.Where(IsCommodityRecipe)
|
||||
.SelectMany(r => r.Inputs.Concat(r.Upkeep).Select(input => input.Commodity.Key.Value)
|
||||
.Concat(Recipes.RoleInputsOf(catalog, r).SelectMany(role => Recipes.Fillers(catalog, role.Name)).Select(filler => filler.Value))
|
||||
.Select(input => (Input: input, Output: r.Output.Key.Value)))
|
||||
.ToLookup(edge => edge.Input, edge => edge.Output, StringComparer.Ordinal);
|
||||
var faults = new List<EconomyFault>();
|
||||
foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var recipe = recipes[key];
|
||||
if (!IsCommodityRecipe(recipe) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue;
|
||||
var reached = new HashSet<string>(StringComparer.Ordinal) { key };
|
||||
var open = new Stack<string>(reached);
|
||||
while (open.Count > 0)
|
||||
foreach (var next in takenBy[open.Pop()])
|
||||
if (reached.Add(next)) open.Push(next);
|
||||
var members = reached.Select(k => recipes[k]).ToList();
|
||||
if (members.Any(r => Recipes.IsConsumerGood(catalog, r))) continue;
|
||||
var uses = members.SelectMany(r => r.Fills).Where(gearRoles.Contains).Distinct(StringComparer.Ordinal).OrderBy(role => role, StringComparer.Ordinal)
|
||||
.Concat(reached.Where(drawn.Contains).OrderBy(k => k, StringComparer.Ordinal).Select(k => catalog.Get(recipes[k].Output).Name)).ToList();
|
||||
var asUpkeep = reached.Any(upkeep.Contains);
|
||||
if (asUpkeep) uses.Add("upkeep");
|
||||
if (uses.Count < 2 && !asUpkeep) continue;
|
||||
var list = uses.Count == 1 ? uses[0] : string.Join(", ", uses.Take(uses.Count - 1)) + " and " + uses[uses.Count - 1];
|
||||
faults.Add(new EconomyFault(LDemand, RecipeData.KeyOf(new CultRecordKey(key)).Value,
|
||||
$"'{catalog.Get(recipe.Output).Name}' leads into {list} but no use leads into a consumer good."));
|
||||
}
|
||||
return faults;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/EconomyChecks.cs.meta
Normal file
11
Assets/Scripts/ServerShared/EconomyChecks.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5e0d62a8f1c34b79b2f58ea3c6d19042
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -85,7 +85,11 @@ public abstract class Entity
|
|||
|
||||
// Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the
|
||||
// hull turns only by Ship.Turn.
|
||||
public float3 Aim;
|
||||
public float3 Aim { get; internal set; }
|
||||
|
||||
// The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by
|
||||
// PilotApplier in the zone step. Runtime only: never packed.
|
||||
public PilotCommand Command { get; } = new PilotCommand();
|
||||
|
||||
public string Name;
|
||||
|
||||
|
|
@ -169,7 +173,7 @@ public abstract class Entity
|
|||
public bool HeatsinksEnabled
|
||||
{
|
||||
get => _heatsinksEnabled;
|
||||
set
|
||||
internal set
|
||||
{
|
||||
if (value == _heatsinksEnabled) return;
|
||||
_heatsinksEnabled = value;
|
||||
|
|
@ -182,10 +186,14 @@ public abstract class Entity
|
|||
|
||||
public EntitySettings Settings { get; }
|
||||
|
||||
public bool OverrideShutdown { get; set; }
|
||||
public bool OverrideShutdown { get; internal set; }
|
||||
|
||||
// The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it.
|
||||
public float TractorCommand;
|
||||
public float TractorCommand { get; internal set; }
|
||||
|
||||
// The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both.
|
||||
public GiveOrder Giving { get; internal set; }
|
||||
public GiveEnd? LastGiveEnd { get; internal set; }
|
||||
|
||||
// The beam's spun-up state: Pickup's, or 0 without one.
|
||||
public float TractorPower => GetBehavior<Pickup>()?.Power ?? 0f;
|
||||
|
|
@ -419,7 +427,7 @@ public abstract class Entity
|
|||
// (clearing the aim) or a revealed item (FireControl.IsRevealed: this entity saw it act) belonging to the current
|
||||
// Target. A fitted item the observer has only located is aimed as its mount, never as an item. Returns whether the
|
||||
// write took effect; a rejected non-null selection leaves the aim unchanged.
|
||||
public bool TrySelectTargetItem(EquippedItem item)
|
||||
internal bool TrySelectTargetItem(EquippedItem item)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
|
|
@ -438,7 +446,7 @@ public abstract class Entity
|
|||
// The aim slot's other writer: a mount of the hull this entity believes the Target to be (LayoutOf, by reference),
|
||||
// unless this entity perceived that mount's item destroyed (SeenDestroyed). Reads only observer state, so whether a
|
||||
// mount can be aimed at says nothing of the Target's truth (ruling cycle-skips-only-perceived-destroyed).
|
||||
public bool TrySelectMount(HardpointData mount)
|
||||
internal bool TrySelectMount(HardpointData mount)
|
||||
{
|
||||
if (!MountAimable(Target.Value.Entity, mount)) return false;
|
||||
AimedAt = AimTarget.OfMount(mount);
|
||||
|
|
@ -475,7 +483,7 @@ public abstract class Entity
|
|||
|
||||
// The player's cycle key: steps the aim to the next AimCycle entry after the resolved one and wraps; an entry its
|
||||
// writer refuses (a revealed item since destroyed) is passed over; nothing to visit clears the aim.
|
||||
public void CycleAim()
|
||||
internal void CycleAim()
|
||||
{
|
||||
var subject = Target.Value.Entity;
|
||||
if (subject == null) return;
|
||||
|
|
@ -493,7 +501,7 @@ public abstract class Entity
|
|||
// Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the
|
||||
// agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an
|
||||
// entity is written as given, as every handler wrote it before. Returns whether the write took effect.
|
||||
public bool SetTarget(TargetRef target)
|
||||
internal bool SetTarget(TargetRef target)
|
||||
{
|
||||
if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false;
|
||||
_target.Value = target;
|
||||
|
|
@ -563,7 +571,7 @@ public abstract class Entity
|
|||
// one writer. Candidates are visible ships at any range and visible chunks within mining reach.
|
||||
|
||||
// The candidate nearest the look direction by planar angle; picking the held target again clears it.
|
||||
public bool TargetUnderReticle()
|
||||
internal bool TargetUnderReticle()
|
||||
{
|
||||
var look = float2(Aim.x, Aim.z);
|
||||
if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false;
|
||||
|
|
@ -571,7 +579,7 @@ public abstract class Entity
|
|||
}
|
||||
|
||||
// Enemies only, as it drives weapon lock; the nearest by distance.
|
||||
public bool TargetNearestEnemy()
|
||||
internal bool TargetNearestEnemy()
|
||||
{
|
||||
Entity nearest = null;
|
||||
var nearestDistance = float.PositiveInfinity;
|
||||
|
|
@ -590,10 +598,10 @@ public abstract class Entity
|
|||
|
||||
// The next candidate after the current target by (planar distance, TargetRef), wrapping to the nearest; with
|
||||
// nothing targeted, the nearest.
|
||||
public bool TargetNext() => CycleTarget(false);
|
||||
internal bool TargetNext() => CycleTarget(false);
|
||||
|
||||
// The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest.
|
||||
public bool TargetPrevious() => CycleTarget(true);
|
||||
internal bool TargetPrevious() => CycleTarget(true);
|
||||
|
||||
private bool CycleTarget(bool reverse)
|
||||
{
|
||||
|
|
@ -1242,7 +1250,7 @@ public abstract class Entity
|
|||
|
||||
// Sets or clears a runtime-only IFF override deciding this entity's hostility toward another.
|
||||
// Pass null to clear the override and restore the derived (faction-based) rule.
|
||||
public void SetIff(Entity other, bool? hostile)
|
||||
internal void SetIff(Entity other, bool? hostile)
|
||||
{
|
||||
if (hostile.HasValue) _iffOverrides[other] = hostile.Value;
|
||||
else _iffOverrides.Remove(other);
|
||||
|
|
@ -1307,7 +1315,7 @@ public abstract class Entity
|
|||
public static bool IsDockingPermitted(FactionRelationship relationship, SecurityLevel securityLevel) =>
|
||||
(int) relationship - (int) securityLevel > 1;
|
||||
|
||||
public void ActivateConsumable(ConsumableItem item)
|
||||
internal void ActivateConsumable(ConsumableItem item)
|
||||
{
|
||||
var effect = new ConsumableItemEffect(item, this);
|
||||
// The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier)
|
||||
|
|
@ -1346,7 +1354,7 @@ public abstract class Entity
|
|||
public bool HasActiveConsumable(Predicate<BehaviorData> kind) =>
|
||||
_activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b)));
|
||||
|
||||
public bool TryActivateConsumable(ConsumableItemData data)
|
||||
internal bool TryActivateConsumable(ConsumableItemData data)
|
||||
{
|
||||
// Death and docking Remove and Deactivate the ship while its agent keeps ticking.
|
||||
if (!_active) return false;
|
||||
|
|
@ -1456,42 +1464,6 @@ public abstract class Entity
|
|||
}
|
||||
}
|
||||
|
||||
// Attempts to move a given number of items of the given type to the target Entity
|
||||
// Returns the number of items successfully transferred
|
||||
public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity)
|
||||
{
|
||||
int quantityTransferred = 0;
|
||||
while (quantityTransferred < quantity)
|
||||
{
|
||||
EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID));
|
||||
|
||||
if (originInventory == null) break;
|
||||
|
||||
var itemInstance = originInventory.ItemsOfType[itemDataID][0];
|
||||
|
||||
if (itemInstance is SimpleCommodity simpleCommodity)
|
||||
{
|
||||
var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred);
|
||||
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity)))
|
||||
{
|
||||
quantityTransferred += targetQuantity - simpleCommodity.Quantity;
|
||||
break;
|
||||
}
|
||||
|
||||
quantityTransferred += targetQuantity;
|
||||
}
|
||||
else if (itemInstance is CraftedItemInstance craftedItemInstance)
|
||||
{
|
||||
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance)))
|
||||
break;
|
||||
|
||||
quantityTransferred++;
|
||||
}
|
||||
}
|
||||
|
||||
return quantityTransferred;
|
||||
}
|
||||
|
||||
public EquippableItem TryUnequip(EquippedItem item)
|
||||
{
|
||||
// Don't allow unequipping when the entity is active
|
||||
|
|
@ -1748,6 +1720,7 @@ public abstract class Entity
|
|||
if (bay != null)
|
||||
{
|
||||
bay.DockedShip = ship;
|
||||
CargoTransfer.End(ship, GiveEnd.GiverDocked);
|
||||
ship.SetParent(this);
|
||||
Zone.Remove(ship);
|
||||
ship.Deactivate();
|
||||
|
|
@ -1809,13 +1782,18 @@ public abstract class Entity
|
|||
Parent = null;
|
||||
}
|
||||
|
||||
// Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires
|
||||
// asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each).
|
||||
public T GetBehavior<T>() where T : Behavior
|
||||
{
|
||||
foreach (var equippedItem in Equipment)
|
||||
if(equippedItem.Behaviors != null)
|
||||
foreach (var behavior in equippedItem.Behaviors)
|
||||
if (behavior is T b)
|
||||
return b;
|
||||
for (var i = 0; i < Equipment.Count; i++)
|
||||
{
|
||||
var behaviors = Equipment[i].Behaviors;
|
||||
if (behaviors == null) continue;
|
||||
foreach (var behavior in behaviors)
|
||||
if (behavior is T b)
|
||||
return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1843,6 +1821,13 @@ public abstract class Entity
|
|||
public virtual void Update(float delta)
|
||||
{
|
||||
TurnRate = 0;
|
||||
// A parented entity (a docked ship) sits on its parent, active or not: docking deactivates the ship, so this pin
|
||||
// comes before the inactive return, or the ship keeps whatever pose its own physics last gave it.
|
||||
if (Parent != null)
|
||||
{
|
||||
Position = Parent.Position;
|
||||
Velocity = Parent.Velocity;
|
||||
}
|
||||
if (!_active) return;
|
||||
|
||||
var velocityBefore = Velocity;
|
||||
|
|
@ -1959,12 +1944,7 @@ public abstract class Entity
|
|||
foreach(var child in Children)
|
||||
child.Update(delta);
|
||||
|
||||
if (Parent != null)
|
||||
{
|
||||
Position = Parent.Position;
|
||||
Velocity = Parent.Velocity;
|
||||
}
|
||||
else Position.y = RideHeight(Position.xz);
|
||||
if (Parent == null) Position.y = RideHeight(Position.xz);
|
||||
|
||||
Acceleration = delta > 0f ? (Velocity + LimitClamp - velocityBefore) / delta : float2(0, 0);
|
||||
Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow);
|
||||
|
|
@ -2166,7 +2146,7 @@ public class EquippedItem : IStatContext
|
|||
public ReactiveProperty<bool> ThermalOnline { get; } = new ReactiveProperty<bool>(false);
|
||||
public ReactiveProperty<bool> DurabilityOnline { get; } = new ReactiveProperty<bool>(false);
|
||||
public ReadOnlyReactiveProperty<bool> Online { get; }
|
||||
public ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
|
||||
internal ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
|
||||
public ReadOnlyReactiveProperty<bool> Active { get; }
|
||||
public ItemManager ItemManager { get; }
|
||||
|
||||
|
|
@ -2557,9 +2537,12 @@ public class EquippedCargoBay : EquippedItem
|
|||
// Try to store the given commodity at the given position
|
||||
// If there's a stack at the given position it will be added to
|
||||
// Returns true only when ALL of the items are successfully stored
|
||||
// The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with
|
||||
// the bay as it was, so no caller, loot, pickup or restore has to check for it.
|
||||
public bool TryStore(SimpleCommodity item, int2 cargoCoord)
|
||||
{
|
||||
var itemData = ItemManager.GetData(item);
|
||||
if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false;
|
||||
if (ItemFits(item, cargoCoord))
|
||||
{
|
||||
foreach (var p in itemData.Shape.Coordinates)
|
||||
|
|
@ -2623,8 +2606,10 @@ public class EquippedCargoBay : EquippedItem
|
|||
return true;
|
||||
}
|
||||
|
||||
// The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit.
|
||||
public SimpleCommodity Remove(SimpleCommodity item, int quantity)
|
||||
{
|
||||
if (quantity < 1) return null;
|
||||
if (!Cargo.ContainsKey(item))
|
||||
{
|
||||
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
|
||||
|
|
@ -2685,6 +2670,7 @@ public class EquippedCargoBay : EquippedItem
|
|||
|
||||
public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity)
|
||||
{
|
||||
if (quantity < 1) return false;
|
||||
if (!Cargo.ContainsKey(item))
|
||||
{
|
||||
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
|
||||
|
|
@ -2693,10 +2679,11 @@ public class EquippedCargoBay : EquippedItem
|
|||
|
||||
var oldPos = Cargo[item];
|
||||
var newItem = Remove(item, quantity);
|
||||
|
||||
if (target.TryStore(item)) return true;
|
||||
|
||||
// Failed to transfer full quantity, move the remaining items back to their old slot
|
||||
|
||||
// The part removed goes across, never the stack left behind.
|
||||
if (target.TryStore(newItem)) return true;
|
||||
|
||||
// Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from.
|
||||
TryStore(newItem, oldPos);
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ public static class EntitySerializer
|
|||
{
|
||||
Orbit = new CultRecordRef<OrbitData>(orbital.OrbitData),
|
||||
SecurityLevel = orbital.SecurityLevel,
|
||||
SecurityRadius = orbital.SecurityRadius
|
||||
SecurityRadius = orbital.SecurityRadius,
|
||||
Kind = orbital.Kind
|
||||
};
|
||||
else if (entity is Ship ship)
|
||||
pack = new ShipPack
|
||||
|
|
@ -47,6 +48,51 @@ public static class EntitySerializer
|
|||
return pack;
|
||||
}
|
||||
|
||||
// A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never
|
||||
// silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack
|
||||
// (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its
|
||||
// first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack
|
||||
// has its own cell, so a spill cannot take a cell a later stack was saved in.
|
||||
private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item,
|
||||
List<(EquippedCargoBay home, SimpleCommodity rest)> spill)
|
||||
{
|
||||
if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack
|
||||
&& itemManager.GetData(commodity).MaxStack >= 1)
|
||||
{
|
||||
var maxStack = itemManager.GetData(commodity).MaxStack;
|
||||
var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack };
|
||||
var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack };
|
||||
if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity;
|
||||
spill.Add((bay, rest));
|
||||
return;
|
||||
}
|
||||
if (bay.TryStore(item, position)) return;
|
||||
var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : "";
|
||||
itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}");
|
||||
}
|
||||
|
||||
// Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then
|
||||
// the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity.
|
||||
private static void SpillOversize(ItemManager itemManager, IReadOnlyList<EquippedCargoBay> bays,
|
||||
List<(EquippedCargoBay home, SimpleCommodity rest)> spill)
|
||||
{
|
||||
foreach (var (home, rest) in spill)
|
||||
{
|
||||
var maxStack = itemManager.GetData(rest).MaxStack;
|
||||
var remaining = rest.Quantity;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) };
|
||||
var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk));
|
||||
// A refused store may still have topped up a stack; what is left in the chunk found no room.
|
||||
remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity);
|
||||
if (placed) continue;
|
||||
itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack)
|
||||
{
|
||||
return pack switch
|
||||
|
|
@ -75,6 +121,7 @@ public static class EntitySerializer
|
|||
Restore(itemManager, zone, pack, entity);
|
||||
entity.SecurityLevel = pack.SecurityLevel;
|
||||
entity.SecurityRadius = pack.SecurityRadius;
|
||||
entity.Kind = pack.Kind;
|
||||
if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story];
|
||||
return entity;
|
||||
}
|
||||
|
|
@ -100,15 +147,18 @@ public static class EntitySerializer
|
|||
entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship;
|
||||
}
|
||||
|
||||
var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>();
|
||||
for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++)
|
||||
if(entity.CargoBays.Count >= bayIndex + 1)
|
||||
foreach (var (position, item) in pack.CargoContents[bayIndex])
|
||||
entity.CargoBays[bayIndex].TryStore(item, position);
|
||||
RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill);
|
||||
|
||||
for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++)
|
||||
if(entity.DockingBays.Count >= bayIndex + 1)
|
||||
foreach (var (position, item) in pack.DockingBayContents[bayIndex])
|
||||
entity.DockingBays[bayIndex].TryStore(item, position);
|
||||
RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill);
|
||||
|
||||
SpillOversize(itemManager, entity.CargoBays.Cast<EquippedCargoBay>().Concat(entity.DockingBays).ToList(), spill);
|
||||
|
||||
entity.Temperature = pack.Temperature;
|
||||
|
||||
|
|
@ -166,6 +216,7 @@ public class OrbitalEntityPack : EntityPack
|
|||
[Key(18)] public int Story = -1;
|
||||
[Key(19)] public SecurityLevel SecurityLevel;
|
||||
[Key(20)] public float SecurityRadius;
|
||||
[Key(22)] public CultRecordRef<StationKindData> Kind;
|
||||
}
|
||||
|
||||
[MessagePackObject,
|
||||
|
|
@ -189,7 +240,7 @@ public abstract class EntityPack
|
|||
[Key(14)] public EntitySettings Settings;
|
||||
[Key(15)] public CultRecordRef<Faction> Faction;
|
||||
[Key(16)] public int[][] WeaponGroups;
|
||||
// Keys 17-20 belong to the subclasses; the base's next free key is 21.
|
||||
// Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22).
|
||||
[Key(21)] public bool Boss;
|
||||
|
||||
private int _price;
|
||||
|
|
|
|||
|
|
@ -152,27 +152,6 @@ public enum MegaPlacementType
|
|||
Isolated = 4
|
||||
}
|
||||
|
||||
public enum SimpleCommodityCategory
|
||||
{
|
||||
Minerals = 0,
|
||||
Metals = 1,
|
||||
Alloys = 2,
|
||||
Compounds = 3,
|
||||
Organics = 4,
|
||||
Ammo = 5,
|
||||
Consumer = 6
|
||||
}
|
||||
|
||||
public enum CompoundCommodityCategory
|
||||
{
|
||||
Wearables = 0,
|
||||
Consumables = 1,
|
||||
Luxuries = 2,
|
||||
Tools = 3,
|
||||
Manufacturing = 4,
|
||||
Assemblies = 5
|
||||
}
|
||||
|
||||
public enum LocationType
|
||||
{
|
||||
Station,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ public class NoiseSettings
|
|||
|
||||
[Union(0, typeof(PowerBrush)),
|
||||
Union(1, typeof(SimplexBrush)),
|
||||
Union(2, typeof(AnimatedSimplexBrush)),
|
||||
Union(3, typeof(RadialWaveBrush)),
|
||||
Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public abstract class Brush
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ public class FactionProductData
|
|||
// no Assembly; the mint derives both from the line.
|
||||
[Inspectable, JsonProperty("bin"), Key(7)]
|
||||
public ProductBin Bin;
|
||||
|
||||
// Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no
|
||||
// station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a
|
||||
// Loadout that pins it. LoadoutGenerator.IsOffered is its one reader.
|
||||
[Inspectable, JsonProperty("notOffered"), Key(8)]
|
||||
public bool NotOffered;
|
||||
}
|
||||
|
||||
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is
|
||||
|
|
|
|||
|
|
@ -84,8 +84,11 @@ public static class FireControl
|
|||
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
|
||||
// a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The
|
||||
// answer is Solve's own -- there is no second copy of the arming test.
|
||||
public static bool Refuses(Weapon weapon, Entity shooter) =>
|
||||
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused;
|
||||
public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity);
|
||||
|
||||
// The same answer against a target the shooter's pilot has commanded but not yet applied.
|
||||
public static bool Refuses(Weapon weapon, Entity shooter, Entity target) =>
|
||||
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused;
|
||||
|
||||
// A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay.
|
||||
public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData;
|
||||
|
|
@ -96,12 +99,19 @@ public static class FireControl
|
|||
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and
|
||||
// never one Solve refuses: a refused weapon is not a weapon it can use.
|
||||
// The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global.
|
||||
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability)
|
||||
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) =>
|
||||
AgentFires(weapon, shooter, target, minHitProbability, null);
|
||||
|
||||
// The same decision priced on the aim the pilot commands this step (Combat), not the shooter's applied aim slot.
|
||||
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget aim) =>
|
||||
AgentFires(weapon, shooter, target, minHitProbability, (AimTarget?) aim);
|
||||
|
||||
private static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget? aim)
|
||||
{
|
||||
// A mine layer lays only at a designated target in range, never at nothing and never on a hit probability.
|
||||
if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated;
|
||||
if (FuseOf(weapon.Item, out _) == null)
|
||||
return HitProbability(weapon, shooter, target) >= minHitProbability;
|
||||
return HitProbability(weapon, shooter, target, aim) >= minHitProbability;
|
||||
var solution = Solve(weapon, weapon.Item, shooter, target);
|
||||
return solution.Designated && solution.Outcome != FireOutcome.Refused;
|
||||
}
|
||||
|
|
@ -330,7 +340,7 @@ public static class FireControl
|
|||
// common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0).
|
||||
// Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of
|
||||
// fire, along the same travel direction, so the live price and the HUD read one evasion.
|
||||
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range)
|
||||
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range, AimTarget aim)
|
||||
{
|
||||
var travel = TravelDirection(weapon, source, target);
|
||||
var evasion = Evasion(target, travel);
|
||||
|
|
@ -340,7 +350,6 @@ public static class FireControl
|
|||
Silhouette sil;
|
||||
try
|
||||
{
|
||||
var aim = source.ResolvedAim;
|
||||
sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount);
|
||||
var settings = source.ItemManager.GameplaySettings;
|
||||
pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize);
|
||||
|
|
@ -355,13 +364,21 @@ public static class FireControl
|
|||
return (sil, pSpread, evasion);
|
||||
}
|
||||
|
||||
public static float HitProbability(Weapon weapon, Entity source, Entity target)
|
||||
public static float HitProbability(Weapon weapon, Entity source, Entity target) =>
|
||||
HitProbability(weapon, source, target, (AimTarget?) null);
|
||||
|
||||
// Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step.
|
||||
public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) =>
|
||||
HitProbability(weapon, source, target, (AimTarget?) aim);
|
||||
|
||||
// A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more.
|
||||
private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim)
|
||||
{
|
||||
var pFire = PFire(weapon, source, target, out var range);
|
||||
if (pFire <= 0f) return 0f;
|
||||
|
||||
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
|
||||
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range);
|
||||
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim);
|
||||
return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +416,7 @@ public static class FireControl
|
|||
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
|
||||
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
|
||||
|
||||
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range);
|
||||
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range, source.ResolvedAim);
|
||||
diagnostic.PSpread = pSpread;
|
||||
diagnostic.POnHull = sil.POnHull;
|
||||
diagnostic.Evasion = evasion;
|
||||
|
|
@ -681,7 +698,11 @@ public static class FireControl
|
|||
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
|
||||
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
|
||||
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
|
||||
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null)
|
||||
// Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born
|
||||
// `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities).
|
||||
// FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round
|
||||
// from where it left and the commit measures the target's stray from the line it was fired along.
|
||||
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f)
|
||||
{
|
||||
var zone = source.Zone;
|
||||
if (IsMineLayer(weapon))
|
||||
|
|
@ -691,7 +712,7 @@ public static class FireControl
|
|||
}
|
||||
// Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks.
|
||||
var target = source.Target.Value.Entity;
|
||||
var now = zone.Time;
|
||||
var now = zone.Time - age;
|
||||
|
||||
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
|
||||
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
|
||||
|
|
@ -707,14 +728,14 @@ public static class FireControl
|
|||
var burstPosition = solution.BurstPosition;
|
||||
var fuse = solution.Fuse;
|
||||
var blastRadius = solution.BlastRadius;
|
||||
var origin = source.Position;
|
||||
var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age;
|
||||
|
||||
var targetVelocity = float3.zero;
|
||||
var targetPosition = origin;
|
||||
if (engaged != null)
|
||||
{
|
||||
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
|
||||
targetPosition = engaged.Position;
|
||||
targetPosition = engaged.Position - targetVelocity * age;
|
||||
}
|
||||
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
|
||||
|
||||
|
|
@ -762,7 +783,12 @@ public static class FireControl
|
|||
// point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once.
|
||||
if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach);
|
||||
|
||||
zone.PendingShots.Add(shot);
|
||||
// The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds
|
||||
// of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one.
|
||||
var queue = zone.PendingShots;
|
||||
var place = queue.Count;
|
||||
while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--;
|
||||
queue.Insert(place, shot);
|
||||
return shot.ShotId;
|
||||
}
|
||||
|
||||
|
|
@ -828,7 +854,10 @@ public static class FireControl
|
|||
if (shots.Count == 0) return;
|
||||
|
||||
var now = zone.Time;
|
||||
for (var i = shots.Count - 1; i >= 0; i--)
|
||||
// Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are
|
||||
// left to the next step.
|
||||
List<(float Arrival, int Id)> arrived = null;
|
||||
for (int i = 0, end = shots.Count; i < end;)
|
||||
{
|
||||
var shot = shots[i];
|
||||
|
||||
|
|
@ -842,6 +871,7 @@ public static class FireControl
|
|||
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
|
||||
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
|
||||
shots.RemoveAt(i);
|
||||
end--;
|
||||
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
|
||||
zone.ShotResolved.OnNext(miss);
|
||||
continue;
|
||||
|
|
@ -862,16 +892,41 @@ public static class FireControl
|
|||
|
||||
if (shot.Committed && now >= shot.ArrivalTime)
|
||||
{
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
|
||||
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
|
||||
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
|
||||
// the same way; only Apply decides what that means.
|
||||
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
|
||||
// that throws would resolve, and hurt, again on every later tick.
|
||||
shots.RemoveAt(i);
|
||||
Apply(zone, shot);
|
||||
zone.ShotResolved.OnNext(shot.Outcome);
|
||||
(arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (arrived == null) return;
|
||||
|
||||
// The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so
|
||||
// which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the
|
||||
// order their rounds left.
|
||||
arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id));
|
||||
foreach (var (_, id) in arrived)
|
||||
{
|
||||
var i = shots.FindIndex(q => q.ShotId == id);
|
||||
if (i < 0) continue;
|
||||
var shot = shots[i];
|
||||
|
||||
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
|
||||
if (targetGone)
|
||||
{
|
||||
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
|
||||
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
|
||||
shots.RemoveAt(i);
|
||||
zone.ShotResolved.OnNext(miss);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
|
||||
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
|
||||
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
|
||||
// the same way; only Apply decides what that means.
|
||||
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
|
||||
// that throws would resolve, and hurt, again on every later tick.
|
||||
shots.RemoveAt(i);
|
||||
Apply(zone, shot);
|
||||
zone.ShotResolved.OnNext(shot.Outcome);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ public class Galaxy
|
|||
// The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds
|
||||
// generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed).
|
||||
public uint Seed { get; }
|
||||
|
||||
// The run's resource provinces, ordered by seed zone index; GalaxyZone.Province is each zone's. Built once, by the
|
||||
// generation constructors or the save constructor (BuildProvinces), and never by PlaceGate, faction placement or naming.
|
||||
public GalaxyProvince[] Provinces { get; private set; }
|
||||
|
||||
private GalaxyZone[] _exitPath;
|
||||
private readonly CultCache _cache;
|
||||
|
|
@ -89,6 +93,9 @@ public class Galaxy
|
|||
if(savedGame.Exit != -1)
|
||||
Exit = Zones[savedGame.Exit];
|
||||
|
||||
// A save from before ore geography has no provinces: it gets them by the rule generation uses.
|
||||
BuildProvinces(global::Provinces.DefaultZonesPerProvince, savedGame.Provinces == null ? null : savedZones.Select(zone => zone.Province).ToArray(), savedGame.Provinces);
|
||||
|
||||
CalculateDistanceMatrix();
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +134,8 @@ public class Galaxy
|
|||
|
||||
PlaceFactionsMain(settings.BossCount, progressCallback);
|
||||
|
||||
BuildProvinces(settings.ProvinceZones);
|
||||
|
||||
CalculateFactionInfluence(progressCallback);
|
||||
|
||||
NamePlaces(ref random, progressCallback);
|
||||
|
|
@ -135,6 +144,39 @@ public class Galaxy
|
|||
if(progressCallback!=null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
||||
}
|
||||
|
||||
// Each zone's index and the links as indices: the graph Provinces reads.
|
||||
private (Dictionary<GalaxyZone, int> Index, int[][] Adjacency) ZoneGraph()
|
||||
{
|
||||
var index = Zones.Select((zone, i) => (zone, i)).ToDictionary(pair => pair.zone, pair => pair.i);
|
||||
return (index, Zones.Select(zone => zone.AdjacentZones.Select(next => index[next]).ToArray()).ToArray());
|
||||
}
|
||||
|
||||
// Regions, then each region's provinces and their types (Provinces.Regions, Partition, Deal); the one builder, called by
|
||||
// the generation constructors and the save constructor. A save passes its membership and types: membership is kept, and a
|
||||
// saved type that still resolves is kept, any other taking the card the region's Deal gives that province's place.
|
||||
private void BuildProvinces(int zonesPerProvince, int[] savedSeedOf = null, List<SavedProvince> savedTypes = null)
|
||||
{
|
||||
var (index, adjacency) = ZoneGraph();
|
||||
var regionOf = global::Provinces.Regions(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), Exit == null ? -1 : index[Exit]);
|
||||
var seedOf = savedSeedOf ?? global::Provinces.Partition(adjacency, regionOf, Seed, zonesPerProvince);
|
||||
var types = savedTypes?.ToDictionary(saved => saved.Seed, saved => saved.Type);
|
||||
|
||||
var provinces = new List<GalaxyProvince>();
|
||||
foreach (var region in Enumerable.Range(0, Zones.Length).GroupBy(zone => regionOf[zone]).OrderBy(group => group.Key))
|
||||
{
|
||||
var seeds = region.Select(zone => seedOf[zone]).Distinct().OrderBy(seed => seed).ToArray();
|
||||
var hand = global::Provinces.Deal(region.Key, seeds.Length, Seed, _cache);
|
||||
for (var place = 0; place < seeds.Length; place++)
|
||||
{
|
||||
var type = types != null && types.TryGetValue(seeds[place], out var saved) && _cache.Get(saved) != null ? saved : hand[place];
|
||||
var province = new GalaxyProvince {Seed = Zones[seeds[place]], Type = type, Zones = region.Where(zone => seedOf[zone] == seeds[place]).Select(zone => Zones[zone]).ToArray()};
|
||||
foreach (var zone in province.Zones) zone.Province = province;
|
||||
provinces.Add(province);
|
||||
}
|
||||
}
|
||||
Provinces = provinces.OrderBy(province => index[province.Seed]).ToArray();
|
||||
}
|
||||
|
||||
public Faction ResolveFaction(string name)
|
||||
{
|
||||
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
|
@ -256,6 +298,9 @@ public class Galaxy
|
|||
|
||||
CalculateFactionInfluence(progressCallback);
|
||||
|
||||
// No boss zone and no Exit yet (PlaceGate comes after): the prelude is one region.
|
||||
BuildProvinces(settings.ProvinceZones);
|
||||
|
||||
NamePlaces(ref random, progressCallback);
|
||||
|
||||
// progressCallback?.Invoke("Weaving Narrative");
|
||||
|
|
@ -303,6 +348,10 @@ public class Galaxy
|
|||
BossZones.Values.Aggregate(1, (i, os) => i * os.Distance[z]));
|
||||
}
|
||||
|
||||
// One exit per region (ruling one-exit-per-region): prune the boss zones' extra links, then home placement below reads
|
||||
// the distances of the graph that is left.
|
||||
OneExitPerRegion(progressCallback);
|
||||
|
||||
// Place boss mega headquarters such that their sphere of influence encompasses their boss zone
|
||||
// While occupying as much territory as possible
|
||||
foreach (var mega in bossMegas)
|
||||
|
|
@ -324,6 +373,21 @@ public class Galaxy
|
|||
}
|
||||
}
|
||||
|
||||
// Each boss zone keeps one link toward the exit, the one on the exit path, so there is one exit to defend where its boss
|
||||
// parks (Provinces.ExtraExits picks the rest; ruling one-exit-per-region). Links are removed from both zones, and the
|
||||
// distance matrix follows. A galaxy built from a save never comes here: its saved links are the world.
|
||||
private void OneExitPerRegion(Action<string> progressCallback)
|
||||
{
|
||||
var (index, adjacency) = ZoneGraph();
|
||||
var exitPath = ExitPath.Select(zone => index[zone]).ToArray();
|
||||
foreach (var (boss, far) in global::Provinces.ExtraExits(adjacency, BossZones.Values.Select(zone => index[zone]).ToArray(), index[Exit], exitPath))
|
||||
{
|
||||
Zones[boss].AdjacentZones.Remove(Zones[far]);
|
||||
Zones[far].AdjacentZones.Remove(Zones[boss]);
|
||||
}
|
||||
CalculateDistanceMatrix(progressCallback);
|
||||
}
|
||||
|
||||
private void CalculateFactionInfluence(Action<string> progressCallback = null)
|
||||
{
|
||||
progressCallback?.Invoke("Calculating Faction Influence");
|
||||
|
|
@ -571,10 +635,20 @@ public class GalaxyZone
|
|||
public Faction Owner;
|
||||
public Zone Contents;
|
||||
public ZonePack PackedContents;
|
||||
public GalaxyProvince Province;
|
||||
public bool NamedZone;
|
||||
public List<LocationStory> Locations = new List<LocationStory>();
|
||||
}
|
||||
|
||||
// A resource province (ruling provinces-within-regions): a contiguous group of zones inside one region, grown from Seed,
|
||||
// whose Type (ProvinceData, unset when the catalog holds none) weights the kinds and ores of its belts.
|
||||
public class GalaxyProvince
|
||||
{
|
||||
public GalaxyZone Seed;
|
||||
public CultRecordRef<ProvinceData> Type;
|
||||
public GalaxyZone[] Zones;
|
||||
}
|
||||
|
||||
public class GalaxyQuest
|
||||
{
|
||||
public Story Story;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,21 @@ public class HelmInput
|
|||
|
||||
public void Toggle() => Latched = !Latched;
|
||||
|
||||
public float Demand(Ship ship, float2 aim, float turnAxis) =>
|
||||
FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f);
|
||||
private float _turnAxis;
|
||||
|
||||
// The turn axis as the helm reads it. A digital input (keys) ramps up while held and back down while released, both at
|
||||
// full scale per rampTime, so tapping at a rate holds a gentle turn; reversing cuts to zero and ramps the other way.
|
||||
// An analog input passes straight through.
|
||||
public float TurnAxis(float raw, bool digital, float dt, float rampTime)
|
||||
{
|
||||
if (!digital || rampTime <= 0) return _turnAxis = raw;
|
||||
var step = dt / rampTime;
|
||||
if (raw == 0) return _turnAxis = sign(_turnAxis) * max(0f, abs(_turnAxis) - step);
|
||||
if (sign(raw) != sign(_turnAxis)) _turnAxis = 0;
|
||||
return _turnAxis = sign(raw) * min(abs(raw), abs(_turnAxis) + step);
|
||||
}
|
||||
|
||||
// The turn the player commands: the aim heading, resolved against the hull at each sim step, or the turn axis.
|
||||
public TurnCommand Demand(float2 aim, float turnAxis) =>
|
||||
FaceAim ? TurnCommand.Toward(aim) : TurnCommand.OfRate(clamp(turnAxis, -1f, 1f));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,13 +322,11 @@ public abstract class ItemData
|
|||
[CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject]
|
||||
public class SimpleCommodityData : ItemData
|
||||
{
|
||||
// MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields; do not reuse them.
|
||||
// MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields, and key 10 to the removed stored Category; do not reuse them.
|
||||
|
||||
[Inspectable, JsonProperty("maxStackSize"), Key(9)]
|
||||
public int MaxStack = 10;
|
||||
|
||||
[Inspectable, JsonProperty("category"), Key(10)]
|
||||
public SimpleCommodityCategory Category;
|
||||
}
|
||||
|
||||
[JsonObject(MemberSerialization.OptIn)]
|
||||
|
|
@ -339,24 +337,23 @@ public abstract class CraftedItemData : ItemData
|
|||
public List<ItemRole> Roles = new List<ItemRole>();
|
||||
}
|
||||
|
||||
// One slot in a design: every laser has a focusing array. A role is a name and nothing else. What fills it is
|
||||
// a quality, authored per role by each manufacturer's product; the part itself earns a record when crafting
|
||||
// needs one to exist.
|
||||
// One slot in a design: every laser has a focusing array. A role is a named slot with a count: the design takes Count units
|
||||
// of a part, and any commodity whose recipe Fills the role can be that part. What fills it is a quality, authored per role
|
||||
// by each manufacturer's product. Records written before Count existed read it as 1.
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class ItemRole
|
||||
{
|
||||
[Inspectable, JsonProperty("name"), Key(0)]
|
||||
public string Name;
|
||||
|
||||
[Inspectable, JsonProperty("count"), Key(1)]
|
||||
public int Count = 1;
|
||||
}
|
||||
|
||||
[CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject]
|
||||
public class CompoundCommodityData : CraftedItemData
|
||||
{
|
||||
[Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("demandProfile"), Key(10)]
|
||||
public Dictionary<CultRecordRef<PersonalityAttribute>, float> DemandProfile = new Dictionary<CultRecordRef<PersonalityAttribute>, float>();
|
||||
|
||||
[Inspectable, JsonProperty("category"), Key(11)]
|
||||
public CompoundCommodityCategory Category;
|
||||
// MessagePack keys 10 and 11 belonged to the removed demand weights and classification; do not reuse them.
|
||||
}
|
||||
|
||||
[CultDocument("aetheria.consumableitemdata", "1"), Inspectable, MessagePackObject]
|
||||
|
|
@ -555,6 +552,12 @@ public class HullData : EquippableItemData
|
|||
[Inspectable, JsonProperty("visual"), Key(32)]
|
||||
public CultRecordRef<ShipAuthoring> Visual;
|
||||
|
||||
// What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind
|
||||
// and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record.
|
||||
// Key 33 is the next free key.
|
||||
[Inspectable, JsonProperty("kind"), Key(33)]
|
||||
public CultRecordRef<StationKindData> Kind;
|
||||
|
||||
// The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world
|
||||
// units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not,
|
||||
// as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ using float4 = CultMath.float4;
|
|||
|
||||
public class ItemManager
|
||||
{
|
||||
public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue));
|
||||
// The item stream: each lot's quality and each generated fit. Seeded by whoever constructs the manager and reseeded
|
||||
// by RunStart at each step of a new run, never by the clock.
|
||||
public Random Random;
|
||||
|
||||
private Action<string> _logger;
|
||||
|
||||
|
|
@ -25,8 +27,10 @@ public class ItemManager
|
|||
public ProvenanceLedger Lots { get; }
|
||||
public GameplaySettings GameplaySettings { get; }
|
||||
|
||||
public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
|
||||
public ItemManager(uint seed, CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
|
||||
{
|
||||
if (seed == 0) throw new ArgumentOutOfRangeException(nameof(seed), "an item stream needs a nonzero seed");
|
||||
Random = new Random(seed);
|
||||
ItemData = itemData;
|
||||
Lots = lots;
|
||||
GameplaySettings = settings;
|
||||
|
|
|
|||
|
|
@ -96,20 +96,28 @@ public static class Loadouts
|
|||
_ => ""
|
||||
};
|
||||
|
||||
// The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it
|
||||
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
|
||||
// file; a cache that already has the catalog open sees the loadout only once it reloads.
|
||||
// Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name
|
||||
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
|
||||
// on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then.
|
||||
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
|
||||
// The only writer of Loadout records, and of the Faction.BossLoadout that names one. It opens its own short-lived cache
|
||||
// over the catalog file, writable, and disposes it after the one commit. No other cache is touched, so no catalog
|
||||
// instance play holds (or has mutated) can reach the file; a cache that already has the catalog open sees the loadout
|
||||
// only once it reloads. The catalog is a stack of packages, the shipped content included (ruling
|
||||
// catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails
|
||||
// closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss
|
||||
// (Faction.BossLoadout), so the loadout and the reference land together or not at all.
|
||||
// Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in
|
||||
// it, when a loadout of this name exists under another key, or when one exists under KeyOf(Name) and replace is not set.
|
||||
// The commit is conditional on the record at KeyOf(Name) and on the faction, so a record changed on disk since this open
|
||||
// is not clobbered: returns false then, and neither is written.
|
||||
public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf = default)
|
||||
{
|
||||
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
|
||||
if (modDesigns.Length > 0)
|
||||
throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout.");
|
||||
if (!File.Exists(catalogPath))
|
||||
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
|
||||
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
|
||||
return Commit(cache, loadout, replace, bossOf);
|
||||
}
|
||||
|
||||
// The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened.
|
||||
internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf)
|
||||
{
|
||||
var key = KeyOf(loadout.Name);
|
||||
var namesake = cache.GetAll<Loadout>().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key));
|
||||
if (namesake != null)
|
||||
|
|
@ -117,16 +125,26 @@ public static class Loadouts
|
|||
var existing = cache.Get<Loadout>(key);
|
||||
if (existing != null && !replace)
|
||||
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
|
||||
var boss = bossOf.IsSet()
|
||||
? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.")
|
||||
: null;
|
||||
return cache.Commit(batch =>
|
||||
{
|
||||
batch.Expect(key, existing);
|
||||
batch.Upsert(typeof(Loadout), loadout, key);
|
||||
if (boss == null) return;
|
||||
batch.Expect(bossOf.Key, boss);
|
||||
boss.BossLoadout = new CultRecordRef<Loadout>(key);
|
||||
batch.Upsert(boss.GetType(), boss, bossOf.Key);
|
||||
});
|
||||
}
|
||||
|
||||
// The quality a design no manufacturer makes is built at: the console give's.
|
||||
public const float UnbrandedQuality = .95f;
|
||||
|
||||
// The seed of the scratch item manager a fit is tried on. It draws nothing, so any nonzero constant does.
|
||||
private const uint ScratchSeed = 1u;
|
||||
|
||||
// All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most
|
||||
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any
|
||||
// failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal
|
||||
|
|
@ -166,7 +184,7 @@ public static class Loadouts
|
|||
|
||||
// The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own
|
||||
// item manager, so nothing is minted into the live ledger and nothing is drawn.
|
||||
var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
|
||||
var scratch = new ItemManager(ScratchSeed, cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
|
||||
Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _);
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,12 @@ public class LoadoutGenerator
|
|||
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
|
||||
}
|
||||
|
||||
public OrbitalEntityPack GenerateStationLoadout()
|
||||
// A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands
|
||||
// (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind.
|
||||
public OrbitalEntityPack GenerateStationLoadout(CultRecordRef<StationKindData> kind)
|
||||
{
|
||||
var (hullProduct, hullData) = RandomHull(HullType.Station);
|
||||
var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind);
|
||||
var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key));
|
||||
if(hullData==null)
|
||||
{
|
||||
ItemManager.Log("Unable to generate station loadout: no compatible hull found!");
|
||||
|
|
@ -85,6 +88,9 @@ public class LoadoutGenerator
|
|||
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
|
||||
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
|
||||
entity.Faction = Faction;
|
||||
entity.Kind = kind;
|
||||
// A stand-in hull keeps its own model but not its own name: the station is what its kind says it is.
|
||||
if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name;
|
||||
|
||||
// Hardpoint gear goes in first so the docking bay can only take space that gear left free
|
||||
EquipHardpoints(entity);
|
||||
|
|
@ -194,9 +200,8 @@ public class LoadoutGenerator
|
|||
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
|
||||
.Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T))
|
||||
.Where(entry =>
|
||||
entry.design != null &&
|
||||
entry.design.Price > 0 &&
|
||||
entry.product.Manufacturer.IsSet() &&
|
||||
// A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either.
|
||||
IsDrawable(ItemManager.ItemData, entry.product, entry.design) &&
|
||||
(!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) &&
|
||||
(filter?.Invoke(entry.design) ?? true))
|
||||
.ToArray();
|
||||
|
|
@ -216,9 +221,10 @@ public class LoadoutGenerator
|
|||
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
|
||||
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
|
||||
// Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its
|
||||
// hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers.
|
||||
// hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it.
|
||||
// The one definition of the hulls HasHome considers.
|
||||
public static HullData[] SoldHulls(CultCache items) =>
|
||||
items.GetAll<FactionProductData>().Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
|
||||
items.GetAll<FactionProductData>().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
|
||||
|
||||
public static bool HasHome(EquippableItemData design, HullData[] hulls) =>
|
||||
design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull ||
|
||||
|
|
@ -231,9 +237,22 @@ public class LoadoutGenerator
|
|||
// always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names
|
||||
// (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy
|
||||
// does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available).
|
||||
// A not-offered product is available to nobody, a stage with no faction included.
|
||||
public bool IsAvailable(FactionProductData product) =>
|
||||
Galaxy == null || Faction == null ||
|
||||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
|
||||
IsOffered(ItemManager.ItemData, product) && (
|
||||
Galaxy == null || Faction == null ||
|
||||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer));
|
||||
|
||||
// Whether a product can be drawn for its design at all: the design resolves, has a price, the product names a maker and is
|
||||
// offered. The one definition of 'a maker offers this design'; AvailableProducts selects by it and StationKinds decides which
|
||||
// station kinds a hull can be drawn for by it, so a kind is kept exactly when a hull of it can be drawn.
|
||||
public static bool IsDrawable(CultCache items, FactionProductData product, CraftedItemData design) =>
|
||||
design != null && design.Price > 0 && product.Manufacturer.IsSet() && IsOffered(items, product);
|
||||
|
||||
// Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. A bin is
|
||||
// offered only if both it and its line are (ruling bin-inherits-line-offered); the catalog read guarantees a bin's line exists.
|
||||
public static bool IsOffered(CultCache items, FactionProductData product) =>
|
||||
!product.NotOffered && (product.Bin == null || !items.Get(product.Bin.Line).NotOffered);
|
||||
|
||||
// How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
|
||||
// generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes
|
||||
|
|
@ -248,9 +267,10 @@ public class LoadoutGenerator
|
|||
// 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean.
|
||||
private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f)));
|
||||
|
||||
// Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept.
|
||||
// Each line's key to the sum of its offered bins' pass fractions: read from the catalog once per weighting pass, never kept. An
|
||||
// off-market bin takes no share, so its line keeps the whole weight.
|
||||
private Dictionary<string, float> BinShares() => ItemManager.ItemData.GetAll<FactionProductData>()
|
||||
.Where(product => product.Bin != null)
|
||||
.Where(product => product.Bin != null && IsOffered(ItemManager.ItemData, product))
|
||||
.GroupBy(product => product.Bin.Line.Key.Value)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor)));
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ public class OrbitalEntity : Entity
|
|||
public float SecurityRadius;
|
||||
public LocationStory Story;
|
||||
public bool CanTow;
|
||||
// What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind.
|
||||
public CultRecordRef<StationKindData> Kind;
|
||||
|
||||
public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings)
|
||||
{
|
||||
|
|
|
|||
176
Assets/Scripts/ServerShared/Pilot.cs
Normal file
176
Assets/Scripts/ServerShared/Pilot.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/* 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.Collections.Generic;
|
||||
using CultMath;
|
||||
|
||||
// A turn demand as a pilot commands it: a rate, or a heading resolved against the hull's facing when it is applied, so a
|
||||
// held heading keeps steering as the hull turns.
|
||||
public readonly struct TurnCommand
|
||||
{
|
||||
private readonly bool _toward;
|
||||
private readonly float _rate;
|
||||
private readonly float2 _heading;
|
||||
|
||||
private TurnCommand(bool toward, float rate, float2 heading)
|
||||
{
|
||||
_toward = toward;
|
||||
_rate = rate;
|
||||
_heading = heading;
|
||||
}
|
||||
|
||||
public static TurnCommand OfRate(float rate) => new TurnCommand(false, rate, default);
|
||||
public static TurnCommand Toward(float2 heading) => new TurnCommand(true, 0, heading);
|
||||
|
||||
public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate;
|
||||
}
|
||||
|
||||
// The player's target presses, each answered by the entity's own chooser (Entity.TargetNext and the rest) when applied.
|
||||
public enum TargetVerb
|
||||
{
|
||||
None,
|
||||
UnderReticle,
|
||||
NearestEnemy,
|
||||
Next,
|
||||
Previous
|
||||
}
|
||||
|
||||
// The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the
|
||||
// pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one
|
||||
// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot
|
||||
// also reads back its own pending decisions this step (Designated, Pulls), because the entity still holds last step's.
|
||||
// Held fields persist until the pilot replaces them, so a pilot deciding below the sim rate keeps flying (ruling
|
||||
// lower-game-sim-rate); null leaves the field to whoever wrote it last. One-shots apply once, in order, and are cleared.
|
||||
public sealed class PilotCommand
|
||||
{
|
||||
public TurnCommand? Turn;
|
||||
public float2? Movement;
|
||||
public float3? Aim;
|
||||
public float? Tractor;
|
||||
|
||||
internal readonly List<TargetRef> Designations = new List<TargetRef>();
|
||||
internal readonly List<EquippedItem> Selections = new List<EquippedItem>();
|
||||
internal readonly List<string> Hails = new List<string>();
|
||||
internal readonly List<ConsumableItemData> Uses = new List<ConsumableItemData>();
|
||||
internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>();
|
||||
internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>();
|
||||
internal TargetVerb TargetVerb;
|
||||
// The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins.
|
||||
internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb;
|
||||
internal int AimCycles;
|
||||
internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings;
|
||||
|
||||
public void Designate(TargetRef target) => Designations.Add(target);
|
||||
public void SelectItem(EquippedItem item) => Selections.Add(item);
|
||||
public void Hail(string message) => Hails.Add(message);
|
||||
public void Use(ConsumableItemData consumable) => Uses.Add(consumable);
|
||||
public void Pull(ActivatedBehavior behavior) => Triggers.Add((behavior, true));
|
||||
public void Release(ActivatedBehavior behavior) => Triggers.Add((behavior, false));
|
||||
// The last verb in a step wins.
|
||||
public void Verb(TargetVerb verb) => TargetVerb = verb;
|
||||
public void CycleAim() => AimCycles++;
|
||||
public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false));
|
||||
// A press that flips the stance on the entity's target as both stand when applied, after the step's target verbs and in
|
||||
// order with Iff, so two in a step cancel. With no target then, it does nothing.
|
||||
public void ToggleStance() => Iffs.Add((null, null, true));
|
||||
// The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel.
|
||||
public void ToggleOverrideShutdown() => ShutdownOverrideToggles++;
|
||||
public void ToggleHeatsinks() => HeatsinkToggles++;
|
||||
public void ToggleShield() => ShieldToggles++;
|
||||
public void Ping() => Pings++;
|
||||
// Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity,
|
||||
// or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and
|
||||
// so is one to oneself when applied. The last give or stop in a step wins.
|
||||
public void Give(Entity receiver, ItemInstance item, int quantity)
|
||||
{
|
||||
if (receiver == null || item == null) return;
|
||||
var count = item is CraftedItemInstance ? 1 : quantity;
|
||||
if (count < 1) return;
|
||||
GiveVerb = (receiver, item, count);
|
||||
}
|
||||
public void StopGiving() => GiveVerb = (null, null, 0);
|
||||
|
||||
// The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state).
|
||||
public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null;
|
||||
|
||||
// Whether this pilot has commanded a trigger, pull or release, for `behavior` this step.
|
||||
public bool Commands(ActivatedBehavior behavior)
|
||||
{
|
||||
foreach (var trigger in Triggers)
|
||||
if (ReferenceEquals(trigger.Behavior, behavior)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether the last trigger this pilot commanded for `behavior` this step is a pull.
|
||||
public bool Pulls(ActivatedBehavior behavior)
|
||||
{
|
||||
for (var i = Triggers.Count - 1; i >= 0; i--)
|
||||
if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void ClearOneShots()
|
||||
{
|
||||
Designations.Clear();
|
||||
Selections.Clear();
|
||||
Hails.Clear();
|
||||
Uses.Clear();
|
||||
Triggers.Clear();
|
||||
Iffs.Clear();
|
||||
TargetVerb = TargetVerb.None;
|
||||
GiveVerb = null;
|
||||
AimCycles = 0;
|
||||
ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target
|
||||
// lands (a designation, then a chooser verb) before its item is selected or the aim cycled, and the aim before the
|
||||
// triggers, so a pilot fires along the aim it commands this step.
|
||||
public static class PilotApplier
|
||||
{
|
||||
public static void Apply(Entity entity)
|
||||
{
|
||||
var command = entity.Command;
|
||||
foreach (var target in command.Designations)
|
||||
if (!entity.Target.Value.Equals(target)) entity.SetTarget(target);
|
||||
switch (command.TargetVerb)
|
||||
{
|
||||
case TargetVerb.UnderReticle: entity.TargetUnderReticle(); break;
|
||||
case TargetVerb.NearestEnemy: entity.TargetNearestEnemy(); break;
|
||||
case TargetVerb.Next: entity.TargetNext(); break;
|
||||
case TargetVerb.Previous: entity.TargetPrevious(); break;
|
||||
}
|
||||
foreach (var item in command.Selections) entity.TrySelectTargetItem(item);
|
||||
for (var i = 0; i < command.AimCycles; i++) entity.CycleAim();
|
||||
foreach (var (other, hostile, toggle) in command.Iffs)
|
||||
{
|
||||
if (!toggle) entity.SetIff(other, hostile);
|
||||
else if (entity.Target.Value.Entity is Entity target) entity.SetIff(target, !entity.IsHostileTo(target));
|
||||
}
|
||||
for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown;
|
||||
for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled;
|
||||
if (entity.Shield != null)
|
||||
for (var i = 0; i < command.ShieldToggles; i++) entity.Shield.Item.Enabled.Value = !entity.Shield.Item.Enabled.Value;
|
||||
for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping();
|
||||
foreach (var hail in command.Hails) entity.SetMessage(hail);
|
||||
foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable);
|
||||
if (command.GiveVerb is { } give)
|
||||
{
|
||||
if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped);
|
||||
else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.Quantity));
|
||||
}
|
||||
if (command.Aim is float3 aim) entity.Aim = aim;
|
||||
foreach (var (behavior, pull) in command.Triggers)
|
||||
if (pull) behavior.Activate();
|
||||
else behavior.Deactivate();
|
||||
if (entity is Ship ship)
|
||||
{
|
||||
if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship);
|
||||
if (command.Movement is float2 movement) ship.MovementDirection = movement;
|
||||
}
|
||||
if (command.Tractor is float tractor) entity.TractorCommand = tractor;
|
||||
command.ClearOneShots();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/Pilot.cs.meta
Normal file
2
Assets/Scripts/ServerShared/Pilot.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0ae6251a7a32402cb7f7db7ff1fc5084
|
||||
138
Assets/Scripts/ServerShared/Recipes.cs
Normal file
138
Assets/Scripts/ServerShared/Recipes.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
// How a good is made: one authored record per output, keyed recipe:<output key>, so a second recipe for one output replaces the
|
||||
// first. A commodity's recipe takes named commodities (Inputs) and roles (RoleInputs), each role filled by any commodity whose
|
||||
// recipe Fills it, and says which roles it can fill. A role recipe (the output is a design with roles, a ship hull, a piece of
|
||||
// gear, a consumer good) restates nothing of the design: its role inputs are the design's Roles and their counts. Station and
|
||||
// Turret hulls take no recipe.
|
||||
[CultDocument("aetheria.recipedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class RecipeData
|
||||
{
|
||||
[JsonProperty("output"), Key(0)]
|
||||
public CultRecordRef<ItemData> Output;
|
||||
|
||||
[JsonProperty("outputCount"), Key(1)]
|
||||
public int OutputCount = 1;
|
||||
|
||||
// Commodities one cycle consumes. Empty on a raw good and on a role recipe.
|
||||
[JsonProperty("inputs"), Key(2)]
|
||||
public List<CommodityCount> Inputs = new List<CommodityCount>();
|
||||
|
||||
// Seconds of sim time for one cycle.
|
||||
[JsonProperty("cycle"), Key(3)]
|
||||
public float Cycle = 1;
|
||||
|
||||
// Crew per running facility; no kind until workforce kinds exist.
|
||||
[JsonProperty("workforce"), Key(4)]
|
||||
public int Workforce;
|
||||
|
||||
// Commodities one cycle uses up on the side (power, coolant), per cycle.
|
||||
[JsonProperty("upkeep"), Key(5)]
|
||||
public List<CommodityCount> Upkeep = new List<CommodityCount>();
|
||||
|
||||
// The kind of station that runs this recipe.
|
||||
[JsonProperty("stationKind"), Key(6)]
|
||||
public CultRecordRef<StationKindData> StationKind;
|
||||
|
||||
// Why this good exists in the world, in the operator's reading. Presence is checked, never its meaning.
|
||||
[JsonProperty("loreHook"), Key(7)]
|
||||
public string LoreHook;
|
||||
|
||||
// The roles of a design this commodity can fill. Only a commodity's recipe has any.
|
||||
[JsonProperty("fills"), Key(8)]
|
||||
public List<string> Fills = new List<string>();
|
||||
|
||||
// Roles one cycle consumes, any filler will do and the facility picks (ruling ores-fill-metal-roles). Empty on a role recipe;
|
||||
// records written before key 9 read it as empty.
|
||||
[JsonProperty("roleInputs"), Key(9)]
|
||||
public List<ItemRole> RoleInputs = new List<ItemRole>();
|
||||
|
||||
public static CultRecordKey KeyOf(CultRecordKey outputKey) => new CultRecordKey("recipe:" + outputKey.Value);
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class CommodityCount
|
||||
{
|
||||
[JsonProperty("commodity"), Key(0)]
|
||||
public CultRecordRef<ItemData> Commodity;
|
||||
|
||||
[JsonProperty("count"), Key(1)]
|
||||
public int Count = 1;
|
||||
}
|
||||
|
||||
// A region gate's trade with the world beyond it: what the outside buys and sells, at what price and at most how fast.
|
||||
// The union of all records is the outside; keyed outside-market:<id>.
|
||||
[CultDocument("aetheria.outsidemarketdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class OutsideMarketData
|
||||
{
|
||||
[CultName, JsonProperty("name"), Key(0)]
|
||||
public string Name;
|
||||
|
||||
[JsonProperty("rows"), Key(1)]
|
||||
public List<OutsideMarketRow> Rows = new List<OutsideMarketRow>();
|
||||
|
||||
public static CultRecordKey KeyOf(string id) => new CultRecordKey("outside-market:" + id);
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class OutsideMarketRow
|
||||
{
|
||||
[JsonProperty("commodity"), Key(0)]
|
||||
public CultRecordRef<ItemData> Commodity;
|
||||
|
||||
[JsonProperty("price"), Key(1)]
|
||||
public int Price;
|
||||
|
||||
[JsonProperty("buys"), Key(2)]
|
||||
public bool Buys;
|
||||
|
||||
[JsonProperty("sells"), Key(3)]
|
||||
public bool Sells;
|
||||
|
||||
[JsonProperty("ratePerHour"), Key(4)]
|
||||
public float RatePerHour;
|
||||
}
|
||||
|
||||
// The one reader of recipes: which recipe makes a good, a recipe's role slots (RoleInputsOf), which commodities fill a role, and
|
||||
// what a good is by its recipe.
|
||||
public static class Recipes
|
||||
{
|
||||
public static RecipeData RecipeOf(CultCache catalog, ItemData item) =>
|
||||
item == null ? null : catalog.Get<RecipeData>(RecipeData.KeyOf(catalog.RefOf(item).Key));
|
||||
|
||||
public static bool IsCommodity(ItemData item) => item is SimpleCommodityData || item is CompoundCommodityData;
|
||||
|
||||
public static IReadOnlyList<ItemRole> RolesOf(ItemData item) =>
|
||||
(item as CraftedItemData)?.Roles ?? new List<ItemRole>();
|
||||
|
||||
// A recipe whose output is a design with roles: a part list rather than a list of inputs.
|
||||
public static bool IsRoleRecipe(CultCache catalog, RecipeData recipe)
|
||||
{
|
||||
var output = catalog.Get(recipe.Output);
|
||||
return output is CraftedItemData && RolesOf(output).Count > 0;
|
||||
}
|
||||
|
||||
// The one reader of a recipe's role slots: the design's Roles for a role recipe, else the recipe's RoleInputs.
|
||||
public static IReadOnlyList<ItemRole> RoleInputsOf(CultCache catalog, RecipeData recipe) =>
|
||||
IsRoleRecipe(catalog, recipe) ? RolesOf(catalog.Get(recipe.Output)) : (IReadOnlyList<ItemRole>) recipe.RoleInputs ?? new List<ItemRole>();
|
||||
|
||||
// Raw: takes no commodity and no role. Everything else is processed. Derived, never stored.
|
||||
public static bool IsRaw(CultCache catalog, RecipeData recipe) => recipe.Inputs.Count == 0 && RoleInputsOf(catalog, recipe).Count == 0;
|
||||
|
||||
// A commodity made by a role recipe: it is assembled from the same components gear is.
|
||||
public static bool IsConsumerGood(CultCache catalog, RecipeData recipe) =>
|
||||
IsCommodity(catalog.Get(recipe.Output)) && IsRoleRecipe(catalog, recipe);
|
||||
|
||||
// The commodities whose recipes Fill the role, ordered by key so a choice never depends on store order.
|
||||
public static CultRecordKey[] Fillers(CultCache catalog, string role) =>
|
||||
catalog.GetAll<RecipeData>()
|
||||
.Where(recipe => recipe.Fills != null && recipe.Fills.Contains(role) && IsCommodity(catalog.Get(recipe.Output)))
|
||||
.Select(recipe => recipe.Output.Key)
|
||||
.OrderBy(key => key.Value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Recipes.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Recipes.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3c9a51d7e0b24f68a1e47d92b5c08f31
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -25,7 +25,7 @@ public static class RunStart
|
|||
|
||||
public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, PlanetSettings planetSettings, Galaxy galaxy, Scenario scenario)
|
||||
{
|
||||
SeedItems(itemManager, galaxy, 1);
|
||||
SeedItems(itemManager, galaxy, ArenaStep);
|
||||
return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone(
|
||||
itemManager, zoneSettings, planetSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient);
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ public static class RunStart
|
|||
public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull,
|
||||
TutorialGenerationSettings tutorialGenerationSettings, List<string> failures)
|
||||
{
|
||||
SeedItems(itemManager, arena.Galaxy, 2);
|
||||
SeedItems(itemManager, arena.Galaxy, StageStep);
|
||||
var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings);
|
||||
scenario.Stage(stage);
|
||||
var reported = failures.Count;
|
||||
|
|
@ -55,6 +55,12 @@ public static class RunStart
|
|||
player.SetIff(placed.Entity, hostile);
|
||||
arena.Admit(placed.Entity, placed.Piloted, placed.Task);
|
||||
}
|
||||
|
||||
// A start state includes what a pilot knows (ruling stage-states-pilot-knowledge): its anchor is tracked from the first
|
||||
// step, by the sensors' own path (info gathered past the detection threshold), so they can lose it like any track.
|
||||
foreach (var placed in stage.Placed)
|
||||
if (placed.Knows != null && placed.Piloted)
|
||||
placed.Entity.EntityInfoGathered[placed.Knows] = (itemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f;
|
||||
return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() };
|
||||
}
|
||||
|
||||
|
|
@ -62,5 +68,22 @@ public static class RunStart
|
|||
// 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);
|
||||
itemManager.Random = new Random(ItemSeed(galaxy, step));
|
||||
|
||||
// The steps of a run that draw items: generating its arena, staging its scenario, and continuing a saved run.
|
||||
public const uint ArenaStep = 1;
|
||||
public const uint StageStep = 2;
|
||||
public const uint ContinueStep = 3;
|
||||
|
||||
// The item seed of an ActionGameManager scene opened with no galaxy. No run is loaded, so nothing a run decides
|
||||
// draws from it.
|
||||
public const uint NoRunSeed = 0x5EED0001u;
|
||||
|
||||
// The item stream's seed for one step of a run: the run's seed and the step. Never zero, which no stream accepts.
|
||||
public static uint ItemSeed(Galaxy galaxy, uint step)
|
||||
{
|
||||
var seed = galaxy.Seed * 0x9E3779B1u + step;
|
||||
if (seed == 0) throw new InvalidOperationException($"Item seed is zero for galaxy seed {galaxy.Seed} at step {step}.");
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,22 @@ public class SavedGame
|
|||
// by Galaxy's save constructor; 0 in a save written before run-seed, which then rolls name-only drops.
|
||||
[Key(14)]
|
||||
public uint Seed;
|
||||
|
||||
// The run's resource provinces (Galaxy.Provinces): each province's seed zone index and its type. Written only by
|
||||
// RunSave.Capture and read only by Galaxy's save constructor; null in a save written before ore geography, which then
|
||||
// gets Regions, Partition and Deal from its saved graph, boss zones, Exit and seed.
|
||||
[Key(15)]
|
||||
public List<SavedProvince> Provinces;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SavedProvince
|
||||
{
|
||||
[Key(0)]
|
||||
public int Seed;
|
||||
|
||||
[Key(1)]
|
||||
public CultRecordRef<ProvinceData> Type;
|
||||
}
|
||||
|
||||
// The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records;
|
||||
|
|
@ -96,8 +112,11 @@ public static class RunSave
|
|||
AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(),
|
||||
Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(),
|
||||
Contents = zone.Contents?.PackZone() ?? zone.PackedContents,
|
||||
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner)
|
||||
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner),
|
||||
Province = Array.IndexOf(galaxy.Zones, zone.Province.Seed)
|
||||
}).ToArray();
|
||||
game.Provinces = galaxy.Provinces.Select(province => new SavedProvince
|
||||
{Seed = Array.IndexOf(galaxy.Zones, province.Seed), Type = province.Type}).ToList();
|
||||
return (game, zones);
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +240,11 @@ public class SavedZone
|
|||
|
||||
[Key(5)]
|
||||
public ZonePack Contents;
|
||||
|
||||
// The seed zone index of the zone's province. Written only by RunSave.Capture and read only by Galaxy's save
|
||||
// constructor; meaningful only when SavedGame.Provinces is present.
|
||||
[Key(6)]
|
||||
public int Province;
|
||||
}
|
||||
|
||||
[MessagePackObject,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ public abstract class Scenario
|
|||
{
|
||||
public abstract string Name { get; }
|
||||
|
||||
// The scenario's stable identity: kebab-case, unique across Scenarios, never shown to the player, recorded by
|
||||
// matches and replays. Name is display only.
|
||||
public abstract string Id { get; }
|
||||
|
||||
// The conditions and what to verify in them, shown under the scenario's button.
|
||||
public abstract string Brief { get; }
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ public sealed class ScenarioStage
|
|||
public ScenarioStance Stance;
|
||||
public bool Piloted;
|
||||
public AgentTask Task;
|
||||
// What the pilot already knows at the start: an entity its sensors hold a track of (Approach). Seeded through the
|
||||
// sensors' own state at admission, so they may lose it honestly.
|
||||
public Entity Knows;
|
||||
}
|
||||
|
||||
// A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is
|
||||
|
|
@ -137,7 +140,7 @@ public sealed class ScenarioStage
|
|||
}
|
||||
|
||||
// The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff
|
||||
// nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states
|
||||
// nothing), in place of the patrol circuit the zone would draw for it. The pilot starts knowing it (RunStart seeds a sensor track). A check that rests on where a pilot goes states
|
||||
// it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a
|
||||
// target that is not placed, is a failure.
|
||||
public void Approach(Entity entity, Entity target)
|
||||
|
|
@ -145,7 +148,11 @@ public sealed class ScenarioStage
|
|||
var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity));
|
||||
if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; }
|
||||
if (target == null) { _failures.Add("approach: no target to close on"); return; }
|
||||
placement.Task = new FollowTask { Anchor = target, Standoff = 0f };
|
||||
// The one production handle minted from truth: the stage states the pilot's knowledge of its anchor (ruling
|
||||
// stage-states-pilot-knowledge), and RunStart seeds the matching sensor track. The view still answers about the
|
||||
// anchor only while the pilot's sensors hold it.
|
||||
placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f };
|
||||
placement.Knows = target;
|
||||
}
|
||||
|
||||
// Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class AiFusedDiscipline : Scenario
|
||||
{
|
||||
public override string Name => "AI Fused Discipline";
|
||||
public override string Id => "ai-fused-discipline";
|
||||
public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " +
|
||||
"Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " +
|
||||
"fused one.";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class Arcs : Scenario
|
||||
{
|
||||
public override string Name => "Arcs";
|
||||
public override string Id => "arcs";
|
||||
public override string Brief => "The Smoke Test Hull with forward mounts and a side mount on each flank; a bare hull off the bow, one off " +
|
||||
"the stern, one abeam to port, one abeam to starboard, and a hostile turret. Verify: the hull keeps its " +
|
||||
"heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " +
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using static ItemRotation;
|
|||
public sealed class Belt : Scenario
|
||||
{
|
||||
public override string Name => "Belt";
|
||||
public override string Id => "belt";
|
||||
public override string Brief => "The biggest asteroid belt the generator made, with the Longinus parked 100 m outside its ring, facing along it, " +
|
||||
"and nothing else in the arena. Verify: next and previous target step through the rocks and the reticle sweeps " +
|
||||
"across the belt without a hitch; Previous with nothing targeted takes the farthest rock; the target indicator " +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class Crossing : Scenario
|
||||
{
|
||||
public override string Name => "Crossing";
|
||||
public override string Id => "crossing";
|
||||
public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " +
|
||||
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " +
|
||||
"its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public sealed class DemoTerminus : Scenario
|
|||
};
|
||||
|
||||
public override string Name => "Terminus (Demo)";
|
||||
public override string Id => "demo-terminus";
|
||||
public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " +
|
||||
"Neutral: Lucent Media and Aeronautics Unlimited.";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using float2 = CultMath.float2;
|
|||
public sealed class DjinniShakedown : Scenario
|
||||
{
|
||||
public override string Name => "Djinni Shakedown";
|
||||
public override string Id => "djinni-shakedown";
|
||||
public override string Brief => "A fitted Djinni in empty space, facing +z, with a bare hull dead ahead and one abeam. Verify: its thrusters " +
|
||||
"move and turn it; with the hull offline and thrusters firing, its speed does not run away. Its " +
|
||||
"articulated ballistic barrel points at the hull ahead when it bears, and rests at the arc edge while you " +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class Duel : Scenario
|
||||
{
|
||||
public override string Name => "Duel";
|
||||
public override string Id => "duel";
|
||||
public override string Brief => "One piloted, hostile Longinus with slow guided missiles and a Light Laser 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;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class FusedRounds : Scenario
|
||||
{
|
||||
public override string Name => "Fused Rounds";
|
||||
public override string Id => "fused-rounds";
|
||||
public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a Cluster Cannon; a hostile bare Longinus at mid range, a bare " +
|
||||
"hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " +
|
||||
"Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class LauncherAngles : Scenario
|
||||
{
|
||||
public override string Name => "Launcher Angles";
|
||||
public override string Id => "launcher-angles";
|
||||
public override string Brief => "A Guided Missile Launcher, a Swarm Rocket Pod and two Cell Blasters; a hostile bare Longinus broadside on, another beside it. " +
|
||||
"Verify: hits pulse only the facing edge; a Swarm Rocket Pod round whose target dies fades unexploded.";
|
||||
public override uint Seed => 105;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class LockOn : Scenario
|
||||
{
|
||||
public override string Name => "Lock On";
|
||||
public override string Id => "lock-on";
|
||||
public override string Brief => "A hostile closes from out of sight, finds you and locks. Verify: no warning at first; once it locks, one " +
|
||||
"indicator appears toward it and tightens as the lock grows; break line of sight or range and it vanishes; " +
|
||||
"your own ping shows no incoming paint on its side, only the lock does.";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using float2 = CultMath.float2;
|
|||
public sealed class LongHaul : Scenario
|
||||
{
|
||||
public override string Name => "Long Haul";
|
||||
public override string Id => "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;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using float2 = CultMath.float2;
|
|||
public sealed class MainGalaxy : Scenario
|
||||
{
|
||||
public override string Name => "Main Galaxy";
|
||||
public override string Id => "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();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public sealed class Pursuit : Scenario
|
|||
private const float Astern = 300;
|
||||
|
||||
public override string Name => "Pursuit";
|
||||
public override string Id => "pursuit";
|
||||
public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " +
|
||||
"Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " +
|
||||
"the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away.";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class RefusedRounds : Scenario
|
||||
{
|
||||
public override string Name => "Refused Rounds";
|
||||
public override string Id => "refused-rounds";
|
||||
public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " +
|
||||
"dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " +
|
||||
"trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class StarvedReactor : Scenario
|
||||
{
|
||||
public override string Name => "Starved Reactor";
|
||||
public override string Id => "starved-reactor";
|
||||
public override string Brief => "Two Light Lasers on a Muon Fusion Reactor, which can fall to a quarter of the Compact Reactor's charge, and a bare hull " +
|
||||
"to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter.";
|
||||
public override uint Seed => 102;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using float2 = CultMath.float2;
|
|||
public sealed class TutorialGalaxy : Scenario
|
||||
{
|
||||
public override string Name => "Tutorial Galaxy";
|
||||
public override string Id => "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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using static ItemRotation;
|
|||
public sealed class WeaponFeel : Scenario
|
||||
{
|
||||
public override string Name => "Weapon Feel";
|
||||
public override string Id => "weapon-feel";
|
||||
public override string Brief => "The Smoke Test Hull with a Charged Scatter Blaster, an Autocannon and a Cell Blaster burst weapon; a bare Longinus dead ahead. " +
|
||||
"Verify: Charged Scatter Blaster and Autocannon fire with effects, particles and action-bar icons; the burst weapon's " +
|
||||
"cadence is as before; a fast release-and-press on the Charged Scatter Blaster starts no second charge.";
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ public class SectorGenerationSettings
|
|||
{
|
||||
public float LinkDensity = .5f;
|
||||
public int ZoneCount = 128;
|
||||
// Zones per resource province (Provinces.Partition): calibration, not a gate.
|
||||
public int ProvinceZones = Provinces.DefaultZonesPerProvince;
|
||||
public int MegaCount;
|
||||
public int BossCount;
|
||||
}
|
||||
|
|
@ -97,6 +99,8 @@ public class TutorialGenerationSettings
|
|||
public string QuestFaction;
|
||||
public float LinkDensity = .5f;
|
||||
public int ZoneCount = 32;
|
||||
// Zones per resource province (Provinces.Partition): calibration, not a gate.
|
||||
public int ProvinceZones = Provinces.DefaultZonesPerProvince;
|
||||
}
|
||||
|
||||
[Serializable, MessagePackObject(keyAsPropertyName:true), JsonObject]
|
||||
|
|
@ -134,6 +138,12 @@ public class ZoneGenerationSettings
|
|||
public float TrojanHalfWidth = .06f;
|
||||
public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f };
|
||||
|
||||
// Laned belts (rulings lanes-two-sided-visual-margin, lane-narrow): the chance a belt candidate that kept its body and has
|
||||
// room on both sides carries a laned belt, and the lane's half-width in the host's body radii (1.5 by ruling). LaneProbability
|
||||
// is 0 here: a settings object built in code draws no lane.
|
||||
public float LaneProbability = 0f;
|
||||
public float LaneBodyRadii = 1.5f;
|
||||
|
||||
public float ResourceDensityMinimum = .1f;
|
||||
public float ResourceDensityMaximum = 1.5f;
|
||||
|
||||
|
|
@ -177,6 +187,11 @@ public class GameplaySettings
|
|||
public float ThermalQualityMin = 2;
|
||||
public float ThermalQualityMax = .25f;
|
||||
public float DefaultShutdownPerformance = .25f;
|
||||
// Global flight tuning over every thruster's authored push (not its turning) and every hull's authored Drag. Push is a tenth, the
|
||||
// ratio gravity fell by in the honest-wells cut (aetheria-gravity:field-strength-ruled, 5 to 0.5 u/s^2): most ships
|
||||
// cannot climb straight out of a well and use orbits instead; an overspecced hull still can.
|
||||
public float ThrustScale = .1f;
|
||||
public float DragScale = 1f;
|
||||
// Free-floating bodies (floating items, mines): drag on a launch velocity, drag on the force-driven drift, and
|
||||
// how strongly the zone force accelerates it.
|
||||
public float FloatingBodyDrag = .05f;
|
||||
|
|
@ -199,6 +214,8 @@ public class GameplaySettings
|
|||
public int WeaponGroupCount = 6;
|
||||
public float WarpDistance = 25;
|
||||
public float DockingDistance = 25;
|
||||
// Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item.
|
||||
public float TransferRate = 2;
|
||||
public float ProductionPersonalityLerp = .05f;
|
||||
public float MessageDuration = 4f;
|
||||
public float TargetPersistenceDuration = 3;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
/* 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/. */
|
||||
|
||||
|
|
@ -25,10 +25,11 @@ public class Ship : Entity
|
|||
// [IgnoreMember] public Dictionary<Targetable, float> Contacts = new Dictionary<Targetable, float>();
|
||||
// [IgnoreMember] public Targetable Target;
|
||||
public Entity HomeEntity;
|
||||
public float2 MovementDirection;
|
||||
// The one facing command, -1..1, positive clockwise. Written each tick by the pilot (player input or the
|
||||
// ship's agent); a heading becomes a demand only through Steering.Toward. Runtime only, never packed.
|
||||
public float Turn;
|
||||
// The pilot-controlled hull fields. PilotApplier, in the zone step, is their pilot-side writer, from the command the
|
||||
// pilot (player input or the ship's agent) gave the entity; no Unity script can set them. Runtime only, never packed.
|
||||
public float2 MovementDirection { get; internal set; }
|
||||
// The one facing command, -1..1, positive clockwise; a heading becomes a demand only through Steering.Toward.
|
||||
public float Turn { get; internal set; }
|
||||
public bool IsPlayerShip;
|
||||
|
||||
private Thruster[] _allThrusters;
|
||||
|
|
@ -111,6 +112,43 @@ public class Ship : Entity
|
|||
|
||||
#endregion
|
||||
|
||||
// The player's turn axis as a fraction of the hull's yaw extreme, banded so each thruster joins over an equal share of
|
||||
// the input. The allocator serves a turn by the thrusters with the least translation per unit of yaw first and brings in
|
||||
// the next as they saturate; here the thrusters yawing the asked way are ranked by that ratio, equal ratios form one band,
|
||||
// and band i fills over input [i/k, (i+1)/k]. A hull whose best-ratio thrusters hold a small share of its yaw then gets
|
||||
// half the travel of a two-band turn before its drives join, instead of the first tenth. Full input is still full yaw.
|
||||
public float BandedTurn(float axis)
|
||||
{
|
||||
axis = clamp(axis, -1f, 1f);
|
||||
if (axis == 0 || _allThrusters == null) return axis;
|
||||
var direction = axis > 0 ? 1f : -1f;
|
||||
var thrusters = new List<(float ratio, float yaw)>();
|
||||
var total = 0f;
|
||||
foreach (var thruster in _allThrusters)
|
||||
{
|
||||
var column = thruster.Column(thruster.NominalThrust);
|
||||
var yaw = column.z * direction;
|
||||
if (!(yaw > 0)) continue;
|
||||
thrusters.Add((length(float2(column.x, column.y)) / yaw, yaw));
|
||||
total += yaw;
|
||||
}
|
||||
if (!(total > 0)) return axis;
|
||||
thrusters.Sort((a, b) => a.ratio.CompareTo(b.ratio));
|
||||
var bands = new List<float>();
|
||||
var bandRatio = float.NaN;
|
||||
foreach (var (ratio, yaw) in thrusters)
|
||||
{
|
||||
if (bands.Count > 0 && abs(ratio - bandRatio) <= .02f * max(bandRatio, 1e-6f)) bands[bands.Count - 1] += yaw;
|
||||
else { bands.Add(yaw); bandRatio = ratio; }
|
||||
}
|
||||
var travel = abs(axis) * bands.Count;
|
||||
var band = min((int)travel, bands.Count - 1);
|
||||
var served = 0f;
|
||||
for (var i = 0; i < band; i++) served += bands[i];
|
||||
served += (travel - band) * bands[band];
|
||||
return direction * min(served / total, 1f);
|
||||
}
|
||||
|
||||
// Called by Entity.Update once the items' performance is current and before the power bus is stepped and the items
|
||||
// execute: liveness, thrust and the throttles they decide are one phase, the one Execute acts on. The lock is a
|
||||
// bound on the allocator's solve; intent reaches the allocator here and nowhere else.
|
||||
|
|
@ -126,13 +164,22 @@ public class Ship : Entity
|
|||
|
||||
public override void Update(float delta)
|
||||
{
|
||||
var velocityMagnitude = length(Velocity);
|
||||
if(velocityMagnitude > .01f)
|
||||
Velocity = normalize(Velocity) * decay(velocityMagnitude, HullData.Drag, delta);
|
||||
|
||||
Position.xz += Velocity * delta;
|
||||
|
||||
Velocity += Zone.GetForce(Position.xz) * delta;
|
||||
// No drag below top speed: ships coast and orbits do not decay; drag bleeds off only the excess over the hull's top
|
||||
// speed (aetheria-gravity:ruling:no-drag-below-top-speed). The VelocityLimit clamp still caps it, so the excess is
|
||||
// what this step's forces added; a soft cap, where surfing the beat is a temporary boost, is the ruling's other half.
|
||||
// A docked ship has no flight of its own: its parent owns its pose (Entity.Update).
|
||||
if (Parent == null)
|
||||
{
|
||||
var velocityMagnitude = length(Velocity);
|
||||
var topSpeed = float.PositiveInfinity;
|
||||
foreach (var limit in GetBehaviors<VelocityLimit>()) topSpeed = min(topSpeed, limit.Limit);
|
||||
if (velocityMagnitude > topSpeed)
|
||||
Velocity = normalize(Velocity) * (topSpeed + decay(velocityMagnitude - topSpeed, HullData.Drag * ItemManager.GameplaySettings.DragScale, delta));
|
||||
|
||||
Position.xz += Velocity * delta;
|
||||
|
||||
Velocity += Zone.GetForce(Position.xz) * delta;
|
||||
}
|
||||
var normal = Zone.GetNormal(Position.xz);
|
||||
var shipRight = Direction.Rotate(ItemRotation.Clockwise);
|
||||
var forward = cross(float3(shipRight.x, 0, shipRight.y), normal);
|
||||
|
|
|
|||
|
|
@ -136,10 +136,14 @@ public static class ShipAuthoringStore
|
|||
{
|
||||
RefuseLegacyEmbeddedHull(path);
|
||||
try { return LoadRecords(path); }
|
||||
catch (MessagePackSerializationException error)
|
||||
catch (Exception error) when (error is InvalidDataException or MessagePackSerializationException)
|
||||
{
|
||||
throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error);
|
||||
}
|
||||
catch (NotSupportedException error)
|
||||
{
|
||||
throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record
|
||||
|
|
@ -152,7 +156,7 @@ public static class ShipAuthoringStore
|
|||
if (bytes.Length == 0) return;
|
||||
CultPersistedStoreSnapshot snapshot;
|
||||
try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); }
|
||||
catch (MessagePackSerializationException error)
|
||||
catch (Exception error) when (error is MessagePackSerializationException or NotSupportedException)
|
||||
{
|
||||
throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ public static class ShipModCatalog
|
|||
|
||||
public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value);
|
||||
|
||||
// A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled.
|
||||
// A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed
|
||||
// fails closed where it is read.
|
||||
public static bool IsModKey(CultRecordKey key) =>
|
||||
key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal));
|
||||
|
||||
|
|
@ -108,6 +109,7 @@ public static class ShipModCatalog
|
|||
Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item.");
|
||||
if (shipIds.Contains(ship.Id))
|
||||
Exclude(directory, $"ship ID collides with an existing catalog ship authoring record.");
|
||||
if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault);
|
||||
foreach (var product in package.Products)
|
||||
{
|
||||
if (!factionKeys.Contains(product.Manufacturer.Key))
|
||||
|
|
|
|||
|
|
@ -14,11 +14,19 @@ public sealed class SimClock
|
|||
// catch-up steps.
|
||||
public const float MaxFrameSeconds = .1f;
|
||||
|
||||
// Game steps per real second at design speed 1, and the sim seconds one step carries. The one owner of the game's
|
||||
// tick: every driver of a game history steps Zone.Update by GameStep. Design speed (ruling sim-speed-surface) will
|
||||
// set Step = DesignSpeed / TickRate in ForGame and nowhere else.
|
||||
public const int TickRate = 30;
|
||||
public const float GameStep = 1f / TickRate;
|
||||
|
||||
public static SimClock ForGame() => new SimClock(GameStep, TickRate);
|
||||
|
||||
private readonly double _period;
|
||||
private double _accumulator;
|
||||
private bool _advancing;
|
||||
|
||||
public SimClock(float step, float stepsPerRealSecond = 60)
|
||||
public SimClock(float step, float stepsPerRealSecond)
|
||||
{
|
||||
if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step));
|
||||
if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond));
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ public static class SimulationDice
|
|||
// never rides ItemManager.Random, so minting items cannot change how a pilot fights.
|
||||
public const uint CombatSampleStream = 3;
|
||||
public const uint PatrolStream = 4;
|
||||
// Generation placement of an unpacked ship packed at the origin, ordinal its 1-based index in the pack, on the zone's
|
||||
// seed (CombatSeed): the run seed feeds drops and mines only (ruling run-seed-drops-only), and placement never rides
|
||||
// ItemManager.Random.
|
||||
public const uint ScatterStream = 5;
|
||||
|
||||
// 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply,
|
||||
// xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small,
|
||||
|
|
|
|||
161
Assets/Scripts/ServerShared/StationKinds.cs
Normal file
161
Assets/Scripts/ServerShared/StationKinds.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
// What a station is for: one authored catalog record per kind (mining, trade-hub, shipyard...). The catalog's list of
|
||||
// these records is the one list of station kinds. A kind names no hull: the hull names its kind (HullData.Kind), and
|
||||
// which hull a faction builds for a kind is derived from the products that faction makes (StationKinds), so a package can
|
||||
// add a faction's model of a kind without editing any kind record. Records are keyed station-kind:<id>.
|
||||
[CultDocument("aetheria.stationkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class StationKindData
|
||||
{
|
||||
[CultName, JsonProperty("name"), Key(0)]
|
||||
public string Name;
|
||||
|
||||
[JsonProperty("description"), Key(1)]
|
||||
public string Description;
|
||||
|
||||
// A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the
|
||||
// faction offers a model of this kind.
|
||||
[JsonProperty("standIns"), Key(2)]
|
||||
public List<StationKindStandIn> StandIns = new List<StationKindStandIn>();
|
||||
|
||||
public static CultRecordKey KeyOf(string id) => new CultRecordKey("station-kind:" + id);
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class StationKindStandIn
|
||||
{
|
||||
[JsonProperty("faction"), Key(0)]
|
||||
public CultRecordRef<Faction> Faction;
|
||||
|
||||
// The kind whose model this faction builds in place of the owning kind's.
|
||||
[JsonProperty("kind"), Key(1)]
|
||||
public CultRecordRef<StationKindData> Kind;
|
||||
}
|
||||
|
||||
// The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in.
|
||||
public static class StationKinds
|
||||
{
|
||||
// The kinds this faction can place: those of the station hulls its products offer, and those that stand in for it.
|
||||
// Ordered by key so a choice never depends on store order. Empty for no faction.
|
||||
public static CultRecordRef<StationKindData>[] KindsOf(CultCache catalog, Faction faction)
|
||||
{
|
||||
if (faction == null) return Array.Empty<CultRecordRef<StationKindData>>();
|
||||
var kinds = new HashSet<string>(OfferedStationHulls(catalog, faction).Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal);
|
||||
foreach (var kind in catalog.GetAll<StationKindData>())
|
||||
if (kind.StandIns.Any(standIn => catalog.Get(standIn.Faction) == faction))
|
||||
kinds.Add(catalog.RefOf(kind).Key.Value);
|
||||
return Ordered(kinds);
|
||||
}
|
||||
|
||||
// The kinds this faction places that a station can be drawn for: those whose hull kind (its own model, else its stand-in's) some
|
||||
// maker offers. A stand-in whose model has not landed leaves its kind out.
|
||||
public static CultRecordRef<StationKindData>[] FieldableKindsOf(CultCache catalog, Faction faction) =>
|
||||
KindsOf(catalog, faction).Where(kind => HasModel(catalog, faction, kind) || LiveStandIn(catalog, faction, kind).IsSet()).ToArray();
|
||||
|
||||
// A station's kind: a stable pick among the faction's kinds it can field, by the station's site, which generation names by the
|
||||
// zone's seed and the station's place in its orbit order (never by a key the cache mints). It draws nothing from any random
|
||||
// stream, so adding the kind to generation moves no other roll. A kind the faction places but cannot field (neither its own
|
||||
// model nor a stand-in's hull kind is carried by any offered station hull) is not in the pool; a faction left with no kind to
|
||||
// field falls back to any kind some offered station hull carries, logged, and the hull draw then falls back to any maker.
|
||||
// Unset when the catalog has none.
|
||||
public static CultRecordRef<StationKindData> Choose(CultCache catalog, Faction faction, string site, Action<string> log = null)
|
||||
{
|
||||
var pool = FieldableKindsOf(catalog, faction);
|
||||
if (pool.Length == 0)
|
||||
{
|
||||
pool = Ordered(OfferedKinds(catalog));
|
||||
if (pool.Length == 0) return default;
|
||||
log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind it can field; placing a station of any kind some maker offers.");
|
||||
}
|
||||
return pool[(int) (site.StableHash() % (uint) pool.Length)];
|
||||
}
|
||||
|
||||
// The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model
|
||||
// wins over a stale stand-in), else the kind its stand-in names when some maker offers that, else the kind itself (the hull
|
||||
// draw then falls back to any maker).
|
||||
public static CultRecordRef<StationKindData> HullKindFor(CultCache catalog, Faction faction, CultRecordRef<StationKindData> kind)
|
||||
{
|
||||
if (!kind.IsSet() || HasModel(catalog, faction, kind)) return kind;
|
||||
var standIn = LiveStandIn(catalog, faction, kind);
|
||||
return standIn.IsSet() ? standIn : kind;
|
||||
}
|
||||
|
||||
private static bool HasModel(CultCache catalog, Faction faction, CultRecordRef<StationKindData> kind) =>
|
||||
OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key));
|
||||
|
||||
// The kind the faction's stand-in names for this kind, when some maker offers a hull of it; else unset.
|
||||
private static CultRecordRef<StationKindData> LiveStandIn(CultCache catalog, Faction faction, CultRecordRef<StationKindData> kind)
|
||||
{
|
||||
var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction);
|
||||
return standIn != null && standIn.Kind.IsSet() && OfferedKinds(catalog).Contains(standIn.Kind.Key.Value) ? standIn.Kind : default;
|
||||
}
|
||||
|
||||
// What is wrong with a hull's kind, or null: a Station hull names a kind the catalog holds, and no other hull names one.
|
||||
// The one rule, applied to the catalog on Open and to each mod package on Compose.
|
||||
public static string HullKindFault(CultCache catalog, HullData hull)
|
||||
{
|
||||
if (hull.HullType == HullType.Station)
|
||||
{
|
||||
if (!hull.Kind.IsSet())
|
||||
return $"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be.";
|
||||
if (catalog.Get(hull.Kind) == null)
|
||||
return $"Station hull '{hull.Name}' Kind names no station kind this catalog holds.";
|
||||
}
|
||||
else if (hull.Kind.IsSet())
|
||||
return $"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds
|
||||
// and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and
|
||||
// does not stand on another stand-in for the same faction (one hop). A caller that gives kindlessStation is the owner
|
||||
// of the catalog being repaired: a Station hull with no Kind is reported to it by name instead of refused.
|
||||
public static void Validate(CultCache catalog, Action<string> kindlessStation = null)
|
||||
{
|
||||
foreach (var hull in catalog.GetAll<HullData>())
|
||||
{
|
||||
var fault = HullKindFault(catalog, hull);
|
||||
if (fault == null) continue;
|
||||
if (kindlessStation != null && hull.HullType == HullType.Station && !hull.Kind.IsSet()) kindlessStation(fault);
|
||||
else throw new InvalidOperationException(fault);
|
||||
}
|
||||
foreach (var kind in catalog.GetAll<StationKindData>())
|
||||
{
|
||||
var self = catalog.RefOf(kind).Key;
|
||||
for (var i = 0; i < kind.StandIns.Count; i++)
|
||||
{
|
||||
var entry = kind.StandIns[i];
|
||||
var at = $"Station kind '{kind.Name}' StandIns[{i}]";
|
||||
var faction = catalog.Get(entry.Faction);
|
||||
if (faction == null) throw new InvalidOperationException($"{at}.Faction names no faction this catalog holds.");
|
||||
var other = catalog.Get(entry.Kind);
|
||||
if (other == null) throw new InvalidOperationException($"{at}.Kind names no station kind this catalog holds.");
|
||||
if (entry.Kind.Key.Equals(self)) throw new InvalidOperationException($"{at}.Kind is the kind itself; a kind cannot stand in for itself.");
|
||||
if (other.StandIns.Any(next => catalog.Get(next.Faction) == faction))
|
||||
throw new InvalidOperationException($"{at}.Kind stands in for the same faction itself; a stand-in is one hop.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hull kinds some maker offers: the pool a station can always be drawn from.
|
||||
private static HashSet<string> OfferedKinds(CultCache catalog) =>
|
||||
new HashSet<string>(DrawableStationProducts(catalog).Select(entry => entry.hull.Kind.Key.Value), StringComparer.Ordinal);
|
||||
|
||||
private static IEnumerable<HullData> OfferedStationHulls(CultCache catalog, Faction faction) =>
|
||||
DrawableStationProducts(catalog).Where(entry => catalog.Get(entry.product.Manufacturer) == faction).Select(entry => entry.hull);
|
||||
|
||||
// The station hulls a hull draw can reach (LoadoutGenerator.IsDrawable, the one definition), with the product that sells each.
|
||||
private static IEnumerable<(FactionProductData product, HullData hull)> DrawableStationProducts(CultCache catalog) =>
|
||||
catalog.GetAll<FactionProductData>()
|
||||
.Select(product => (product, hull: catalog.Get(product.Design) as HullData))
|
||||
.Where(entry => entry.hull != null && entry.hull.HullType == HullType.Station && entry.hull.Kind.IsSet() &&
|
||||
LoadoutGenerator.IsDrawable(catalog, entry.product, entry.hull));
|
||||
|
||||
private static CultRecordRef<StationKindData>[] Ordered(IEnumerable<string> keys) =>
|
||||
keys.OrderBy(key => key, StringComparer.Ordinal).Select(key => new CultRecordRef<StationKindData>(new CultRecordKey(key))).ToArray();
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/StationKinds.cs.meta
Normal file
11
Assets/Scripts/ServerShared/StationKinds.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7b3e1a94c2d84f6e9a05d1c8e4f27b63
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -79,6 +79,21 @@ public static class StationServices
|
|||
return BuyResult.Bought;
|
||||
}
|
||||
|
||||
// The purchase of `quantity` units of a station stack, delivered as one lot. A bay holds no stack above its
|
||||
// design's MaxStack, so a stack the station holds is a lot already; a stack above it comes from outside a bay and is
|
||||
// refused, as is a purchase of more than the stack holds or of a stack that holds nothing. All of that is refused
|
||||
// before anything moves, so `deliver` moves units the station has and the charge is for the units it delivered.
|
||||
public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func<int, bool> deliver)
|
||||
{
|
||||
if (quantity < 1 || quantity > stock.Quantity || stock.Quantity > items.GetData(stock).MaxStack) return BuyResult.NoRoom;
|
||||
return TryBuy(items, stock, quantity, ref credits, () => deliver(quantity));
|
||||
}
|
||||
|
||||
// The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station
|
||||
// has, and never below one unit (a price the credits do not cover is then refused as short of credits).
|
||||
public static int BuyableQuantity(ItemManager items, SimpleCommodity stock, int typed, int credits) =>
|
||||
Math.Max(1, Math.Min(Math.Min(typed, credits / BuyPrice(items, stock)), stock.Quantity));
|
||||
|
||||
// The sale: the item leaves the bay for one of the station's cargo bays and the station pays SellPrice. False,
|
||||
// with the item in its bay and the credits unchanged, when no station bay can take it.
|
||||
public static bool TrySell(EquippedCargoBay from, Entity station, ItemInstance item, ref int credits)
|
||||
|
|
|
|||
|
|
@ -69,10 +69,9 @@ public class Zone
|
|||
private ItemManager _itemManager;
|
||||
private double _time;
|
||||
public List<Agent> Agents = new List<Agent>();
|
||||
// One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns
|
||||
// what its members target and do (Agents/Flight.cs).
|
||||
private static readonly Faction Unaffiliated = new Faction();
|
||||
private readonly Dictionary<Faction, Flight> _flights = new Dictionary<Faction, Flight>();
|
||||
// One flight per side, created on its first piloted join. A side is a faction, or one unaffiliated ship
|
||||
// (brain-one-per-side-r2). A flight owns what its members target and do (Agents/Flight.cs).
|
||||
private readonly Dictionary<object, Flight> _flights = new Dictionary<object, Flight>();
|
||||
public IEnumerable<Flight> Flights => _flights.Values;
|
||||
|
||||
// Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and
|
||||
|
|
@ -177,6 +176,8 @@ public class Zone
|
|||
if (agent == null) return;
|
||||
Agents.Remove(agent);
|
||||
foreach (var flight in _flights.Values) flight.Leave(agent);
|
||||
// A side of one is gone with its ship.
|
||||
if (remove.Value.Faction == null) _flights.Remove(remove.Value);
|
||||
});
|
||||
|
||||
Targets.Add(new EntityTargets());
|
||||
|
|
@ -195,7 +196,7 @@ public class Zone
|
|||
case AsteroidBeltData belt:
|
||||
// Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by
|
||||
// the rule generation uses, and keeps it.
|
||||
FieldKinds.Ensure(body.Key, belt, cache);
|
||||
FieldKinds.Ensure(body.Key.Value, belt, cache, GalaxyZone?.Province?.Type ?? default);
|
||||
AsteroidBelts[body.Key] = new AsteroidBelt(belt);
|
||||
break;
|
||||
case SunData sun:
|
||||
|
|
@ -221,7 +222,7 @@ public class Zone
|
|||
foreach (var belt in AsteroidBelts)
|
||||
{
|
||||
var rail = Planets[belt.Key].Orbit.Key;
|
||||
beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance);
|
||||
beltExtent[rail] = AsteroidBelt.Extent(belt.Value.Data, Orbits[rail].Data.Distance);
|
||||
}
|
||||
var wellNodes = new List<WellNode>();
|
||||
foreach (var orbit in Orbits)
|
||||
|
|
@ -266,14 +267,18 @@ public class Zone
|
|||
foreach (var wear in pack.ChunkWear)
|
||||
_wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil };
|
||||
|
||||
var ordinal = 0u;
|
||||
foreach (var entityPack in pack.Entities)
|
||||
{
|
||||
ordinal++;
|
||||
var entity = EntitySerializer.Unpack(_itemManager, this, entityPack);
|
||||
var ship = entity as Ship;
|
||||
Admit(entity, piloted: ship is {IsPlayerShip: false});
|
||||
// Generation placement, not admission: a ship packed at the origin is scattered across the zone.
|
||||
// Generation placement, not admission: a ship packed at the origin is scattered across the zone, on the
|
||||
// zone's own dice, so nothing drawn from the item stream can move it.
|
||||
if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1)
|
||||
ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f));
|
||||
ship.Position = SimulationDice.For(CombatSeed, SimulationDice.ScatterStream, ordinal)
|
||||
.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f));
|
||||
}
|
||||
|
||||
// TODO: Associate planets with stored entities for planetary colonies
|
||||
|
|
@ -294,11 +299,13 @@ public class Zone
|
|||
if (piloted) Agents.Add(CreateAgent((Ship) entity, task));
|
||||
}
|
||||
|
||||
// The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and
|
||||
// nothing changes, when the entity is not a member. The caller deactivates; Remove does not.
|
||||
// The one removal from this zone (death, dock, jump): membership and its death watch leave together, and so does the
|
||||
// entity's give, which ends GiverGone unless a dock already ended it. False, and nothing changes, when the entity is
|
||||
// not a member. The caller deactivates; Remove does not.
|
||||
public bool Remove(Entity entity)
|
||||
{
|
||||
if (!_entities.Remove(entity)) return false;
|
||||
CargoTransfer.End(entity, GiveEnd.GiverGone);
|
||||
_deathWatches.Remove(entity, out var watch);
|
||||
watch.Dispose();
|
||||
foreach (var floating in FloatingItems)
|
||||
|
|
@ -306,9 +313,11 @@ public class Zone
|
|||
return true;
|
||||
}
|
||||
|
||||
private static object SideOf(Ship ship) => (object) ship.Faction ?? ship;
|
||||
|
||||
private Agent CreateAgent(Ship ship, AgentTask stated)
|
||||
{
|
||||
var key = ship.Faction ?? Unaffiliated;
|
||||
var key = SideOf(ship);
|
||||
if (!_flights.TryGetValue(key, out var flight))
|
||||
_flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings);
|
||||
var id = NextAgentId();
|
||||
|
|
@ -361,8 +370,19 @@ public class Zone
|
|||
}
|
||||
|
||||
foreach (var flight in _flights.Values) flight.Update(deltaTime);
|
||||
foreach(var agent in Agents.ToArray())
|
||||
// Every pilot's command is applied by PilotApplier, once per step. Entities with no agent (the player, a scripted
|
||||
// pilot, a lone ship) first, as the player's input lands before the step; then each agent applies right after it
|
||||
// decides, so the next agent sees it as it saw the direct writes this replaced.
|
||||
var agents = Agents.ToArray();
|
||||
var piloted = new HashSet<Entity>();
|
||||
foreach (var agent in agents) piloted.Add(agent.Ship);
|
||||
foreach (var entity in Entities.ToArray())
|
||||
if (!piloted.Contains(entity)) PilotApplier.Apply(entity);
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
agent.Update(deltaTime);
|
||||
PilotApplier.Apply(agent.Ship);
|
||||
}
|
||||
|
||||
var entities = Entities.ToArray();
|
||||
foreach (var entity in entities) entity.PublishInbox();
|
||||
|
|
@ -370,6 +390,7 @@ public class Zone
|
|||
foreach (var entity in entities) entity.Sense();
|
||||
_departures.Clear();
|
||||
|
||||
CargoTransfer.Step(this, deltaTime);
|
||||
StepMines(deltaTime);
|
||||
StepClouds(deltaTime);
|
||||
StepFloatingItems(deltaTime);
|
||||
|
|
@ -888,8 +909,10 @@ public class AsteroidBelt
|
|||
}
|
||||
|
||||
// What the belt's rail adds to its parent's L_F (WellTree): an annulus reaches its outermost rock, whose period reads the
|
||||
// field there; a swarm riding its rail reaches only the rail, whose period every rock shares.
|
||||
public float Extent(float railDistance) => Data.RidesRail ? railDistance : Math.Max(Radius, railDistance);
|
||||
// field there; a swarm riding its rail reaches only the rail, whose period every rock shares. The one belt extent rule:
|
||||
// Zone, ZoneGenerator's clearance tree and the station tests all read it.
|
||||
public static float Extent(AsteroidBeltData data, float railDistance) =>
|
||||
data.RidesRail ? railDistance : Math.Max(data.Asteroids.Max(a => a.Distance), railDistance);
|
||||
|
||||
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.
|
||||
// The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from
|
||||
|
|
|
|||
|
|
@ -81,8 +81,7 @@ public abstract class BodyData
|
|||
[JsonProperty("mass"), Key(3)]
|
||||
public float Mass = 0;
|
||||
|
||||
[CultReference(typeof(SimpleCommodityData), many: true), JsonProperty("resources"), Key(4)]
|
||||
public Dictionary<CultRecordRef<SimpleCommodityData>, float> Resources = new Dictionary<CultRecordRef<SimpleCommodityData>, float>();
|
||||
// MessagePack key 4 belonged to the removed stored Resources; do not reuse it. A belt's yield is derived by FieldKinds.Yield.
|
||||
|
||||
[JsonProperty("bodyRadiusMul")] [Key(5)]
|
||||
public float BodyRadiusMultiplier = 1;
|
||||
|
|
@ -114,7 +113,8 @@ public class AsteroidBeltData : BodyData
|
|||
}
|
||||
|
||||
// Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's
|
||||
// reflectivity comes from its field's kind; Cut 5 adds the yield table.
|
||||
// reflectivity comes from its field's kind; its yields (ore geography, ruling ores-are-regional) are the ores a belt of
|
||||
// this kind holds before a province weights them. FieldKinds.Yield is the one reader.
|
||||
[CultDocument("aetheria.fieldkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class FieldKindData
|
||||
{
|
||||
|
|
@ -129,40 +129,308 @@ public class FieldKindData
|
|||
// How often generation picks this kind, relative to the others (FieldKinds.Assign). Zero never picks it.
|
||||
[JsonProperty("generationWeight"), Key(2)]
|
||||
public float GenerationWeight = 1;
|
||||
|
||||
// The ores a belt of this kind holds. Empty on a kind from before this key (MessagePack leaves a missing trailing key at its default).
|
||||
[JsonProperty("yields"), Key(3)]
|
||||
public List<FieldYield> Yields = new List<FieldYield>();
|
||||
}
|
||||
|
||||
// Mining Cut 3 (Q16 B): the one owner of which kind a belt is.
|
||||
// One ore a kind of field holds: Abundance of Commodity before the belt's province weights it. A Regional ore is held only
|
||||
// where the province lists it (ruling ores-are-regional: some ores exist only in some provinces); any other entry is held
|
||||
// everywhere, scaled by the province's factor for it.
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class FieldYield
|
||||
{
|
||||
[JsonProperty("commodity"), Key(0)]
|
||||
public CultRecordRef<SimpleCommodityData> Commodity;
|
||||
|
||||
[JsonProperty("abundance"), Key(1)]
|
||||
public float Abundance;
|
||||
|
||||
[JsonProperty("regional"), Key(2)]
|
||||
public bool Regional;
|
||||
}
|
||||
|
||||
// Ore geography (rulings provinces-within-regions, ores-are-regional): a kind of ground, authored. A region deals its
|
||||
// provinces' types from a deck holding GenerationWeight cards of each type (ruling province-types-dealt-per-region);
|
||||
// a province's type weights which kinds its belts draw and which ores a kind holds there.
|
||||
[CultDocument("aetheria.provincedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class ProvinceData
|
||||
{
|
||||
[CultName, JsonProperty("name"), Key(0)]
|
||||
public string Name = "";
|
||||
|
||||
// The number of cards of this type in a region's deck. Zero is never dealt.
|
||||
[JsonProperty("generationWeight"), Key(1)]
|
||||
public int GenerationWeight = 1;
|
||||
|
||||
// Multiplies the kind's own GenerationWeight in a belt's draw; a kind not listed is multiplied by one.
|
||||
[JsonProperty("kinds"), Key(2)]
|
||||
public List<ProvinceKindWeight> Kinds = new List<ProvinceKindWeight>();
|
||||
|
||||
// Multiplies an ore's Abundance wherever it is held; an ore not listed is multiplied by one, or by zero when the
|
||||
// kind's entry for it is Regional.
|
||||
[JsonProperty("ores"), Key(3)]
|
||||
public List<ProvinceOre> Ores = new List<ProvinceOre>();
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class ProvinceKindWeight
|
||||
{
|
||||
[JsonProperty("kind"), Key(0)]
|
||||
public CultRecordRef<FieldKindData> Kind;
|
||||
|
||||
[JsonProperty("weight"), Key(1)]
|
||||
public float Weight = 1;
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class ProvinceOre
|
||||
{
|
||||
[JsonProperty("commodity"), Key(0)]
|
||||
public CultRecordRef<SimpleCommodityData> Commodity;
|
||||
|
||||
[JsonProperty("weight"), Key(1)]
|
||||
public float Weight = 1;
|
||||
}
|
||||
|
||||
// Mining Cut 3 (Q16 B): the one owner of which kind a belt is. Ore geography: and of what it yields.
|
||||
public static class FieldKinds
|
||||
{
|
||||
// A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same
|
||||
// catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order.
|
||||
// Unset when the catalog has no kind with positive weight.
|
||||
public static CultRecordRef<FieldKindData> Assign(CultRecordKey field, CultCache catalog)
|
||||
// The one weighted pick: an index into weights, chosen by a stable hash of the site (the same site and the same
|
||||
// weights always give the same index), never one whose weight is not positive. -1 when no weight is positive.
|
||||
internal static int Pick(string site, double[] weights)
|
||||
{
|
||||
var pick = site.StableHash() / 4294967296.0 * weights.Sum();
|
||||
var last = -1;
|
||||
for (var i = 0; i < weights.Length; i++)
|
||||
{
|
||||
if (weights[i] <= 0) continue;
|
||||
last = i;
|
||||
if (pick < weights[i]) return i;
|
||||
pick -= weights[i];
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
// A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site, catalog and province
|
||||
// type always give the same kind. A kind's weight is its GenerationWeight times its province's factor for it; a belt with
|
||||
// no province type (unset, or naming nothing the catalog holds) draws unweighted. Generation names a belt by the zone's
|
||||
// seed and the belt's place in generation order (never by the key the cache mints, which differs every run); a belt saved
|
||||
// without a kind is named by its key. Kinds are ordered by key so the pick does not depend on store order.
|
||||
// Unset when the catalog has no kind with positive weight.
|
||||
public static CultRecordRef<FieldKindData> Assign(string site, CultCache catalog, CultRecordRef<ProvinceData> province)
|
||||
{
|
||||
var type = catalog.Get(province);
|
||||
var kinds = catalog.GetAll<FieldKindData>()
|
||||
.Where(kind => kind.GenerationWeight > 0f)
|
||||
.Select(kind => (Key: catalog.RefOf(kind).Key, Weight: kind.GenerationWeight))
|
||||
.Select(kind =>
|
||||
{
|
||||
var key = catalog.RefOf(kind).Key;
|
||||
var factor = Factor(type?.Kinds.Where(entry => entry.Kind.Key.Equals(key)).Select(entry => (double) entry.Weight), 1.0);
|
||||
// Both must be positive: two negatives do not multiply into a weight.
|
||||
return (Key: key, Weight: kind.GenerationWeight > 0f && factor > 0.0 ? kind.GenerationWeight * factor : 0.0);
|
||||
})
|
||||
.Where(kind => kind.Weight > 0.0)
|
||||
.OrderBy(kind => kind.Key.Value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (kinds.Length == 0) return default;
|
||||
|
||||
var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight);
|
||||
foreach (var kind in kinds)
|
||||
{
|
||||
if (pick < kind.Weight) return new CultRecordRef<FieldKindData>(kind.Key);
|
||||
pick -= kind.Weight;
|
||||
}
|
||||
return new CultRecordRef<FieldKindData>(kinds[kinds.Length - 1].Key);
|
||||
return new CultRecordRef<FieldKindData>(kinds[Pick(site, kinds.Select(kind => kind.Weight).ToArray())].Key);
|
||||
}
|
||||
|
||||
// A province's factor for one entry: the sum of its listings, or the default when it lists none.
|
||||
private static double Factor(IEnumerable<double> listed, double unlisted)
|
||||
{
|
||||
var weights = listed?.ToArray();
|
||||
return weights == null || weights.Length == 0 ? unlisted : weights.Sum();
|
||||
}
|
||||
|
||||
// Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says.
|
||||
// Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule.
|
||||
public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache)
|
||||
public static void Ensure(string site, AsteroidBeltData belt, CultCache cache, CultRecordRef<ProvinceData> province)
|
||||
{
|
||||
if (belt.Kind.IsSet()) return;
|
||||
belt.Kind = Assign(field, cache);
|
||||
belt.Kind = Assign(site, cache, province);
|
||||
if (belt.Kind.IsSet()) cache.Upsert(belt);
|
||||
}
|
||||
|
||||
// What a belt of this kind holds in a zone of this province: each of the kind's yields, its Abundance times the
|
||||
// province's factor for the ore (unlisted: one, or zero when the yield is Regional), summed per commodity, in commodity
|
||||
// key order, zero amounts dropped. Derived on every call, never stored. Empty for an unset kind.
|
||||
public static (CultRecordRef<SimpleCommodityData> Commodity, float Amount)[] Yield(CultRecordRef<FieldKindData> kind,
|
||||
CultRecordRef<ProvinceData> province, CultCache catalog)
|
||||
{
|
||||
var data = catalog.Get(kind);
|
||||
if (data == null) return Array.Empty<(CultRecordRef<SimpleCommodityData>, float)>();
|
||||
var type = catalog.Get(province);
|
||||
return data.Yields
|
||||
.Select(yield => (yield.Commodity, Amount: yield.Abundance * Factor(
|
||||
type?.Ores.Where(ore => ore.Commodity.Key.Equals(yield.Commodity.Key)).Select(ore => (double) ore.Weight), yield.Regional ? 0.0 : 1.0)))
|
||||
.GroupBy(yield => yield.Commodity.Key.Value, StringComparer.Ordinal)
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group => (group.First().Commodity, Amount: (float) group.Sum(yield => yield.Amount)))
|
||||
.Where(yield => yield.Amount > 0f)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// Ore geography (rulings provinces-within-regions, boss-zone-is-near-side, province-partition-by-size, province-types-dealt-per-region).
|
||||
// Pure functions of the zone graph, the run seed and the catalog: nothing here draws from Galaxy's Random stream, stores membership
|
||||
// or reads who owns what. Galaxy builds provinces once, in its generation and save constructors, and nothing re-partitions them.
|
||||
public static class Provinces
|
||||
{
|
||||
// Zones per province when a settings object does not say (SectorGenerationSettings.ProvinceZones): calibration, not a rule.
|
||||
public const int DefaultZonesPerProvince = 8;
|
||||
|
||||
// A boss zone sits on the near side of its border: its crossing links are its links to zones in the component of exit in
|
||||
// the graph without it. The one definition, read by Regions (which cuts them) and ExtraExits (which prunes all but one).
|
||||
// exit unset (-1) gives none, and a boss zone that is the exit has none. Ordered by boss, then far zone.
|
||||
public static (int Boss, int Far)[] CrossingLinks(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit)
|
||||
{
|
||||
var links = new List<(int Boss, int Far)>();
|
||||
if (exit >= 0)
|
||||
foreach (var boss in bossZones.Distinct().Where(boss => boss != exit).OrderBy(boss => boss))
|
||||
{
|
||||
var exitSide = Flood(adjacency, exit, (a, b) => b != boss);
|
||||
foreach (var next in adjacency[boss].OrderBy(next => next))
|
||||
if (exitSide[next]) links.Add((boss, next));
|
||||
}
|
||||
return links.ToArray();
|
||||
}
|
||||
|
||||
// The crossing links to prune so each boss zone keeps one exit: all of them but the link to the boss zone's successor on
|
||||
// exitPath (the zones from the entrance to the exit), which is the link the run goes through. The generator's exit path
|
||||
// passes through every boss zone by one of its crossing links; a boss zone off exitPath, last on it, or followed by a zone
|
||||
// it has no crossing link to breaks that, and throws naming the boss zone rather than guessing which link to keep.
|
||||
public static (int Boss, int Far)[] ExtraExits(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit, int[] exitPath)
|
||||
{
|
||||
var extra = new List<(int Boss, int Far)>();
|
||||
foreach (var links in CrossingLinks(adjacency, bossZones, exit).GroupBy(link => link.Boss))
|
||||
{
|
||||
var at = Array.IndexOf(exitPath, links.Key);
|
||||
var kept = at >= 0 && at + 1 < exitPath.Length ? exitPath[at + 1] : -1;
|
||||
if (!links.Any(link => link.Far == kept))
|
||||
throw new InvalidOperationException($"boss zone {links.Key} has no crossing link to its successor on the exit path, so no exit to keep");
|
||||
extra.AddRange(links.Where(link => link.Far != kept));
|
||||
}
|
||||
return extra.ToArray();
|
||||
}
|
||||
|
||||
// Each zone's region, named by the region's least zone index. Cutting every boss zone's crossing links (CrossingLinks)
|
||||
// leaves the regions as the connected components. Every zone, boss zones included, is in exactly one.
|
||||
public static int[] Regions(int[][] adjacency, IReadOnlyCollection<int> bossZones, int exit)
|
||||
{
|
||||
var cut = CrossingLinks(adjacency, bossZones, exit).Select(link => (Math.Min(link.Boss, link.Far), Math.Max(link.Boss, link.Far))).ToHashSet();
|
||||
|
||||
var regionOf = new int[adjacency.Length];
|
||||
Array.Fill(regionOf, -1);
|
||||
for (var start = 0; start < adjacency.Length; start++)
|
||||
{
|
||||
if (regionOf[start] >= 0) continue;
|
||||
var members = Flood(adjacency, start, (a, b) => !cut.Contains((Math.Min(a, b), Math.Max(a, b))));
|
||||
for (var i = 0; i < members.Length; i++) if (members[i]) regionOf[i] = start;
|
||||
}
|
||||
return regionOf;
|
||||
}
|
||||
|
||||
// Each zone's province, named by the province's seed zone index. A region of size s takes max(1, round(s / zonesPerProvince))
|
||||
// seeds: the first is the zone with the least hash of the run seed, each next the zone farthest (in-region hops) from the
|
||||
// seeds chosen so far, ties by that hash; then every zone joins the nearest seed, ties to the earlier seed. A province is
|
||||
// contiguous and never crosses a region.
|
||||
public static int[] Partition(int[][] adjacency, int[] regionOf, uint runSeed, int zonesPerProvince)
|
||||
{
|
||||
var seedOf = new int[adjacency.Length];
|
||||
uint Hash(int index) => $"{runSeed}:province-seed:{index}".StableHash();
|
||||
foreach (var region in Enumerable.Range(0, adjacency.Length).GroupBy(zone => regionOf[zone]).OrderBy(group => group.Key))
|
||||
{
|
||||
var zones = region.ToArray();
|
||||
var count = Math.Min(zones.Length, Math.Max(1, (int) Math.Round((double) zones.Length / Math.Max(1, zonesPerProvince), MidpointRounding.AwayFromZero)));
|
||||
var seeds = new List<int> { zones.OrderBy(Hash).ThenBy(zone => zone).First() };
|
||||
while (seeds.Count < count)
|
||||
{
|
||||
var hops = Grow(adjacency, regionOf, seeds.ToArray()).Hops;
|
||||
seeds.Add(zones.Where(zone => !seeds.Contains(zone)).OrderByDescending(zone => hops[zone]).ThenBy(Hash).ThenBy(zone => zone).First());
|
||||
}
|
||||
var owner = Grow(adjacency, regionOf, seeds.ToArray()).Owner;
|
||||
foreach (var zone in zones) seedOf[zone] = seeds[owner[zone]];
|
||||
}
|
||||
return seedOf;
|
||||
}
|
||||
|
||||
// A region's hand of province types, one per province in ascending seed-zone order. Types are dealt without replacement from
|
||||
// a deck of GenerationWeight cards per type (catalog types in key order), each draw one weighted pick over the cards remaining
|
||||
// at a site named by the run seed, the region and the draw's place; the deck is restored whole when it runs out. An empty
|
||||
// deck deals every province an unset type.
|
||||
public static CultRecordRef<ProvinceData>[] Deal(int regionKey, int seedCount, uint runSeed, CultCache catalog)
|
||||
{
|
||||
var types = catalog.GetAll<ProvinceData>()
|
||||
.Where(type => type.GenerationWeight > 0)
|
||||
.Select(type => (Ref: catalog.RefOf(type), Cards: type.GenerationWeight))
|
||||
.OrderBy(type => type.Ref.Key.Value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var hand = new CultRecordRef<ProvinceData>[seedCount];
|
||||
if (types.Length == 0) return hand;
|
||||
|
||||
var remaining = types.Select(type => type.Cards).ToArray();
|
||||
for (var n = 0; n < seedCount; n++)
|
||||
{
|
||||
if (remaining.Sum() == 0) remaining = types.Select(type => type.Cards).ToArray();
|
||||
var card = FieldKinds.Pick($"{runSeed}:province-deal:{regionKey}:{n}", remaining.Select(cards => (double) cards).ToArray());
|
||||
remaining[card]--;
|
||||
hand[n] = types[card].Ref;
|
||||
}
|
||||
return hand;
|
||||
}
|
||||
|
||||
// Every zone reachable from start over the links the filter allows.
|
||||
private static bool[] Flood(int[][] adjacency, int start, Func<int, int, bool> allowed)
|
||||
{
|
||||
var reached = new bool[adjacency.Length];
|
||||
var stack = new Stack<int>();
|
||||
reached[start] = true;
|
||||
stack.Push(start);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var zone = stack.Pop();
|
||||
foreach (var next in adjacency[zone])
|
||||
if (!reached[next] && allowed(zone, next))
|
||||
{
|
||||
reached[next] = true;
|
||||
stack.Push(next);
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
// Breadth-first growth from seeds inside their regions, level by level: each zone's hops to the nearest seed and the rank
|
||||
// (place in seeds) of the earliest seed among those that reach it first.
|
||||
private static (int[] Owner, int[] Hops) Grow(int[][] adjacency, int[] regionOf, int[] seeds)
|
||||
{
|
||||
var owner = new int[adjacency.Length];
|
||||
var hops = new int[adjacency.Length];
|
||||
Array.Fill(owner, -1);
|
||||
for (var rank = 0; rank < seeds.Length; rank++)
|
||||
{
|
||||
owner[seeds[rank]] = rank;
|
||||
hops[seeds[rank]] = 0;
|
||||
}
|
||||
var frontier = seeds.ToList();
|
||||
for (var level = 1; frontier.Count > 0; level++)
|
||||
{
|
||||
var claims = new Dictionary<int, int>();
|
||||
foreach (var zone in frontier)
|
||||
foreach (var next in adjacency[zone])
|
||||
if (owner[next] < 0 && regionOf[next] == regionOf[zone])
|
||||
claims[next] = claims.TryGetValue(next, out var rank) ? Math.Min(rank, owner[zone]) : owner[zone];
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
owner[claim.Key] = claim.Value;
|
||||
hops[claim.Key] = level;
|
||||
}
|
||||
frontier = claims.Keys.OrderBy(zone => zone).ToList();
|
||||
}
|
||||
return (owner, hops);
|
||||
}
|
||||
}
|
||||
|
||||
[CultDocument("aetheria.gasgiantdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ public static class ZoneGenerator
|
|||
}
|
||||
pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList();
|
||||
|
||||
pack.Planets = planets.Where(p=>!p.Empty).Select(planet =>
|
||||
var beltOf = new Dictionary<GeneratorPlanet, AsteroidBeltData>();
|
||||
pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) =>
|
||||
{
|
||||
BodyType bodyType = planet.Belt ? BodyType.Asteroid :
|
||||
planet.Mass > zoneSettings.SunMass ? BodyType.Sun :
|
||||
|
|
@ -149,6 +150,7 @@ public static class ZoneGenerator
|
|||
planetData.Orbit = cache.RefOf(orbitMap[planet]);
|
||||
if (planetData is AsteroidBeltData beltData)
|
||||
{
|
||||
beltOf[planet] = beltData;
|
||||
beltData.Asteroids =
|
||||
Enumerable.Range(0, (int) zoneSettings.AsteroidCount.Evaluate(beltData.Mass * orbitMap[planet].Distance))
|
||||
.Select(_ => new Asteroid
|
||||
|
|
@ -211,18 +213,21 @@ public static class ZoneGenerator
|
|||
var bodyKey = cache.Upsert(planetData).Key;
|
||||
planetData.Name = bodyKey.Value.Substring(0, 8);
|
||||
// Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it.
|
||||
if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache);
|
||||
if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache, galaxyZone.Province?.Type ?? default);
|
||||
return cache.Upsert(planetData);
|
||||
}).ToList();
|
||||
|
||||
// Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its
|
||||
// rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet,
|
||||
// so it takes no slot and no station candidacy sees it.
|
||||
// so it takes no slot and no station candidacy sees it. A standing binary leaves a bodyless node (Empty) that keeps its
|
||||
// pair's mass and a parent, so the Empty guard is what keeps a swarm off a rail no body is on.
|
||||
var swarmRandom = new Random(seed ^ TrojanStream);
|
||||
var swarmHosts = new HashSet<GeneratorPlanet>();
|
||||
foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0
|
||||
&& p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass))
|
||||
{
|
||||
if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue;
|
||||
swarmHosts.Add(host);
|
||||
var hostOrbit = orbitMap[host];
|
||||
var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
|
||||
pack.Orbits.Add(cache.Upsert(rail));
|
||||
|
|
@ -234,15 +239,46 @@ public static class ZoneGenerator
|
|||
BodyData swarmBody = swarm;
|
||||
var swarmKey = cache.Upsert(swarmBody).Key;
|
||||
swarm.Name = swarmKey.Value.Substring(0, 8);
|
||||
FieldKinds.Ensure(swarmKey, swarm, cache);
|
||||
FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache, galaxyZone.Province?.Type ?? default);
|
||||
pack.Planets.Add(cache.Upsert(swarmBody));
|
||||
}
|
||||
|
||||
// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin): a belt candidate that kept its body carries
|
||||
// an annulus on a copy of its rail that fills its slot less a lane round the body, rocks on both sides. The lane is sized
|
||||
// against the body the player sees (LaneBodyRadii body radii a side), and only a body whose slot holds a band at least
|
||||
// that wide on both sides gets one. One belt shape per parent, and none on a swarm host. Drawn from a stream of its own,
|
||||
// so no other draw in the zone moves; its rocks sit inside the host's slot, below the next sibling's spacing, so they
|
||||
// raise no L_F and no support: the lane moves no well, period or height.
|
||||
var laneRandom = new Random(seed ^ LaneStream);
|
||||
var shaped = new HashSet<GeneratorPlanet>(planets.Where(p => p.Belt).Select(p => p.Parent));
|
||||
foreach (var host in planets.Where(p => p.BeltCandidate && !p.Belt))
|
||||
{
|
||||
if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue;
|
||||
var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass);
|
||||
if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) || !LaneHost(host, lane)) continue;
|
||||
shaped.Add(host.Parent);
|
||||
var hostOrbit = orbitMap[host];
|
||||
var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
|
||||
pack.Orbits.Add(cache.Upsert(rail));
|
||||
var laned = new AsteroidBeltData
|
||||
{
|
||||
Orbit = cache.RefOf(rail),
|
||||
Asteroids = LaneRocks(zoneSettings, ref laneRandom, host.Mass, rail.Distance, lane, host.SlotInner, host.SlotOuter)
|
||||
};
|
||||
BodyData lanedBody = laned;
|
||||
var lanedKey = cache.Upsert(lanedBody).Key;
|
||||
laned.Name = lanedKey.Value.Substring(0, 8);
|
||||
FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache, galaxyZone.Province?.Type ?? default);
|
||||
pack.Planets.Add(cache.Upsert(lanedBody));
|
||||
}
|
||||
|
||||
// The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's
|
||||
// support, or its Roche margin if more.
|
||||
// support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm and lane rails
|
||||
// are left out: a swarm's extent is its own rail, a copy of its host's, and a lane's outer rock sits inside its host's slot,
|
||||
// below the next sibling's spacing, so neither raises an L_F or a support.
|
||||
var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key,
|
||||
p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass,
|
||||
p.Belt ? p.Distance + p.BeltHalfWidth : p.Distance, p.FixedPosition)).ToList();
|
||||
p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList();
|
||||
var wells = WellTree.Build(wellNodes, planetSettings, pack.Radius);
|
||||
float Extent(GeneratorPlanet p)
|
||||
{
|
||||
|
|
@ -383,7 +419,8 @@ public static class ZoneGenerator
|
|||
if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; }
|
||||
var orbit = selectedStationOrbits[i];
|
||||
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
|
||||
var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout();
|
||||
var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(
|
||||
StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log));
|
||||
if (station == null)
|
||||
{
|
||||
itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!");
|
||||
|
|
@ -404,7 +441,8 @@ public static class ZoneGenerator
|
|||
var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f));
|
||||
|
||||
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
|
||||
var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout();
|
||||
var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(
|
||||
StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log));
|
||||
if (station == null)
|
||||
{
|
||||
if (isTutorialEntrance)
|
||||
|
|
@ -484,12 +522,6 @@ public static class ZoneGenerator
|
|||
public static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>
|
||||
factions.Where(f => f != owner && f != nearest).ToArray();
|
||||
|
||||
// static float ResourceValue(ref Random random, ZoneGenerationSettings settings, SimpleCommodityData resource, float density)
|
||||
// {
|
||||
// return random.NextPowerDistribution(resource.Minimum, resource.Maximum, resource.Exponent,
|
||||
// 1 / lerp(settings.ResourceDensityMinimum, settings.ResourceDensityMaximum, density));
|
||||
// }
|
||||
|
||||
public static GeneratorPlanet[] GenerateEntities(ZoneGenerationSettings settings, PlanetSettings planetSettings, ref Random random, float mass, float radius, float2 fixedPosition,
|
||||
float rootReach)
|
||||
{
|
||||
|
|
@ -595,8 +627,11 @@ public static class ZoneGenerator
|
|||
: root.AllPlanets().Where(p => p != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0);
|
||||
|
||||
foreach(var planet in beltCandidates.Reverse())
|
||||
{
|
||||
planet.BeltCandidate = true;
|
||||
if (random.NextFloat() < settings.BeltProbability && !planet.Parent.Children.Any(p=>p.Belt))
|
||||
planet.Belt = true;
|
||||
}
|
||||
|
||||
var totalMass = root.AllPlanets().Sum(p => p.Mass);
|
||||
var rootMass = root.Mass;
|
||||
|
|
@ -630,6 +665,41 @@ public static class ZoneGenerator
|
|||
};
|
||||
return rocks;
|
||||
}
|
||||
|
||||
private const uint LaneStream = 0x85EBCA6Bu;
|
||||
|
||||
// Whether a placed body can carry a lane of half-width `lane`: its slot holds a band at least that wide on both sides
|
||||
// (ruling lanes-two-sided-visual-margin), and it is not its parent's outermost child (ruling lane-not-outermost). Place
|
||||
// stretches the chain to the window, so an outermost child's outer side is the window, not a sibling's spacing; a lane's outer
|
||||
// band there would reach past every sibling's extent and raise its parent's support, and lanes move nothing else in the zone.
|
||||
// An only child is its parent's outermost.
|
||||
public static bool LaneHost(GeneratorPlanet host, float lane) =>
|
||||
host.Parent.Children.Any(c => c.Distance > host.Distance)
|
||||
&& host.SlotInner <= host.Distance - 2 * lane && host.SlotOuter >= host.Distance + 2 * lane;
|
||||
|
||||
// A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane]
|
||||
// and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width.
|
||||
public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer)
|
||||
{
|
||||
float Sq(float x) => x * x;
|
||||
var innerArea = Sq(distance - lane) - Sq(inner);
|
||||
var share = innerArea / (innerArea + Sq(outer) - Sq(distance + lane));
|
||||
var rocks = new Asteroid[(int) settings.AsteroidCount.Evaluate(mass * distance)];
|
||||
for (var i = 0; i < rocks.Length; i++)
|
||||
{
|
||||
var innerBand = random.NextFloat() < share;
|
||||
var a = innerBand ? inner : distance + lane;
|
||||
var b = innerBand ? distance - lane : outer;
|
||||
rocks[i] = new Asteroid
|
||||
{
|
||||
Distance = (a + b) / 2 + random.NextFloat() * (random.NextFloat() - .5f) * (b - a),
|
||||
Phase = random.NextFloat(),
|
||||
Size = random.NextFloat(),
|
||||
RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
|
||||
};
|
||||
}
|
||||
return rocks;
|
||||
}
|
||||
public const float CuspMargin = 1.5f;
|
||||
public const float SiblingSpacing = .5f;
|
||||
|
||||
|
|
@ -688,15 +758,18 @@ public static class ZoneGenerator
|
|||
var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f;
|
||||
for (var k = 0; k < children.Count; k++)
|
||||
children[k].Distance = positions[k] * pow(g, k);
|
||||
// A belt is an interval inside its slot: inner edge outside the lower bound and the previous sibling's spacing, outer edge
|
||||
// inside the next sibling's spacing and the window; the authored width is the widest it may be.
|
||||
// Every child's slot: from the lower bound and the previous sibling's spacing out to the next sibling's spacing and the
|
||||
// window. Place is the only writer of a slot; a belt fills its own, and the lane pass reads a body's. A belt is an
|
||||
// interval inside its slot, the authored width the widest it may be.
|
||||
var outer = 0f;
|
||||
for (var k = 0; k < children.Count; k++)
|
||||
{
|
||||
var c = children[k];
|
||||
c.SlotInner = Math.Max(lo, outer * q);
|
||||
c.SlotOuter = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper;
|
||||
if (!c.Belt) { outer = c.Distance; continue; }
|
||||
var inner = Math.Max(lo, outer * q);
|
||||
var outerLimit = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper;
|
||||
var inner = c.SlotInner;
|
||||
var outerLimit = c.SlotOuter;
|
||||
c.Distance = (inner + outerLimit) / 2;
|
||||
var half = Math.Max(0f, Math.Min(c.Settings.AsteroidBeltWidth.Evaluate(c.Distance), outerLimit - inner) / 2);
|
||||
c.BeltHalfWidth = half;
|
||||
|
|
@ -762,6 +835,11 @@ public class GeneratorPlanet
|
|||
public float BeltHalfWidth;
|
||||
public bool Empty = false;
|
||||
public bool Belt = false;
|
||||
// The interval Place gives a child between its previous sibling's spacing and the next sibling's spacing or the window; zero
|
||||
// on a ring member, which has no slot.
|
||||
public float SlotInner, SlotOuter;
|
||||
// A childless light body, not a binary or root rosette member, that could have become a belt (set by GenerateEntities).
|
||||
public bool BeltCandidate;
|
||||
// A member of a rosette's ring, which Place puts on one orbit round its bodyless parent.
|
||||
public bool Ring = false;
|
||||
public List<GeneratorPlanet> Children = new List<GeneratorPlanet>(); // Planets orbiting this one are referred to as children
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public class MainMenu : MonoBehaviour
|
|||
public float FadeAlphaExponent = 2;
|
||||
public float FadePositionExponent = 2;
|
||||
|
||||
private (PropertiesPanel panel, CanvasGroup group) _currentMenu, _nextMenu;
|
||||
private (PropertiesPanel panel, CanvasGroup group, PropertiesPanel detail) _currentMenu, _nextMenu;
|
||||
private bool _fadeFromRight;
|
||||
private float _fadeLerp;
|
||||
private bool _fading;
|
||||
|
|
@ -38,10 +38,14 @@ public class MainMenu : MonoBehaviour
|
|||
_panelPosition = PanelPrototype.transform.position;
|
||||
|
||||
var panel1 = PanelPrototype.Instantiate<PropertiesPanel>();
|
||||
_currentMenu = (panel1, panel1.GetComponent<CanvasGroup>());
|
||||
var detail1 = PanelPrototype.Instantiate<PropertiesPanel>();
|
||||
ScenarioPicker.Dock(detail1, panel1);
|
||||
_currentMenu = (panel1, panel1.GetComponent<CanvasGroup>(), detail1);
|
||||
|
||||
var panel2 = PanelPrototype.Instantiate<PropertiesPanel>();
|
||||
_nextMenu = (panel2, panel2.GetComponent<CanvasGroup>());
|
||||
var detail2 = PanelPrototype.Instantiate<PropertiesPanel>();
|
||||
ScenarioPicker.Dock(detail2, panel2);
|
||||
_nextMenu = (panel2, panel2.GetComponent<CanvasGroup>(), detail2);
|
||||
|
||||
_currentMenu.panel.gameObject.SetActive(false);
|
||||
//_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves");
|
||||
|
|
@ -74,7 +78,7 @@ public class MainMenu : MonoBehaviour
|
|||
}
|
||||
}
|
||||
|
||||
private string TitleSubtitle(string title, string subtitle) => $"{title}\n<smallcaps><size=50%>{subtitle}";
|
||||
public static string TitleSubtitle(string title, string subtitle) => $"{title}\n<smallcaps><size=50%>{subtitle}";
|
||||
|
||||
private void Fade(bool fromRight)
|
||||
{
|
||||
|
|
@ -87,10 +91,18 @@ public class MainMenu : MonoBehaviour
|
|||
|
||||
private static bool _exclusionsShown;
|
||||
|
||||
private void ShowMain()
|
||||
// Starts the next screen on an empty panel with a title and its scenario detail pane hidden and empty.
|
||||
private void Next(string title)
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
_nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus");
|
||||
_nextMenu.panel.Title.text = title;
|
||||
_nextMenu.detail.Clear();
|
||||
_nextMenu.detail.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void ShowMain()
|
||||
{
|
||||
Next(TitleSubtitle("aetheria", "terminus"));
|
||||
if (ActionGameManager.CurrentGalaxy == null)
|
||||
{
|
||||
// No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as
|
||||
|
|
@ -131,31 +143,18 @@ public class MainMenu : MonoBehaviour
|
|||
// to set up a run and 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);
|
||||
Next(TitleSubtitle("new game", "scenarios"));
|
||||
var groups = new List<(string label, Scenario[] scenarios)> { ("modes", Scenarios.Modes) };
|
||||
if (Debug.isDebugBuild)
|
||||
{
|
||||
_nextMenu.panel.AddSection("development");
|
||||
AddScenarios(Scenarios.Development);
|
||||
_nextMenu.panel.AddSection("test arenas");
|
||||
AddScenarios(Scenarios.Tests);
|
||||
groups.Add(("development", Scenarios.Development));
|
||||
groups.Add(("test arenas", Scenarios.Tests));
|
||||
}
|
||||
_nextMenu.panel.AddButton("Back",
|
||||
() =>
|
||||
{
|
||||
ShowMain();
|
||||
Fade(false);
|
||||
});
|
||||
}
|
||||
|
||||
private void AddScenarios(IEnumerable<Scenario> scenarios)
|
||||
{
|
||||
foreach (var scenario in scenarios)
|
||||
ScenarioPicker.Show(_nextMenu.panel, _nextMenu.detail, groups, Launch, () =>
|
||||
{
|
||||
_nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario));
|
||||
_nextMenu.panel.AddProperty(() => scenario.Brief);
|
||||
}
|
||||
ShowMain();
|
||||
Fade(false);
|
||||
}, Dialog.Confirm);
|
||||
}
|
||||
|
||||
// The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where
|
||||
|
|
@ -263,8 +262,7 @@ public class MainMenu : MonoBehaviour
|
|||
|
||||
private void ShowSettings()
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
_nextMenu.panel.Title.text = "settings";
|
||||
Next("settings");
|
||||
_nextMenu.panel.AddButton("Gameplay",
|
||||
() =>
|
||||
{
|
||||
|
|
@ -299,8 +297,7 @@ public class MainMenu : MonoBehaviour
|
|||
|
||||
private void ShowGameplaySettings()
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
_nextMenu.panel.Title.text = TitleSubtitle("gameplay", "settings");
|
||||
Next(TitleSubtitle("gameplay", "settings"));
|
||||
_nextMenu.panel.AddField("Name",
|
||||
() => ActionGameManager.PlayerSettings.Name,
|
||||
s => ActionGameManager.PlayerSettings.Name = s);
|
||||
|
|
@ -322,8 +319,7 @@ public class MainMenu : MonoBehaviour
|
|||
|
||||
private void ShowGraphicsSettings()
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
_nextMenu.panel.Title.text = TitleSubtitle("graphics", "settings");
|
||||
Next(TitleSubtitle("graphics", "settings"));
|
||||
_nextMenu.panel.AddField("Nebula Quality",
|
||||
() => (int)ActionGameManager.PlayerSettings.GraphicsSettings.NebulaQuality,
|
||||
i =>
|
||||
|
|
@ -347,8 +343,7 @@ public class MainMenu : MonoBehaviour
|
|||
|
||||
private void ShowInputSettings()
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
_nextMenu.panel.Title.text = TitleSubtitle("input", "settings");
|
||||
Next(TitleSubtitle("input", "settings"));
|
||||
_nextMenu.panel.AddButton("Back",
|
||||
() =>
|
||||
{
|
||||
|
|
@ -359,8 +354,7 @@ public class MainMenu : MonoBehaviour
|
|||
|
||||
private void ShowAudioSettings()
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
_nextMenu.panel.Title.text = TitleSubtitle("audio", "settings");
|
||||
Next(TitleSubtitle("audio", "settings"));
|
||||
_nextMenu.panel.AddButton("Back",
|
||||
() =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ public class TradeMenu : MonoBehaviour
|
|||
// False lists the station's wares to buy; true lists the selected bay's goods to sell.
|
||||
private bool _selling;
|
||||
private (ItemFilter filter, HardpointType type) _hardpointFilter;
|
||||
private (ItemFilter filter, SimpleCommodityCategory type) _commodityFilter;
|
||||
private (ItemFilter filter, CompoundCommodityCategory type) _compoundCommodityFilter;
|
||||
private List<(ItemFilter filter, Type type)> _behaviorFilters = new List<(ItemFilter filter, Type type)>();
|
||||
|
||||
public EquippedCargoBay Inventory { get; set; }
|
||||
|
|
@ -62,14 +60,6 @@ public class TradeMenu : MonoBehaviour
|
|||
if (_hardpointFilter.filter != null)
|
||||
hardpointTypes = hardpointTypes.Where(x => x != _hardpointFilter.type);
|
||||
|
||||
IEnumerable<SimpleCommodityCategory> commodityTypes = (SimpleCommodityCategory[]) Enum.GetValues(typeof(SimpleCommodityCategory));
|
||||
if (_commodityFilter.filter != null)
|
||||
commodityTypes = commodityTypes.Where(x => x != _commodityFilter.type);
|
||||
|
||||
IEnumerable<CompoundCommodityCategory> compoundCommodityTypes = (CompoundCommodityCategory[]) Enum.GetValues(typeof(CompoundCommodityCategory));
|
||||
if (_compoundCommodityFilter.filter != null)
|
||||
compoundCommodityTypes = compoundCommodityTypes.Where(x => x != _compoundCommodityFilter.type);
|
||||
|
||||
ContextMenu.AddDropdown("Gear Type", hardpointTypes
|
||||
.Select<HardpointType, (string, Action, bool)>(x => (Enum.GetName(typeof(HardpointType), x), () =>
|
||||
{
|
||||
|
|
@ -82,75 +72,10 @@ public class TradeMenu : MonoBehaviour
|
|||
Populate();
|
||||
};
|
||||
}
|
||||
if(_commodityFilter.filter)
|
||||
{
|
||||
_commodityFilter.filter.GetComponent<Prototype>().ReturnToPool();
|
||||
_commodityFilter.filter = null;
|
||||
}
|
||||
if(_compoundCommodityFilter.filter)
|
||||
{
|
||||
_compoundCommodityFilter.filter.GetComponent<Prototype>().ReturnToPool();
|
||||
_compoundCommodityFilter.filter = null;
|
||||
}
|
||||
|
||||
_hardpointFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(HardpointType), x)}";
|
||||
_hardpointFilter.type = x;
|
||||
Populate();
|
||||
}, true)));
|
||||
ContextMenu.AddDropdown("Simple Commodity", commodityTypes
|
||||
.Select<SimpleCommodityCategory, (string, Action, bool)>(x => (Enum.GetName(typeof(SimpleCommodityCategory), x), () =>
|
||||
{
|
||||
if (!_commodityFilter.filter)
|
||||
{
|
||||
_commodityFilter.filter = FilterPrototype.Instantiate<ItemFilter>();
|
||||
_commodityFilter.filter.OnDisable += () =>
|
||||
{
|
||||
_commodityFilter.filter = null;
|
||||
Populate();
|
||||
};
|
||||
}
|
||||
if(_hardpointFilter.filter)
|
||||
{
|
||||
_hardpointFilter.filter.GetComponent<Prototype>().ReturnToPool();
|
||||
_hardpointFilter.filter = null;
|
||||
}
|
||||
if(_compoundCommodityFilter.filter)
|
||||
{
|
||||
_compoundCommodityFilter.filter.GetComponent<Prototype>().ReturnToPool();
|
||||
_compoundCommodityFilter.filter = null;
|
||||
}
|
||||
|
||||
_commodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(SimpleCommodityCategory), x)}";
|
||||
_commodityFilter.type = x;
|
||||
Populate();
|
||||
}, true)));
|
||||
ContextMenu.AddDropdown("Compound Commodity", compoundCommodityTypes
|
||||
.Select<CompoundCommodityCategory, (string, Action, bool)>(x => (Enum.GetName(typeof(CompoundCommodityCategory), x), () =>
|
||||
{
|
||||
if (!_compoundCommodityFilter.filter)
|
||||
{
|
||||
_compoundCommodityFilter.filter = FilterPrototype.Instantiate<ItemFilter>();
|
||||
_compoundCommodityFilter.filter.OnDisable += () =>
|
||||
{
|
||||
_compoundCommodityFilter.filter = null;
|
||||
Populate();
|
||||
};
|
||||
}
|
||||
if(_hardpointFilter.filter)
|
||||
{
|
||||
_hardpointFilter.filter.GetComponent<Prototype>().ReturnToPool();
|
||||
_hardpointFilter.filter = null;
|
||||
}
|
||||
if(_commodityFilter.filter)
|
||||
{
|
||||
_commodityFilter.filter.GetComponent<Prototype>().ReturnToPool();
|
||||
_commodityFilter.filter = null;
|
||||
}
|
||||
|
||||
_compoundCommodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(CompoundCommodityCategory), x)}";
|
||||
_compoundCommodityFilter.type = x;
|
||||
Populate();
|
||||
}, true)));
|
||||
ContextMenu.AddDropdown("Item Behavior", typeof(BehaviorData).GetAllChildClasses()
|
||||
.Where(x=> x.GetCustomAttribute<RuntimeInspectable>() != null && _behaviorFilters.All(f => f.type != x))
|
||||
.Select<Type, (string, Action, bool)>(x=> (x.Name.FormatTypeName(), () =>
|
||||
|
|
@ -199,19 +124,15 @@ public class TradeMenu : MonoBehaviour
|
|||
columns.Add(("Type", 2,
|
||||
x => () =>
|
||||
{
|
||||
if (x.data is SimpleCommodityData s) return Enum.GetName(typeof(SimpleCommodityCategory), s.Category);
|
||||
if(x.data is CompoundCommodityData c) return Enum.GetName(typeof(CompoundCommodityCategory), c.Category);
|
||||
if (x.data is SimpleCommodityData || x.data is CompoundCommodityData) return "Commodity";
|
||||
if(x.data is EquippableItemData e) return Enum.GetName(typeof(HardpointType), e.HardpointType);
|
||||
return "None";
|
||||
},
|
||||
x =>
|
||||
{
|
||||
var data = x.data;
|
||||
if (data is SimpleCommodityData s) return (int) s.Category;
|
||||
var offset = Enum.GetValues(typeof(SimpleCommodityCategory)).Length;
|
||||
if(data is CompoundCommodityData c) return (int) c.Category + offset;
|
||||
offset += Enum.GetValues(typeof(CompoundCommodityCategory)).Length;
|
||||
if(data is EquippableItemData e) return (int) e.HardpointType + offset;
|
||||
if (data is SimpleCommodityData || data is CompoundCommodityData) return 0;
|
||||
if (data is EquippableItemData e) return 1 + (int) e.HardpointType;
|
||||
return 0;
|
||||
}));
|
||||
columns.Add(("Mass", 1,
|
||||
|
|
@ -237,12 +158,6 @@ public class TradeMenu : MonoBehaviour
|
|||
!(MaximumSizeFilter.Width.text.Length > 0 && i.data.Shape.Width > int.Parse(MaximumSizeFilter.Width.text) ||
|
||||
MaximumSizeFilter.Height.text.Length > 0 && i.data.Shape.Height > int.Parse(MaximumSizeFilter.Height.text)));
|
||||
|
||||
if(_commodityFilter.filter != null)
|
||||
items = items.Where(i => i.data is SimpleCommodityData s && s.Category == _commodityFilter.type);
|
||||
|
||||
if(_compoundCommodityFilter.filter != null)
|
||||
items = items.Where(i => i.data is CompoundCommodityData c && c.Category == _compoundCommodityFilter.type);
|
||||
|
||||
if (_hardpointFilter.filter != null)
|
||||
items = items.Where(i => i.data is EquippableItemData e && e.HardpointType == _hardpointFilter.type);
|
||||
|
||||
|
|
@ -355,7 +270,7 @@ public class TradeMenu : MonoBehaviour
|
|||
Dialog.Title.text = $"Buying {GameManager.ItemManager.Title(i.item)}";
|
||||
Dialog.AddField("Quantity",
|
||||
() => quantity,
|
||||
q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity));
|
||||
q => quantity = StationServices.BuyableQuantity(GameManager.ItemManager, s, q, GameManager.Credits));
|
||||
Dialog.Show(() =>
|
||||
{
|
||||
Buy(s,quantity);
|
||||
|
|
@ -428,17 +343,8 @@ public class TradeMenu : MonoBehaviour
|
|||
|
||||
private void Buy(SimpleCommodity simpleCommodity, int quantity)
|
||||
{
|
||||
var data = GameManager.ItemManager.GetData(simpleCommodity);
|
||||
// Up-rounded integer division from https://stackoverflow.com/a/503201
|
||||
int lots = (quantity - 1) / data.MaxStack + 1;
|
||||
int remaining = quantity;
|
||||
for (int i = 0; i < lots; i++)
|
||||
{
|
||||
int q = min(remaining, data.MaxStack);
|
||||
if (!Bought(StationServices.TryBuy(GameManager.ItemManager, simpleCommodity, q, ref GameManager.Credits,
|
||||
() => Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)))) return;
|
||||
remaining -= q;
|
||||
}
|
||||
Bought(StationServices.TryBuyLots(GameManager.ItemManager, simpleCommodity, quantity, ref GameManager.Credits,
|
||||
lot => Inventory.TryTransferItem(_targetCargo, simpleCommodity, lot)));
|
||||
}
|
||||
|
||||
// Refreshes the credits after a purchase, or tells the player why the station refused it.
|
||||
|
|
|
|||
289
Assets/Scripts/UI/ScenarioPicker.cs
Normal file
289
Assets/Scripts/UI/ScenarioPicker.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UniRx;
|
||||
using UniRx.Triggers;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem.UI;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// The New Game screen: scenario names on the left pane by group, the selected scenario's brief, seed and ambient on a
|
||||
// docked right pane, one Launch. This class owns which scenario is selected (one index into the rows it built);
|
||||
// everything the screen shows about a scenario, and what Launch and Enter launch, reads that index. Keys arrive only as
|
||||
// Input System UI events on the focused Launch button, so Enter and a mouse click are the same Button.onClick.
|
||||
public sealed class ScenarioPicker
|
||||
{
|
||||
private readonly List<Scenario> _scenarios = new List<Scenario>();
|
||||
private readonly List<string> _groupLabels = new List<string>();
|
||||
private readonly List<Button> _rows = new List<Button>();
|
||||
private readonly List<ColorBlock> _rowColors = new List<ColorBlock>();
|
||||
private readonly List<Color> _rowFills = new List<Color>();
|
||||
private readonly PropertiesPanel _list;
|
||||
private readonly PropertiesPanel _detail;
|
||||
private readonly Action<Scenario> _launch;
|
||||
private readonly Action _back;
|
||||
private Button _launchButton;
|
||||
private ScrollRect _scroll;
|
||||
private UnityAction<Vector2> _fit;
|
||||
private static readonly Color Accent = new Color(1, .4458f, .05f);
|
||||
private int _selected;
|
||||
private bool _launched;
|
||||
private bool _restoreDeselect;
|
||||
|
||||
private ScenarioPicker(PropertiesPanel list, PropertiesPanel detail, Action<Scenario> launch, Action back)
|
||||
{
|
||||
_list = list;
|
||||
_detail = detail;
|
||||
_launch = launch;
|
||||
_back = back;
|
||||
}
|
||||
|
||||
// The scenario the detail pane describes and Launch will launch.
|
||||
public Scenario Selected => _scenarios[_selected];
|
||||
|
||||
// Parks the detail pane beside the list pane: a child of it, outside its layout, hidden until Show.
|
||||
public static void Dock(PropertiesPanel detail, PropertiesPanel list, float width = 480, float gap = 128)
|
||||
{
|
||||
var rect = (RectTransform) detail.transform;
|
||||
rect.SetParent(list.transform, false);
|
||||
detail.gameObject.AddComponent<LayoutElement>().ignoreLayout = true;
|
||||
rect.anchorMin = new Vector2(1, 0);
|
||||
rect.anchorMax = new Vector2(1, 1);
|
||||
rect.pivot = new Vector2(0, .5f);
|
||||
rect.anchoredPosition = new Vector2(gap, 0);
|
||||
rect.sizeDelta = new Vector2(width, 0);
|
||||
detail.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private static readonly Color Muted = new Color(1, 1, 1, .55f);
|
||||
|
||||
// Builds both panes for the groups, shows the detail pane, selects the first scenario and focuses Launch. The
|
||||
// launchChrome is a button of the menu's own styling, cloned for Launch.
|
||||
public static ScenarioPicker Show(PropertiesPanel list, PropertiesPanel detail,
|
||||
IReadOnlyList<(string label, Scenario[] scenarios)> groups, Action<Scenario> launch, Action back, Button launchChrome)
|
||||
{
|
||||
var picker = new ScenarioPicker(list, detail, launch, back);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (groups.Count > 1)
|
||||
{
|
||||
if (picker._scenarios.Count > 0) list.AddSpacer();
|
||||
var header = list.AddSection(group.label);
|
||||
header.GetComponent<LayoutElement>().preferredHeight = 26;
|
||||
var text = header.GetComponentInChildren<TMP_Text>();
|
||||
text.fontSize = 14;
|
||||
text.fontStyle = FontStyles.SmallCaps;
|
||||
text.alignment = TextAlignmentOptions.Left;
|
||||
text.color = Muted;
|
||||
}
|
||||
foreach (var scenario in group.scenarios)
|
||||
{
|
||||
var index = picker._scenarios.Count;
|
||||
picker._scenarios.Add(scenario);
|
||||
picker._groupLabels.Add(group.label);
|
||||
var row = list.AddButton(scenario.Name, () => picker.Select(index));
|
||||
picker.Track(row.Button);
|
||||
picker._rows.Add(row.Button);
|
||||
picker._rowColors.Add(row.Button.colors);
|
||||
picker._rowFills.Add(row.Button.targetGraphic.color);
|
||||
}
|
||||
}
|
||||
picker.Track(list.AddButton("Back", picker.Back).Button);
|
||||
|
||||
detail.Clear();
|
||||
detail.gameObject.SetActive(true);
|
||||
// The pane's own title is an invisible copy of the page title, so both panes stack their content from the same
|
||||
// line; the scenario is headed by its group label and name in the body.
|
||||
detail.Title.text = list.Title.text;
|
||||
detail.Title.color = Color.clear;
|
||||
Style(detail.AddProperty(() => $"<smallcaps>{picker._groupLabels[picker._selected]}"), 14, Muted);
|
||||
Style(detail.AddProperty(() => picker.Selected.Name), 30, Color.white);
|
||||
detail.AddProperty(() => $"<margin-right=20%>{picker.Selected.Brief}");
|
||||
detail.AddSpacer();
|
||||
detail.AddProperty(() => $"<color=#ffffff8c>Seed</color><pos=22%>{(picker.Selected.Seed == 0 ? "new each launch" : picker.Selected.Seed.ToString())}");
|
||||
detail.AddProperty(() => $"<color=#ffffff8c>Ambient</color><pos=22%>{(picker.Selected.Ambient ? "on" : "off")}");
|
||||
detail.AddSpacer();
|
||||
picker._launchButton = AddLaunch(detail, launchChrome, picker.Activate);
|
||||
picker.Track(picker._launchButton);
|
||||
picker._launchButton.OnMoveAsObservable().Subscribe(move =>
|
||||
{
|
||||
if (move.moveDir == MoveDirection.Up) picker.Step(-1);
|
||||
else if (move.moveDir == MoveDirection.Down) picker.Step(1);
|
||||
});
|
||||
picker._launchButton.OnCancelAsObservable().Subscribe(_ => picker.Back());
|
||||
|
||||
picker.StyleScrollbars();
|
||||
picker.HoldFocus();
|
||||
picker.Select(0);
|
||||
// Layout is not live until the frame after Show; edit mode has no frames.
|
||||
if (Application.isPlaying) Observable.NextFrame().Subscribe(_ => picker.FitViewport());
|
||||
return picker;
|
||||
}
|
||||
|
||||
private static void Style(Property property, float size, Color color)
|
||||
{
|
||||
property.Label.enableAutoSizing = false;
|
||||
property.Label.fontSize = size;
|
||||
property.Label.color = color;
|
||||
}
|
||||
|
||||
// Launch is a clone of the menu's own chrome button, filled with the accent so it reads as the default action. The
|
||||
// clone lives under a spacer row, so the pane's Clear destroys it.
|
||||
private static Button AddLaunch(PropertiesPanel detail, Button chrome, Action onClick)
|
||||
{
|
||||
var holder = detail.AddSpacer();
|
||||
holder.GetComponent<LayoutElement>().preferredHeight = 40;
|
||||
var button = UnityEngine.Object.Instantiate(chrome, holder, false);
|
||||
var rect = (RectTransform) button.transform;
|
||||
rect.anchorMin = Vector2.zero;
|
||||
rect.anchorMax = new Vector2(0, 1);
|
||||
rect.pivot = new Vector2(0, .5f);
|
||||
rect.anchoredPosition = Vector2.zero;
|
||||
rect.sizeDelta = new Vector2(220, 0);
|
||||
// The visible chrome is the button's Fill child, which the button's own layout group sizes to its text and
|
||||
// centres; with the group off, Fill stretches to the button.
|
||||
button.GetComponent<LayoutGroup>().enabled = false;
|
||||
var fill = button.targetGraphic.rectTransform;
|
||||
fill.anchorMin = Vector2.zero;
|
||||
fill.anchorMax = Vector2.one;
|
||||
fill.offsetMin = fill.offsetMax = Vector2.zero;
|
||||
button.gameObject.SetActive(true);
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(() => onClick());
|
||||
button.targetGraphic.color = Accent;
|
||||
button.GetComponentInChildren<TMP_Text>().text = "Launch";
|
||||
return button;
|
||||
}
|
||||
|
||||
// The detail pane needs no scrollbar; the list's gets a visible track, so its handle reads as a scrollbar rather
|
||||
// than a bar that stops partway.
|
||||
private void StyleScrollbars()
|
||||
{
|
||||
var detailScroll = ScrollOf(_detail);
|
||||
if (detailScroll != null && detailScroll.verticalScrollbar != null) detailScroll.verticalScrollbar.gameObject.SetActive(false);
|
||||
_scroll = ScrollOf(_list);
|
||||
if (_scroll == null) return;
|
||||
if (_scroll.verticalScrollbar != null)
|
||||
{
|
||||
if (!_scroll.verticalScrollbar.TryGetComponent<Image>(out var track)) track = _scroll.verticalScrollbar.gameObject.AddComponent<Image>();
|
||||
track.color = new Color(Accent.r, Accent.g, Accent.b, .2f);
|
||||
// A click on the bar selects it and takes focus off Launch, which would silence the arrows.
|
||||
_scroll.verticalScrollbar.navigation = new Navigation { mode = Navigation.Mode.None };
|
||||
}
|
||||
_scroll.onValueChanged.RemoveAllListeners();
|
||||
_fit = _ => FitViewport();
|
||||
_scroll.onValueChanged.AddListener(_fit);
|
||||
}
|
||||
|
||||
// The panel is inactive until the menu shows it, which is after Show builds it; an inactive ScrollRect still counts.
|
||||
private static ScrollRect ScrollOf(PropertiesPanel panel) => (panel.Content ?? panel.transform).GetComponentInParent<ScrollRect>(true);
|
||||
|
||||
// Hides every list entry the viewport only partly shows, so the list ends on a whole row instead of slicing text.
|
||||
private void FitViewport()
|
||||
{
|
||||
if (_scroll == null || _scroll.content == null) return;
|
||||
if (_rows[0] == null)
|
||||
{
|
||||
_scroll.onValueChanged.RemoveListener(_fit);
|
||||
return;
|
||||
}
|
||||
var (low, high) = ViewSpan(_scroll);
|
||||
foreach (RectTransform entry in _list.Content ?? _list.transform)
|
||||
{
|
||||
var bounds = RectTransformUtility.CalculateRelativeRectTransformBounds(_scroll.content, entry);
|
||||
var whole = bounds.min.y >= low - .5f && bounds.max.y <= high + .5f;
|
||||
if (!entry.TryGetComponent<CanvasGroup>(out var group)) group = entry.gameObject.AddComponent<CanvasGroup>();
|
||||
group.alpha = whole ? 1 : 0;
|
||||
group.blocksRaycasts = whole;
|
||||
}
|
||||
}
|
||||
|
||||
// The viewport's vertical extent in content space. CalculateRelativeRectTransformBounds cannot give it: it also
|
||||
// folds in the rects of the viewport's children, which makes the content's own bounds the answer.
|
||||
private static (float low, float high) ViewSpan(ScrollRect scroll)
|
||||
{
|
||||
var view = scroll.viewport != null ? scroll.viewport : (RectTransform) scroll.transform;
|
||||
var rect = view.rect;
|
||||
var low = scroll.content.InverseTransformPoint(view.TransformPoint(new Vector3(0, rect.yMin))).y;
|
||||
var high = scroll.content.InverseTransformPoint(view.TransformPoint(new Vector3(0, rect.yMax))).y;
|
||||
return (Mathf.Min(low, high), Mathf.Max(low, high));
|
||||
}
|
||||
|
||||
// Takes a button out of Unity's navigation so a click never moves focus and Move never walks automatic navigation.
|
||||
private void Track(Button button) => button.navigation = new Navigation { mode = Navigation.Mode.None };
|
||||
|
||||
// Moves the selection one row, stopping at either end.
|
||||
private void Step(int delta) => Select(_selected + delta);
|
||||
|
||||
// Makes one row the selection: clamps the index, redraws the highlight, and returns focus to Launch.
|
||||
private void Select(int index)
|
||||
{
|
||||
_selected = Mathf.Clamp(index, 0, _scenarios.Count - 1);
|
||||
for (var i = 0; i < _rows.Count; i++)
|
||||
{
|
||||
// The prefab tint barely moves the row colour, so the selected row is drawn in the menu accent.
|
||||
var colors = _rowColors[i];
|
||||
if (i == _selected) colors.normalColor = Color.white;
|
||||
_rows[i].colors = colors;
|
||||
_rows[i].targetGraphic.color = i == _selected ? Accent : _rowFills[i];
|
||||
}
|
||||
Focus(_launchButton.gameObject);
|
||||
Reveal((RectTransform) _rows[_selected].transform);
|
||||
}
|
||||
|
||||
// Scrolls the list pane just far enough that the selected row is inside its viewport.
|
||||
private void Reveal(RectTransform row)
|
||||
{
|
||||
var scroll = _scroll;
|
||||
if (scroll == null || scroll.content == null) return;
|
||||
var (low, high) = ViewSpan(scroll);
|
||||
var rowBounds = RectTransformUtility.CalculateRelativeRectTransformBounds(scroll.content, row);
|
||||
var shift = rowBounds.max.y > high ? rowBounds.max.y - high
|
||||
: rowBounds.min.y < low ? rowBounds.min.y - low : 0;
|
||||
// Content moves against the row: a row above the viewport needs the content to move down, and the reverse.
|
||||
scroll.content.anchoredPosition -= new Vector2(0, shift);
|
||||
if (shift != 0) FitViewport();
|
||||
}
|
||||
|
||||
// Launches the selection, once: Enter, Submit and a mouse click all arrive here, and nothing launches again until
|
||||
// the picker is shown afresh, however long the menu takes to fade out.
|
||||
private void Activate()
|
||||
{
|
||||
if (_launched) return;
|
||||
_launched = true;
|
||||
Focus(null);
|
||||
ReleaseFocus();
|
||||
_launch(Selected);
|
||||
}
|
||||
|
||||
// Leaves the screen; the same path for the Back button and Cancel.
|
||||
private void Back()
|
||||
{
|
||||
Focus(null);
|
||||
ReleaseFocus();
|
||||
_back();
|
||||
}
|
||||
|
||||
// While the picker is up, a click on empty space must not drop focus from Launch, or the arrows and Enter go dead.
|
||||
// The input module's flag is scene-wide, so the picker takes it for its own lifetime and gives it back on leaving.
|
||||
private void HoldFocus()
|
||||
{
|
||||
if (EventSystem.current == null || !(EventSystem.current.currentInputModule is InputSystemUIInputModule module) || !module.deselectOnBackgroundClick) return;
|
||||
module.deselectOnBackgroundClick = false;
|
||||
_restoreDeselect = true;
|
||||
}
|
||||
|
||||
private void ReleaseFocus()
|
||||
{
|
||||
if (!_restoreDeselect) return;
|
||||
_restoreDeselect = false;
|
||||
if (EventSystem.current != null && EventSystem.current.currentInputModule is InputSystemUIInputModule module) module.deselectOnBackgroundClick = true;
|
||||
}
|
||||
|
||||
private static void Focus(GameObject target)
|
||||
{
|
||||
if (EventSystem.current != null) EventSystem.current.SetSelectedGameObject(target);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue