Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1a6fbf880 | |||
|
|
7f148f08f1 | ||
| 73d36698b0 | |||
|
|
344ef3265c | ||
|
|
7675296ec4 | ||
|
|
6cb7b94551 | ||
|
|
9512e35d62 | ||
|
|
ff54fe7b46 | ||
| 2c087bf2b0 | |||
|
|
156d7c5a19 | ||
|
|
4c6dcfea73 | ||
|
|
b1dae6218e | ||
| 5e443569c3 | |||
|
|
3c78dbcbd1 | ||
|
|
b6c428d682 | ||
|
|
feb7cf8606 | ||
| a5536cf150 | |||
|
|
f856021997 | ||
|
|
782aee4ae5 | ||
|
|
465ab50666 | ||
|
|
dcd27cb3b0 | ||
|
|
58c4fa10c1 | ||
|
|
eee5f213bf | ||
|
|
036f75a0d8 | ||
| 6cc6414425 | |||
|
|
3458f0d7b3 | ||
|
|
663d054b02 | ||
|
|
601ce3cc18 | ||
|
|
ee49f95843 | ||
|
|
85f1a933d4 | ||
|
|
b2a2da9d27 | ||
|
|
0e9b10c3ae | ||
|
|
bd3563dee3 | ||
|
|
908712b2ee | ||
|
|
ac45a5c836 | ||
|
|
1013e2a2e4 | ||
| 16bb2e4c8f | |||
|
|
36a74478b6 | ||
|
|
afa048641f | ||
|
|
ded74e8b10 | ||
|
|
c21fad28f5 | ||
|
|
8948b99e59 | ||
|
|
54632d4eb9 | ||
|
|
1f93e22239 | ||
| 1a8ced9ab6 | |||
|
|
35fa50f256 | ||
|
|
5ec368c11e | ||
|
|
386f29bf0d | ||
|
|
fdbf0b6851 | ||
|
|
8f77000844 | ||
|
|
9135360beb | ||
| d5890fd259 | |||
|
|
b3f3c9a0c8 | ||
| 16e34049e0 | |||
|
|
c6a3bee879 | ||
| db6bda1655 | |||
| c33feebde2 | |||
|
|
07cfa26d6c | ||
| 9f8452818d | |||
| 25594f480f | |||
| c965962281 | |||
|
|
d97e2714cd | ||
|
|
b571555e2a | ||
|
|
65a6297aee | ||
|
|
2b0fb3c22d | ||
|
|
a0c760f5cf | ||
|
|
8c5a904cb7 | ||
|
|
a892b5463b | ||
|
|
5b4665dfbb | ||
|
|
17c19521f5 | ||
|
|
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 | ||
|
|
3df3a9549a | ||
|
|
d52b50f911 | ||
|
|
2f4b617e50 | ||
|
|
a9207abba9 | ||
|
|
2882907f66 | ||
|
|
82dab50ab5 | ||
|
|
96cb6d20f1 | ||
|
|
1ecddc0e69 | ||
|
|
3ac185a84e | ||
|
|
f121b46463 | ||
|
|
f3719d9943 | ||
|
|
820ec73f76 | ||
|
|
66510b003f | ||
|
|
8ae052bb47 | ||
|
|
70c366b541 | ||
|
|
8a1125f1fc | ||
|
|
a2284391ba | ||
|
|
399dc769df | ||
|
|
8a6816ed3e | ||
|
|
af993acf07 | ||
|
|
7f35c79ecc | ||
|
|
adaa8b8865 | ||
|
|
eaf7b799d4 | ||
|
|
35c93dd6ab | ||
|
|
1950397630 | ||
|
|
5acbaa1b2f | ||
|
|
3c4ea230bc | ||
|
|
d5491c1c57 | ||
|
|
1e7678526a | ||
|
|
5a050f0fca | ||
|
|
d83cacc6a7 | ||
|
|
f9acdc1e9c | ||
|
|
2e597b0715 | ||
|
|
24f057d18f | ||
|
|
763fa0987f | ||
|
|
f17b04ec1a | ||
| 40db8291d8 | |||
| d8afb9d184 | |||
| f235eea3d9 | |||
| 3ad3fcc505 | |||
| d2ec3a6678 | |||
| ad503059e5 | |||
| e5485bb3e0 | |||
| e2b1b9ca7b | |||
| e6a17a5e01 | |||
| 352e221453 | |||
| 900fa51e43 | |||
| 0769314ee6 | |||
| 8d65e957f8 | |||
| f73507ff2f | |||
| 129e5ff4dc | |||
| d28ce42df9 | |||
| a40072d9ab | |||
| 096f833c24 | |||
| cb1c093e84 | |||
| 0ed0605aa5 | |||
| 9457e4c8f9 | |||
| beb733f4b5 | |||
| de3ecd9bb0 | |||
| 112dd24536 | |||
| fb7b95fb06 | |||
| aec7b3c019 | |||
| 9b7a1c00dc | |||
| 6768cdcb95 | |||
| 2806b443c9 | |||
| 7f09b05725 | |||
| 49853d3166 | |||
| b19d6add89 | |||
| 47f834ea23 | |||
| 727c409659 | |||
| f23d3c6542 | |||
| 020dc58a81 | |||
| 8779304499 | |||
| 6d10331c05 | |||
| ac835a8ab2 | |||
| 9cdd885af0 | |||
| 92d6153c2e |
477 changed files with 25030 additions and 9496 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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
//
|
||||
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
|
||||
//
|
||||
// Original work (webgl-noise) Copyright (C) 2011 Stefan Gustavson
|
||||
// Translation and modification was made by Keijiro Takahashi.
|
||||
//
|
||||
// This shader is based on the webgl-noise GLSL shader. For further details
|
||||
// of the original shader, please see the following description from the
|
||||
// original source code.
|
||||
//
|
||||
|
||||
//
|
||||
// GLSL textureless classic 2D noise "cnoise",
|
||||
// with an RSL-style periodic variant "pnoise".
|
||||
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
|
||||
// Version: 2011-08-22
|
||||
//
|
||||
// Many thanks to Ian McEwan of Ashima Arts for the
|
||||
// ideas for permutation and gradient selection.
|
||||
//
|
||||
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
|
||||
// Distributed under the MIT license. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
|
||||
float4 mod(float4 x, float4 y)
|
||||
{
|
||||
return x - y * floor(x / y);
|
||||
}
|
||||
|
||||
float4 mod289(float4 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 permute(float4 x)
|
||||
{
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float4 taylorInvSqrt(float4 r)
|
||||
{
|
||||
return (float4)1.79284291400159 - r * 0.85373472095314;
|
||||
}
|
||||
|
||||
float2 fade(float2 t) {
|
||||
return t*t*t*(t*(t*6.0-15.0)+10.0);
|
||||
}
|
||||
|
||||
// Classic Perlin noise
|
||||
float cnoise(float2 P)
|
||||
{
|
||||
float4 Pi = floor(P.xyxy) + float4(0.0, 0.0, 1.0, 1.0);
|
||||
float4 Pf = frac (P.xyxy) - float4(0.0, 0.0, 1.0, 1.0);
|
||||
Pi = mod289(Pi); // To avoid truncation effects in permutation
|
||||
float4 ix = Pi.xzxz;
|
||||
float4 iy = Pi.yyww;
|
||||
float4 fx = Pf.xzxz;
|
||||
float4 fy = Pf.yyww;
|
||||
|
||||
float4 i = permute(permute(ix) + iy);
|
||||
|
||||
float4 gx = frac(i / 41.0) * 2.0 - 1.0 ;
|
||||
float4 gy = abs(gx) - 0.5 ;
|
||||
float4 tx = floor(gx + 0.5);
|
||||
gx = gx - tx;
|
||||
|
||||
float2 g00 = float2(gx.x,gy.x);
|
||||
float2 g10 = float2(gx.y,gy.y);
|
||||
float2 g01 = float2(gx.z,gy.z);
|
||||
float2 g11 = float2(gx.w,gy.w);
|
||||
|
||||
float4 norm = taylorInvSqrt(float4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
|
||||
g00 *= norm.x;
|
||||
g01 *= norm.y;
|
||||
g10 *= norm.z;
|
||||
g11 *= norm.w;
|
||||
|
||||
float n00 = dot(g00, float2(fx.x, fy.x));
|
||||
float n10 = dot(g10, float2(fx.y, fy.y));
|
||||
float n01 = dot(g01, float2(fx.z, fy.z));
|
||||
float n11 = dot(g11, float2(fx.w, fy.w));
|
||||
|
||||
float2 fade_xy = fade(Pf.xy);
|
||||
float2 n_x = lerp(float2(n00, n01), float2(n10, n11), fade_xy.x);
|
||||
float n_xy = lerp(n_x.x, n_x.y, fade_xy.y);
|
||||
return 2.3 * n_xy;
|
||||
}
|
||||
|
||||
// Classic Perlin noise, periodic variant
|
||||
float pnoise(float2 P, float2 rep)
|
||||
{
|
||||
float4 Pi = floor(P.xyxy) + float4(0.0, 0.0, 1.0, 1.0);
|
||||
float4 Pf = frac (P.xyxy) - float4(0.0, 0.0, 1.0, 1.0);
|
||||
Pi = mod(Pi, rep.xyxy); // To create noise with explicit period
|
||||
Pi = mod289(Pi); // To avoid truncation effects in permutation
|
||||
float4 ix = Pi.xzxz;
|
||||
float4 iy = Pi.yyww;
|
||||
float4 fx = Pf.xzxz;
|
||||
float4 fy = Pf.yyww;
|
||||
|
||||
float4 i = permute(permute(ix) + iy);
|
||||
|
||||
float4 gx = frac(i / 41.0) * 2.0 - 1.0 ;
|
||||
float4 gy = abs(gx) - 0.5 ;
|
||||
float4 tx = floor(gx + 0.5);
|
||||
gx = gx - tx;
|
||||
|
||||
float2 g00 = float2(gx.x,gy.x);
|
||||
float2 g10 = float2(gx.y,gy.y);
|
||||
float2 g01 = float2(gx.z,gy.z);
|
||||
float2 g11 = float2(gx.w,gy.w);
|
||||
|
||||
float4 norm = taylorInvSqrt(float4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
|
||||
g00 *= norm.x;
|
||||
g01 *= norm.y;
|
||||
g10 *= norm.z;
|
||||
g11 *= norm.w;
|
||||
|
||||
float n00 = dot(g00, float2(fx.x, fy.x));
|
||||
float n10 = dot(g10, float2(fx.y, fy.y));
|
||||
float n01 = dot(g01, float2(fx.z, fy.z));
|
||||
float n11 = dot(g11, float2(fx.w, fy.w));
|
||||
|
||||
float2 fade_xy = fade(Pf.xy);
|
||||
float2 n_x = lerp(float2(n00, n01), float2(n10, n11), fade_xy.x);
|
||||
float n_xy = lerp(n_x.x, n_x.y, fade_xy.y);
|
||||
return 2.3 * n_xy;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e1b0b5fd6a4e44923b40d856017562e1
|
||||
ShaderImporter:
|
||||
defaultTextures: []
|
||||
userData:
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
//
|
||||
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
|
||||
//
|
||||
// Original work (webgl-noise) Copyright (C) 2011 Stefan Gustavson
|
||||
// Translation and modification was made by Keijiro Takahashi.
|
||||
//
|
||||
// This shader is based on the webgl-noise GLSL shader. For further details
|
||||
// of the original shader, please see the following description from the
|
||||
// original source code.
|
||||
//
|
||||
|
||||
//
|
||||
// GLSL textureless classic 3D noise "cnoise",
|
||||
// with an RSL-style periodic variant "pnoise".
|
||||
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
|
||||
// Version: 2011-10-11
|
||||
//
|
||||
// Many thanks to Ian McEwan of Ashima Arts for the
|
||||
// ideas for permutation and gradient selection.
|
||||
//
|
||||
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
|
||||
// Distributed under the MIT license. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
|
||||
float3 mod(float3 x, float3 y)
|
||||
{
|
||||
return x - y * floor(x / y);
|
||||
}
|
||||
|
||||
float3 mod289(float3 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 mod289(float4 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 permute(float4 x)
|
||||
{
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float4 taylorInvSqrt(float4 r)
|
||||
{
|
||||
return (float4)1.79284291400159 - r * 0.85373472095314;
|
||||
}
|
||||
|
||||
float3 fade(float3 t) {
|
||||
return t*t*t*(t*(t*6.0-15.0)+10.0);
|
||||
}
|
||||
|
||||
// Classic Perlin noise
|
||||
float cnoise(float3 P)
|
||||
{
|
||||
float3 Pi0 = floor(P); // Integer part for indexing
|
||||
float3 Pi1 = Pi0 + (float3)1.0; // Integer part + 1
|
||||
Pi0 = mod289(Pi0);
|
||||
Pi1 = mod289(Pi1);
|
||||
float3 Pf0 = frac(P); // Fractional part for interpolation
|
||||
float3 Pf1 = Pf0 - (float3)1.0; // Fractional part - 1.0
|
||||
float4 ix = float4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
|
||||
float4 iy = float4(Pi0.y, Pi0.y, Pi1.y, Pi1.y);
|
||||
float4 iz0 = (float4)Pi0.z;
|
||||
float4 iz1 = (float4)Pi1.z;
|
||||
|
||||
float4 ixy = permute(permute(ix) + iy);
|
||||
float4 ixy0 = permute(ixy + iz0);
|
||||
float4 ixy1 = permute(ixy + iz1);
|
||||
|
||||
float4 gx0 = ixy0 / 7.0;
|
||||
float4 gy0 = frac(floor(gx0) / 7.0) - 0.5;
|
||||
gx0 = frac(gx0);
|
||||
float4 gz0 = (float4)0.5 - abs(gx0) - abs(gy0);
|
||||
float4 sz0 = step(gz0, (float4)0.0);
|
||||
gx0 -= sz0 * (step((float4)0.0, gx0) - 0.5);
|
||||
gy0 -= sz0 * (step((float4)0.0, gy0) - 0.5);
|
||||
|
||||
float4 gx1 = ixy1 / 7.0;
|
||||
float4 gy1 = frac(floor(gx1) / 7.0) - 0.5;
|
||||
gx1 = frac(gx1);
|
||||
float4 gz1 = (float4)0.5 - abs(gx1) - abs(gy1);
|
||||
float4 sz1 = step(gz1, (float4)0.0);
|
||||
gx1 -= sz1 * (step((float4)0.0, gx1) - 0.5);
|
||||
gy1 -= sz1 * (step((float4)0.0, gy1) - 0.5);
|
||||
|
||||
float3 g000 = float3(gx0.x,gy0.x,gz0.x);
|
||||
float3 g100 = float3(gx0.y,gy0.y,gz0.y);
|
||||
float3 g010 = float3(gx0.z,gy0.z,gz0.z);
|
||||
float3 g110 = float3(gx0.w,gy0.w,gz0.w);
|
||||
float3 g001 = float3(gx1.x,gy1.x,gz1.x);
|
||||
float3 g101 = float3(gx1.y,gy1.y,gz1.y);
|
||||
float3 g011 = float3(gx1.z,gy1.z,gz1.z);
|
||||
float3 g111 = float3(gx1.w,gy1.w,gz1.w);
|
||||
|
||||
float4 norm0 = taylorInvSqrt(float4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
|
||||
g000 *= norm0.x;
|
||||
g010 *= norm0.y;
|
||||
g100 *= norm0.z;
|
||||
g110 *= norm0.w;
|
||||
|
||||
float4 norm1 = taylorInvSqrt(float4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
|
||||
g001 *= norm1.x;
|
||||
g011 *= norm1.y;
|
||||
g101 *= norm1.z;
|
||||
g111 *= norm1.w;
|
||||
|
||||
float n000 = dot(g000, Pf0);
|
||||
float n100 = dot(g100, float3(Pf1.x, Pf0.y, Pf0.z));
|
||||
float n010 = dot(g010, float3(Pf0.x, Pf1.y, Pf0.z));
|
||||
float n110 = dot(g110, float3(Pf1.x, Pf1.y, Pf0.z));
|
||||
float n001 = dot(g001, float3(Pf0.x, Pf0.y, Pf1.z));
|
||||
float n101 = dot(g101, float3(Pf1.x, Pf0.y, Pf1.z));
|
||||
float n011 = dot(g011, float3(Pf0.x, Pf1.y, Pf1.z));
|
||||
float n111 = dot(g111, Pf1);
|
||||
|
||||
float3 fade_xyz = fade(Pf0);
|
||||
float4 n_z = lerp(float4(n000, n100, n010, n110), float4(n001, n101, n011, n111), fade_xyz.z);
|
||||
float2 n_yz = lerp(n_z.xy, n_z.zw, fade_xyz.y);
|
||||
float n_xyz = lerp(n_yz.x, n_yz.y, fade_xyz.x);
|
||||
return 2.2 * n_xyz;
|
||||
}
|
||||
|
||||
// Classic Perlin noise, periodic variant
|
||||
float pnoise(float3 P, float3 rep)
|
||||
{
|
||||
float3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
|
||||
float3 Pi1 = mod(Pi0 + (float3)1.0, rep); // Integer part + 1, mod period
|
||||
Pi0 = mod289(Pi0);
|
||||
Pi1 = mod289(Pi1);
|
||||
float3 Pf0 = frac(P); // Fractional part for interpolation
|
||||
float3 Pf1 = Pf0 - (float3)1.0; // Fractional part - 1.0
|
||||
float4 ix = float4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
|
||||
float4 iy = float4(Pi0.y, Pi0.y, Pi1.y, Pi1.y);
|
||||
float4 iz0 = (float4)Pi0.z;
|
||||
float4 iz1 = (float4)Pi1.z;
|
||||
|
||||
float4 ixy = permute(permute(ix) + iy);
|
||||
float4 ixy0 = permute(ixy + iz0);
|
||||
float4 ixy1 = permute(ixy + iz1);
|
||||
|
||||
float4 gx0 = ixy0 / 7.0;
|
||||
float4 gy0 = frac(floor(gx0) / 7.0) - 0.5;
|
||||
gx0 = frac(gx0);
|
||||
float4 gz0 = (float4)0.5 - abs(gx0) - abs(gy0);
|
||||
float4 sz0 = step(gz0, (float4)0.0);
|
||||
gx0 -= sz0 * (step((float4)0.0, gx0) - 0.5);
|
||||
gy0 -= sz0 * (step((float4)0.0, gy0) - 0.5);
|
||||
|
||||
float4 gx1 = ixy1 / 7.0;
|
||||
float4 gy1 = frac(floor(gx1) / 7.0) - 0.5;
|
||||
gx1 = frac(gx1);
|
||||
float4 gz1 = (float4)0.5 - abs(gx1) - abs(gy1);
|
||||
float4 sz1 = step(gz1, (float4)0.0);
|
||||
gx1 -= sz1 * (step((float4)0.0, gx1) - 0.5);
|
||||
gy1 -= sz1 * (step((float4)0.0, gy1) - 0.5);
|
||||
|
||||
float3 g000 = float3(gx0.x,gy0.x,gz0.x);
|
||||
float3 g100 = float3(gx0.y,gy0.y,gz0.y);
|
||||
float3 g010 = float3(gx0.z,gy0.z,gz0.z);
|
||||
float3 g110 = float3(gx0.w,gy0.w,gz0.w);
|
||||
float3 g001 = float3(gx1.x,gy1.x,gz1.x);
|
||||
float3 g101 = float3(gx1.y,gy1.y,gz1.y);
|
||||
float3 g011 = float3(gx1.z,gy1.z,gz1.z);
|
||||
float3 g111 = float3(gx1.w,gy1.w,gz1.w);
|
||||
|
||||
float4 norm0 = taylorInvSqrt(float4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
|
||||
g000 *= norm0.x;
|
||||
g010 *= norm0.y;
|
||||
g100 *= norm0.z;
|
||||
g110 *= norm0.w;
|
||||
float4 norm1 = taylorInvSqrt(float4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
|
||||
g001 *= norm1.x;
|
||||
g011 *= norm1.y;
|
||||
g101 *= norm1.z;
|
||||
g111 *= norm1.w;
|
||||
|
||||
float n000 = dot(g000, Pf0);
|
||||
float n100 = dot(g100, float3(Pf1.x, Pf0.y, Pf0.z));
|
||||
float n010 = dot(g010, float3(Pf0.x, Pf1.y, Pf0.z));
|
||||
float n110 = dot(g110, float3(Pf1.x, Pf1.y, Pf0.z));
|
||||
float n001 = dot(g001, float3(Pf0.x, Pf0.y, Pf1.z));
|
||||
float n101 = dot(g101, float3(Pf1.x, Pf0.y, Pf1.z));
|
||||
float n011 = dot(g011, float3(Pf0.x, Pf1.y, Pf1.z));
|
||||
float n111 = dot(g111, Pf1);
|
||||
|
||||
float3 fade_xyz = fade(Pf0);
|
||||
float4 n_z = lerp(float4(n000, n100, n010, n110), float4(n001, n101, n011, n111), fade_xyz.z);
|
||||
float2 n_yz = lerp(n_z.xy, n_z.zw, fade_xyz.y);
|
||||
float n_xyz = lerp(n_yz.x, n_yz.y, fade_xyz.x);
|
||||
return 2.2 * n_xyz;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3ba1abd1cb44c47b68fbb577c6c4ba3e
|
||||
ShaderImporter:
|
||||
defaultTextures: []
|
||||
userData:
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
//
|
||||
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
|
||||
//
|
||||
// Original work (webgl-noise) Copyright (C) 2011 Ashima Arts.
|
||||
// Translation and modification was made by Keijiro Takahashi.
|
||||
//
|
||||
// This shader is based on the webgl-noise GLSL shader. For further details
|
||||
// of the original shader, please see the following description from the
|
||||
// original source code.
|
||||
//
|
||||
|
||||
//
|
||||
// Description : Array and textureless GLSL 2D/3D/4D simplex
|
||||
// noise functions.
|
||||
// Author : Ian McEwan, Ashima Arts.
|
||||
// Maintainer : ijm
|
||||
// Lastmod : 20110822 (ijm)
|
||||
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
|
||||
// Distributed under the MIT License. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
|
||||
float3 mod289(float3 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 mod289(float4 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 permute(float4 x)
|
||||
{
|
||||
return mod289((x * 34.0 + 1.0) * x);
|
||||
}
|
||||
|
||||
float4 taylorInvSqrt(float4 r)
|
||||
{
|
||||
return 1.79284291400159 - r * 0.85373472095314;
|
||||
}
|
||||
|
||||
float snoise(float3 v)
|
||||
{
|
||||
const float2 C = float2(1.0 / 6.0, 1.0 / 3.0);
|
||||
|
||||
// First corner
|
||||
float3 i = floor(v + dot(v, C.yyy));
|
||||
float3 x0 = v - i + dot(i, C.xxx);
|
||||
|
||||
// Other corners
|
||||
float3 g = step(x0.yzx, x0.xyz);
|
||||
float3 l = 1.0 - g;
|
||||
float3 i1 = min(g.xyz, l.zxy);
|
||||
float3 i2 = max(g.xyz, l.zxy);
|
||||
|
||||
// x1 = x0 - i1 + 1.0 * C.xxx;
|
||||
// x2 = x0 - i2 + 2.0 * C.xxx;
|
||||
// x3 = x0 - 1.0 + 3.0 * C.xxx;
|
||||
float3 x1 = x0 - i1 + C.xxx;
|
||||
float3 x2 = x0 - i2 + C.yyy;
|
||||
float3 x3 = x0 - 0.5;
|
||||
|
||||
// Permutations
|
||||
i = mod289(i); // Avoid truncation effects in permutation
|
||||
float4 p =
|
||||
permute(permute(permute(i.z + float4(0.0, i1.z, i2.z, 1.0))
|
||||
+ i.y + float4(0.0, i1.y, i2.y, 1.0))
|
||||
+ i.x + float4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
// Gradients: 7x7 points over a square, mapped onto an octahedron.
|
||||
// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
|
||||
float4 j = p - 49.0 * floor(p / 49.0); // mod(p,7*7)
|
||||
|
||||
float4 x_ = floor(j / 7.0);
|
||||
float4 y_ = floor(j - 7.0 * x_); // mod(j,N)
|
||||
|
||||
float4 x = (x_ * 2.0 + 0.5) / 7.0 - 1.0;
|
||||
float4 y = (y_ * 2.0 + 0.5) / 7.0 - 1.0;
|
||||
|
||||
float4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
float4 b0 = float4(x.xy, y.xy);
|
||||
float4 b1 = float4(x.zw, y.zw);
|
||||
|
||||
//float4 s0 = float4(lessThan(b0, 0.0)) * 2.0 - 1.0;
|
||||
//float4 s1 = float4(lessThan(b1, 0.0)) * 2.0 - 1.0;
|
||||
float4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
float4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
float4 sh = -step(h, 0.0);
|
||||
|
||||
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
float3 g0 = float3(a0.xy, h.x);
|
||||
float3 g1 = float3(a0.zw, h.y);
|
||||
float3 g2 = float3(a1.xy, h.z);
|
||||
float3 g3 = float3(a1.zw, h.w);
|
||||
|
||||
// Normalise gradients
|
||||
float4 norm = taylorInvSqrt(float4(dot(g0, g0), dot(g1, g1), dot(g2, g2), dot(g3, g3)));
|
||||
g0 *= norm.x;
|
||||
g1 *= norm.y;
|
||||
g2 *= norm.z;
|
||||
g3 *= norm.w;
|
||||
|
||||
// Mix final noise value
|
||||
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
m = m * m;
|
||||
m = m * m;
|
||||
|
||||
float4 px = float4(dot(x0, g0), dot(x1, g1), dot(x2, g2), dot(x3, g3));
|
||||
return 42.0 * dot(m, px);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b577a94f58a1398409bfcfe9cd5dbf99
|
||||
timeCreated: 1448788105
|
||||
licenseType: Pro
|
||||
ShaderImporter:
|
||||
defaultTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
//
|
||||
// Noise Shader Library for Unity - https://github.com/keijiro/NoiseShader
|
||||
//
|
||||
// Original work (webgl-noise) Copyright (C) 2011 Ashima Arts.
|
||||
// Translation and modification was made by Keijiro Takahashi.
|
||||
//
|
||||
// This shader is based on the webgl-noise GLSL shader. For further details
|
||||
// of the original shader, please see the following description from the
|
||||
// original source code.
|
||||
//
|
||||
|
||||
//
|
||||
// Description : Array and textureless GLSL 2D/3D/4D simplex
|
||||
// noise functions.
|
||||
// Author : Ian McEwan, Ashima Arts.
|
||||
// Maintainer : ijm
|
||||
// Lastmod : 20110822 (ijm)
|
||||
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
|
||||
// Distributed under the MIT License. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
|
||||
float3 mod289(float3 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 mod289(float4 x)
|
||||
{
|
||||
return x - floor(x / 289.0) * 289.0;
|
||||
}
|
||||
|
||||
float4 permute(float4 x)
|
||||
{
|
||||
return mod289((x * 34.0 + 1.0) * x);
|
||||
}
|
||||
|
||||
float4 taylorInvSqrt(float4 r)
|
||||
{
|
||||
return 1.79284291400159 - r * 0.85373472095314;
|
||||
}
|
||||
|
||||
float3 snoise_grad(float3 v)
|
||||
{
|
||||
const float2 C = float2(1.0 / 6.0, 1.0 / 3.0);
|
||||
|
||||
// First corner
|
||||
float3 i = floor(v + dot(v, C.yyy));
|
||||
float3 x0 = v - i + dot(i, C.xxx);
|
||||
|
||||
// Other corners
|
||||
float3 g = step(x0.yzx, x0.xyz);
|
||||
float3 l = 1.0 - g;
|
||||
float3 i1 = min(g.xyz, l.zxy);
|
||||
float3 i2 = max(g.xyz, l.zxy);
|
||||
|
||||
// x1 = x0 - i1 + 1.0 * C.xxx;
|
||||
// x2 = x0 - i2 + 2.0 * C.xxx;
|
||||
// x3 = x0 - 1.0 + 3.0 * C.xxx;
|
||||
float3 x1 = x0 - i1 + C.xxx;
|
||||
float3 x2 = x0 - i2 + C.yyy;
|
||||
float3 x3 = x0 - 0.5;
|
||||
|
||||
// Permutations
|
||||
i = mod289(i); // Avoid truncation effects in permutation
|
||||
float4 p =
|
||||
permute(permute(permute(i.z + float4(0.0, i1.z, i2.z, 1.0))
|
||||
+ i.y + float4(0.0, i1.y, i2.y, 1.0))
|
||||
+ i.x + float4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
// Gradients: 7x7 points over a square, mapped onto an octahedron.
|
||||
// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
|
||||
float4 j = p - 49.0 * floor(p / 49.0); // mod(p,7*7)
|
||||
|
||||
float4 x_ = floor(j / 7.0);
|
||||
float4 y_ = floor(j - 7.0 * x_); // mod(j,N)
|
||||
|
||||
float4 x = (x_ * 2.0 + 0.5) / 7.0 - 1.0;
|
||||
float4 y = (y_ * 2.0 + 0.5) / 7.0 - 1.0;
|
||||
|
||||
float4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
float4 b0 = float4(x.xy, y.xy);
|
||||
float4 b1 = float4(x.zw, y.zw);
|
||||
|
||||
//float4 s0 = float4(lessThan(b0, 0.0)) * 2.0 - 1.0;
|
||||
//float4 s1 = float4(lessThan(b1, 0.0)) * 2.0 - 1.0;
|
||||
float4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
float4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
float4 sh = -step(h, 0.0);
|
||||
|
||||
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
float3 g0 = float3(a0.xy, h.x);
|
||||
float3 g1 = float3(a0.zw, h.y);
|
||||
float3 g2 = float3(a1.xy, h.z);
|
||||
float3 g3 = float3(a1.zw, h.w);
|
||||
|
||||
// Normalise gradients
|
||||
float4 norm = taylorInvSqrt(float4(dot(g0, g0), dot(g1, g1), dot(g2, g2), dot(g3, g3)));
|
||||
g0 *= norm.x;
|
||||
g1 *= norm.y;
|
||||
g2 *= norm.z;
|
||||
g3 *= norm.w;
|
||||
|
||||
// Compute gradient of noise function at P
|
||||
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
float4 m2 = m * m;
|
||||
float4 m3 = m2 * m;
|
||||
float4 m4 = m2 * m2;
|
||||
float3 grad =
|
||||
-6.0 * m3.x * x0 * dot(x0, g0) + m4.x * g0 +
|
||||
-6.0 * m3.y * x1 * dot(x1, g1) + m4.y * g1 +
|
||||
-6.0 * m3.z * x2 * dot(x2, g2) + m4.z * g2 +
|
||||
-6.0 * m3.w * x3 * dot(x3, g3) + m4.w * g3;
|
||||
return 42.0 * grad;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a7111eafc2463224faacf7be3bddb9a4
|
||||
timeCreated: 1449292284
|
||||
licenseType: Pro
|
||||
ShaderImporter:
|
||||
defaultTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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,45 @@ 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));
|
||||
Ship ship;
|
||||
if (pack != null) ship = (Ship) EntitySerializer.Unpack(ItemManager, Zone, pack);
|
||||
else
|
||||
{
|
||||
// An unoffered hull is drawn by nobody; it reaches a ship only through a Loadout (ruling off-market-is-general).
|
||||
var failures = new List<string>();
|
||||
var loadout = ItemManager.ItemData.GetAll<Loadout>().FirstOrDefault(l =>
|
||||
ItemManager.ItemData.Get(l.Hull) is { HullType: HullType.Ship } hull &&
|
||||
string.Equals(hull.Name, hullName, StringComparison.InvariantCultureIgnoreCase));
|
||||
ship = loadout == null ? null : Loadouts.Materialize(ItemManager, Zone, loadout, generator.ChooseProduct, failures) as Ship;
|
||||
if (ship == null)
|
||||
{
|
||||
ConsoleController.Instance.AppendLogLine(failures.Count > 0
|
||||
? $"Cannot build \"{hullName}\": {string.Join("; ", failures)}"
|
||||
: $"No ship hull named \"{hullName}\" that a maker offers or a Loadout fits");
|
||||
return;
|
||||
}
|
||||
}
|
||||
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 +632,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
|
||||
|
|
@ -642,9 +655,9 @@ public class ActionGameManager : MonoBehaviour
|
|||
{
|
||||
var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace);
|
||||
console.AppendLogLine(written
|
||||
? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
|
||||
"Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets."
|
||||
: $"Preset '{name}' changed on disk since load; nothing written");
|
||||
? $"Loadout '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
|
||||
"Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured loadouts."
|
||||
: $"Loadout '{name}' changed on disk since load; nothing written");
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
|
|
@ -1020,6 +1033,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 +1112,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 +1366,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 +1376,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 +1396,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 +1509,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))
|
||||
|
|
@ -41,6 +43,8 @@ public static class AetheriaStores
|
|||
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
|
||||
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
|
||||
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
|
||||
ValidateBins(cache);
|
||||
StationKinds.Validate(cache, kindlessStation);
|
||||
return cache;
|
||||
}
|
||||
catch
|
||||
|
|
@ -49,6 +53,24 @@ public static class AetheriaStores
|
|||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// The mint and the weighting trust a bin: they never check its Floor or its Line. So the catalog read refuses one whose Floor is
|
||||
// not a finite number (it would weigh NaN and poison every draw of its design), whose Line names no product the catalog holds, or
|
||||
// whose Line is itself a bin (a bin is of a line, never of a bin). The refusals name the field and echo no value.
|
||||
private static void ValidateBins(CultCache cache)
|
||||
{
|
||||
foreach (var product in cache.GetAll<FactionProductData>())
|
||||
{
|
||||
if (product.Bin == null) continue;
|
||||
if (!float.IsFinite(product.Bin.Floor))
|
||||
throw new InvalidOperationException("A product bin's Floor is not a finite number.");
|
||||
var line = cache.Get(product.Bin.Line);
|
||||
if (line == null)
|
||||
throw new InvalidOperationException("A product bin's Line names no product this catalog holds.");
|
||||
if (line.Bin != null)
|
||||
throw new InvalidOperationException("A product bin's Line names another bin; a bin is of a line.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class CultRecordRefs
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using System;
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
using CultMath;
|
||||
|
|
@ -87,10 +88,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 +199,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
|
||||
{
|
||||
|
|
@ -206,4 +213,7 @@ public abstract class BehaviorData
|
|||
{
|
||||
return base.ToString().FormatTypeName();
|
||||
}
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(37)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
{
|
||||
get => _ammo;
|
||||
}
|
||||
public override bool HasMagazine => false;
|
||||
|
||||
public float Progress
|
||||
{
|
||||
|
|
@ -146,9 +147,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 +200,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 +211,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;
|
||||
|
|
@ -98,13 +133,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
public InstantWeapon(InstantWeaponData data, EquippedItem item) : base(data, item)
|
||||
{
|
||||
_data = data;
|
||||
_ammo = data.MagazineSize;
|
||||
_ammo = Magazine;
|
||||
}
|
||||
|
||||
public InstantWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item)
|
||||
{
|
||||
_data = data;
|
||||
_ammo = data.MagazineSize;
|
||||
_ammo = Magazine;
|
||||
}
|
||||
|
||||
protected void Trigger()
|
||||
|
|
@ -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,9 +222,57 @@ 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 (_data.MagazineSize <= 1) return true;
|
||||
if (!_coolingDown || !(_cooldown < 0)) return false;
|
||||
_overshoot = min(-_cooldown * CountdownSpan, dt);
|
||||
_coolingDown = false;
|
||||
if (Magazine > 0 && _ammo == 0 && TakeReloadRound())
|
||||
{
|
||||
_ammo = Magazine;
|
||||
OnReloadComplete?.Invoke();
|
||||
}
|
||||
else
|
||||
OnCooldownComplete?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
// A reload pays a round (ruling ballistics-real-ammo): one unit of the weapon's AmmoType leaves the cargo as the magazine refills,
|
||||
// and with none to take the magazine stays empty. A weapon with no AmmoType reloads free.
|
||||
private bool TakeReloadRound()
|
||||
{
|
||||
if (!_data.AmmoType.IsSet()) return true;
|
||||
var cargo = Entity.FindItemInCargo(_data.AmmoType.Key);
|
||||
if (cargo == null) return false;
|
||||
if (cargo.ItemsOfType[_data.AmmoType.Key][0] is SimpleCommodity simpleCommodity) cargo.Remove(simpleCommodity, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
private float CountdownSpan => max(ShortestCycle, Magazine > 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;
|
||||
}
|
||||
|
||||
// A weapon that names an AmmoType is magazine-fed whatever MagazineSize says: its magazine is at least one round, every round
|
||||
// leaves it and the reload takes a crate. A weapon with no AmmoType and a magazine of one or none fires free.
|
||||
private int Magazine => _data.AmmoType.IsSet() ? max(1, _data.MagazineSize) : _data.MagazineSize;
|
||||
|
||||
private bool UseAmmo(float dt)
|
||||
{
|
||||
if (!_data.AmmoType.IsSet() && _data.MagazineSize <= 1) return true;
|
||||
|
||||
if (_ammo > 0)
|
||||
{
|
||||
|
|
@ -193,22 +280,12 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
return true;
|
||||
}
|
||||
|
||||
var hasAmmo = true;
|
||||
if (_data.AmmoType.IsSet())
|
||||
{
|
||||
var cargo = Entity.FindItemInCargo(_data.AmmoType.Key);
|
||||
if (cargo != null)
|
||||
{
|
||||
var item = cargo.ItemsOfType[_data.AmmoType.Key][0];
|
||||
if (item is SimpleCommodity simpleCommodity)
|
||||
cargo.Remove(simpleCommodity, 1);
|
||||
}
|
||||
else hasAmmo = false;
|
||||
}
|
||||
// The reload starts only if a round is there to load; FinishCountdown takes it.
|
||||
var hasAmmo = !_data.AmmoType.IsSet() || Entity.CountItemsInCargo(_data.AmmoType.Key) > 0;
|
||||
if(hasAmmo)
|
||||
{
|
||||
OnReloadBegin?.Invoke();
|
||||
_cooldown = 1;
|
||||
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime;
|
||||
_coolingDown = true;
|
||||
_firing = false;
|
||||
}
|
||||
|
|
@ -218,29 +295,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 +329,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 +340,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 +358,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,13 +95,23 @@ public abstract class WeaponData : BehaviorData
|
|||
public float RatedRange => Range.Max;
|
||||
}
|
||||
|
||||
public abstract class Weapon : Behavior, IActivatedBehavior
|
||||
public abstract class Weapon : ActivatedBehavior
|
||||
{
|
||||
private WeaponData _data;
|
||||
|
||||
public abstract float DamagePerSecond { get; }
|
||||
public abstract float RangeDamagePerSecond(float range);
|
||||
public abstract int Ammo { get; }
|
||||
// Rounds in the ship's cargo that reload this weapon's magazine: crates of AmmoType, 0 when the weapon draws none.
|
||||
public int Reserve => _data.AmmoType.IsSet() ? Entity.CountItemsInCargo(_data.AmmoType.Key) : 0;
|
||||
// Whether Ammo counts a magazine. A weapon that names an AmmoType and fires from one (InstantWeapon) does; a beam (ConstantWeapon) draws one
|
||||
// crate per AmmoInterval straight from the cargo and has none.
|
||||
public virtual bool HasMagazine => true;
|
||||
// What the HUD's ammunition label shows: "magazine/reserve" for a magazine weapon, the reserve alone for one without a magazine, empty when
|
||||
// the weapon draws no ammunition. The one rule the HUD reads, so the label cannot show a magazine a weapon does not have.
|
||||
public string AmmoReadout => !ShowsAmmoReadout ? "" : HasMagazine ? $"{Ammo}/{Reserve}" : Reserve.ToString();
|
||||
// Whether the HUD shows the ammunition label at all (and not the infinite-ammunition icon): the weapon names an AmmoType.
|
||||
public bool ShowsAmmoReadout => _data.AmmoType.IsSet();
|
||||
public WeaponData WeaponData => _data;
|
||||
public float RangeFactor(float at) => WeaponData.RangeFactor(MinRange, EffectiveRange, Range, _data.DamageAtMaxRange, at);
|
||||
|
||||
|
|
@ -179,12 +184,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:
|
||||
|
|
@ -39,7 +39,7 @@ public class Faction
|
|||
|
||||
// Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it.
|
||||
|
||||
// Key 10 was the faction's named boss hull, retired by boss-hull-derived: a faction's boss hulls are derived from the Boss hulls it makes; never reuse it.
|
||||
// Key 10 was the faction's boss hull reference, retired by ruling faction-boss-is-a-loadout-reference; never reuse it.
|
||||
|
||||
[Inspectable, JsonProperty("influence"), Key(11)]
|
||||
public int InfluenceDistance = 4;
|
||||
|
|
@ -47,14 +47,7 @@ public class Faction
|
|||
[Inspectable, CultReference(typeof(Faction), many: true), CultInspectorRange(0, 1), JsonProperty("allegiance"), Key(12)]
|
||||
public Dictionary<CultRecordRef<Faction>, float> Allegiance = new Dictionary<CultRecordRef<Faction>, float>();
|
||||
|
||||
[Inspectable, JsonProperty("overworldMusic"), Key(13)]
|
||||
public uint OverworldMusic;
|
||||
|
||||
[Inspectable, JsonProperty("combatMusic"), Key(14)]
|
||||
public uint CombatMusic;
|
||||
|
||||
[Inspectable, JsonProperty("bossMusic"), Key(15)]
|
||||
public uint BossMusic;
|
||||
// Keys 13 to 15 (overworldMusic, combatMusic, bossMusic) held Wwise ids; retired, never reuse them.
|
||||
|
||||
// Key 16 is reserved for Relations (operator ruling faction-relations-field).
|
||||
|
||||
|
|
@ -65,7 +58,7 @@ public class Faction
|
|||
// The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each
|
||||
// of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's
|
||||
// defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly);
|
||||
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery.
|
||||
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. Key 20 was assemblyDeviation (retired); never reuse it.
|
||||
[Inspectable, JsonProperty("assembly"), Key(19)]
|
||||
public AssemblyDistribution Assembly;
|
||||
|
||||
|
|
@ -73,6 +66,11 @@ public class Faction
|
|||
// Authored by AetherDb place-names, which restores the table on every apply.
|
||||
[Inspectable, JsonProperty("places"), Key(21)]
|
||||
public FactionPlace[] Places = Array.Empty<FactionPlace>();
|
||||
|
||||
// The Loadout this faction's boss flies; unset means the faction has no boss (ruling faction-boss-is-a-loadout-reference).
|
||||
// The one owner of which Loadout is its boss, read through Loadouts.BossOf.
|
||||
[Inspectable, JsonProperty("bossLoadout"), Key(22)]
|
||||
public CultRecordRef<Loadout> BossLoadout;
|
||||
}
|
||||
|
||||
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).
|
||||
|
|
@ -86,5 +84,8 @@ public class FactionPlace
|
|||
// treats an invalid entry as absent; AetherDb place-names refuses a table that holds one.
|
||||
public static bool IsValidName(string name) =>
|
||||
!string.IsNullOrEmpty(name) && name == name.Trim() && !name.StartsWith("EAC-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -1553,7 +1525,8 @@ public abstract class Entity
|
|||
|
||||
// Check whether the given item will fit when its origin is placed at the given coordinate. For hardpoint gear,
|
||||
// hardpoint is the one hardpoint the item would occupy there (HardpointAt); Tool gear occupies none.
|
||||
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint)
|
||||
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint,
|
||||
EquippedItem moving = null)
|
||||
{
|
||||
hardpoint = null;
|
||||
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
|
||||
|
|
@ -1566,13 +1539,13 @@ public abstract class Entity
|
|||
// general gear may take it until hardpoint gear claims it, which is the same rule
|
||||
// UnoccupiedSpace reports, so what generation is offered and what equipping accepts agree.
|
||||
var itemCoord = hullCoord + itemData.Shape.Rotate(i, item.Rotation);
|
||||
if (!hullData.InteriorCells[itemCoord] || GearOccupancy[itemCoord.x, itemCoord.y] != null)
|
||||
if (!hullData.InteriorCells[itemCoord] || !IsFree(itemCoord.x, itemCoord.y, moving))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
hardpoint = HardpointAt(itemData, hullData, hullCoord);
|
||||
hardpoint = HardpointAt(itemData, hullData, hullCoord, moving);
|
||||
if (hardpoint == null) return false;
|
||||
item.Rotation = hardpoint.Rotation;
|
||||
}
|
||||
|
|
@ -1580,27 +1553,33 @@ public abstract class Entity
|
|||
return true;
|
||||
}
|
||||
|
||||
// A cell is free when no gear occupies it, or when the only occupant is the item being moved: a re-place of
|
||||
// equipped gear is judged against the hull minus that item's own cells (the caller unequips, then equips).
|
||||
private bool IsFree(int x, int y, EquippedItem moving) => GearOccupancy[x, y] == null || GearOccupancy[x, y] == moving;
|
||||
|
||||
// The one placement rule for hardpoint gear. At this origin the item lies within a hardpoint of its type, turned to
|
||||
// that hardpoint's rotation (HardpointData.TakesAt, the fit rule); its own cells are free, since a hardpoint's
|
||||
// leftover cells may hold general gear (operator, 2026-09-30: "saving slots in a hardpoint can be a valid tradeoff
|
||||
// for crowded ships"); and that hardpoint holds no other hardpoint item, since it holds one at most.
|
||||
private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord)
|
||||
private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord, EquippedItem moving)
|
||||
{
|
||||
foreach (var hardpoint in hullData.Hardpoints)
|
||||
{
|
||||
if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) continue;
|
||||
var ownCellsFree = itemData.Shape.Coordinates
|
||||
.Select(v => hullCoord + itemData.Shape.Rotate(v, hardpoint.Rotation))
|
||||
.All(c => GearOccupancy[c.x, c.y] == null);
|
||||
.All(c => IsFree(c.x, c.y, moving));
|
||||
var holdsAnother = hardpoint.Shape.Coordinates
|
||||
.Select(v => GearOccupancy[hardpoint.Position.x + v.x, hardpoint.Position.y + v.y])
|
||||
.Any(occupant => occupant != null && occupant.Data.HardpointType != HardpointType.Tool);
|
||||
.Any(occupant => occupant != null && occupant != moving && occupant.Data.HardpointType != HardpointType.Tool);
|
||||
if (ownCellsFree && !holdsAnother) return hardpoint;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull
|
||||
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull. An item that
|
||||
// is already equipped here is judged without its own cells, so a gun can be moved within its own hardpoint;
|
||||
// TryEquip stays strict (it never sees an item as its own occupant), so equipping an equipped item twice is refused.
|
||||
public bool ItemFits(EquippableItem item, int2 hullCoord)
|
||||
{
|
||||
// Don't allow equipping while deployed
|
||||
|
|
@ -1608,7 +1587,7 @@ public abstract class Entity
|
|||
|
||||
var itemData = ItemManager.GetData(item);
|
||||
var hullData = ItemManager.GetData(Hull) as HullData;
|
||||
return ItemFits(itemData, hullData, item, hullCoord, out _);
|
||||
return ItemFits(itemData, hullData, item, hullCoord, out _, Equipment.FirstOrDefault(e => e.EquippableItem == item));
|
||||
}
|
||||
|
||||
public bool TryFindSpace(EquippableItem item, out int2 hullCoord)
|
||||
|
|
@ -1748,6 +1727,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 +1789,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 +1828,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 +1951,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 +2153,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 +2544,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 +2613,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 +2677,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 +2686,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;
|
||||
|
|
@ -243,4 +294,7 @@ public abstract class EntityPack
|
|||
|
||||
return _price;
|
||||
}
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(23)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,16 +32,13 @@ public static class Extensions
|
|||
|
||||
// Every loadable type assignable to this one. An assembly whose dependencies are missing (a Unity plugin
|
||||
// referencing UnityEngine outside Unity) still yields the types that do load.
|
||||
private static readonly Dictionary<Type, Type[]> ChildClasses = new Dictionary<Type, Type[]>();
|
||||
public static Type[] GetAllChildClasses(this Type type)
|
||||
// Read from parallel loaders (catalog validation, parallel tests), so the memo is concurrent.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Type, Type[]> ChildClasses = new System.Collections.Concurrent.ConcurrentDictionary<Type, Type[]>();
|
||||
public static Type[] GetAllChildClasses(this Type type) => ChildClasses.GetOrAdd(type, t => AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly =>
|
||||
{
|
||||
if (ChildClasses.TryGetValue(type, out var children)) return children;
|
||||
return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly =>
|
||||
{
|
||||
try { return assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t != null); }
|
||||
}).Where(type.IsAssignableFrom).ToArray();
|
||||
}
|
||||
try { return assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { return e.Types.Where(x => x != null); }
|
||||
}).Where(t.IsAssignableFrom).ToArray());
|
||||
|
||||
public static string SplitCamelCase(this string str) =>
|
||||
Regex.Replace(Regex.Replace(str, @"(\P{Ll})(\P{Ll}\p{Ll})", "$1 $2"), @"(\p{Ll})(\P{Ll})", "$1 $2");
|
||||
|
|
@ -180,6 +177,21 @@ public static class Extensions
|
|||
return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2);
|
||||
}
|
||||
|
||||
// NextGaussian truncated below mean + floorDeviations * deviation: draws until one clears that cutoff, at most 64 times,
|
||||
// then returns the cutoff itself. Exact for any reachable floor and bounded and deterministic per seed for an unreachable
|
||||
// one. A binned product's fills and assembly (ProductBin). Callers clamp, as with NextGaussian.
|
||||
public static float NextGaussianAbove(this ref Random random, float mean, float deviation, float floorDeviations)
|
||||
{
|
||||
var cutoff = mean + floorDeviations * deviation;
|
||||
for (var tries = 0; tries < 64; tries++)
|
||||
{
|
||||
var draw = random.NextGaussian(mean, deviation);
|
||||
if (draw >= cutoff) return draw;
|
||||
}
|
||||
|
||||
return cutoff;
|
||||
}
|
||||
|
||||
public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1;
|
||||
public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ using GameCult.Caching;
|
|||
using MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of
|
||||
// the design's roles. Which products exist is what a faction makes, so market segmentation and regional
|
||||
// progression are authored here rather than as duplicate item entries.
|
||||
// What a manufacturer sells of a generic design: its name, its flavor text, and either a line (how the maker builds each of the
|
||||
// design's roles) or a bin of one of its own lines (maker-design-tiers-allowed). Which products exist is what a faction makes, so
|
||||
// market segmentation and regional progression are authored here rather than as duplicate item entries.
|
||||
[CultDocument("aetheria.factionproductdata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class FactionProductData
|
||||
{
|
||||
|
|
@ -29,16 +29,27 @@ public class FactionProductData
|
|||
[Inspectable, JsonProperty("roles"), Key(5)]
|
||||
public List<ProductRole> Roles = new List<ProductRole>();
|
||||
|
||||
// This line's own final test (maker-design-tiers-allowed): a binned or premium line is a better assembly as well as better
|
||||
// parts. Null assembles as its maker does.
|
||||
// This line's own final test (maker-design-tiers-allowed): a premium line is a better assembly as well as better parts.
|
||||
// Null assembles as its maker does. A binned product leaves it null and reads its line's.
|
||||
[Inspectable, JsonProperty("assembly"), Key(6)]
|
||||
public AssemblyDistribution Assembly;
|
||||
|
||||
// Set on a binned product: the line it bins and the floor above which it takes that line's output. A bin authors no Roles and
|
||||
// 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
|
||||
// the technology the manufacturer puts in and whose standard deviation is their quality control. A market
|
||||
// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell,
|
||||
// because a part here carries quality and nothing else.
|
||||
// segment is a bin of the line (ProductBin), not a second product with a role pumped up; what makes a part better is the
|
||||
// flavor text's to tell, because a part here carries quality and nothing else.
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class ProductRole
|
||||
{
|
||||
|
|
@ -50,6 +61,22 @@ public class ProductRole
|
|||
|
||||
[Inspectable, JsonProperty("standardDeviation"), Key(2)]
|
||||
public float StandardDeviation = .15f;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(3)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself
|
||||
// unbinned) and the floor, in standard deviations above the mean of each of the line's distributions, below which the bin
|
||||
// takes nothing. 0 is the top half of the line, .67 the top quarter.
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class ProductBin
|
||||
{
|
||||
[Inspectable, JsonProperty("line"), Key(0)]
|
||||
public CultRecordRef<FactionProductData> Line;
|
||||
|
||||
[Inspectable, JsonProperty("floor"), Key(1)]
|
||||
public float Floor;
|
||||
}
|
||||
|
||||
// How well a maker puts the thing together: the technology and quality control of final assembly. A lot's own quality is drawn
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -775,7 +801,7 @@ public static class FireControl
|
|||
// Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the
|
||||
// layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity
|
||||
// plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's
|
||||
// own (zone seed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
|
||||
// own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
|
||||
// InstantWeapon has already paid. A layer whose item has no blast radius lays nothing.
|
||||
public static void Lay(Weapon weapon, EquippedItem item, Entity source)
|
||||
{
|
||||
|
|
@ -789,7 +815,7 @@ public static class FireControl
|
|||
|
||||
var id = zone.NextBodyId();
|
||||
var half = weapon.Spread / 2f;
|
||||
var angle = radians(SimulationDice.For(zone.CombatSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
|
||||
var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
|
||||
var s = sin(angle);
|
||||
var c = cos(angle);
|
||||
var mount = MountDirection(item).xz;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@ public class Galaxy
|
|||
private Action<string> Log { get; }
|
||||
public bool IsPrelude { get; }
|
||||
|
||||
// The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy
|
||||
// restored from a save.
|
||||
// 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;
|
||||
|
|
@ -51,12 +55,17 @@ public class Galaxy
|
|||
_cache = cultCache;
|
||||
Log = log;
|
||||
Background = savedGame.Background;
|
||||
Seed = savedGame.Seed;
|
||||
|
||||
Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray();
|
||||
for (var i = 0; i < Factions.Length; i++)
|
||||
{
|
||||
FactionRelationships[Factions[i]] = savedGame.Relationships[i];
|
||||
}
|
||||
if (savedGame.Standings != null)
|
||||
foreach (var standing in savedGame.Standings)
|
||||
FactionRelationships[cultCache.Get(standing.Faction)] = standing.Relationship;
|
||||
else
|
||||
// A save before Standings wrote Relationships in an order it did not record; it is right only for a
|
||||
// galaxy that never reordered, and is all that save holds.
|
||||
for (var i = 0; i < Factions.Length; i++)
|
||||
FactionRelationships[Factions[i]] = savedGame.Relationships[i];
|
||||
|
||||
var savedZones = savedGame.Zones.Select(zone => cultCache.Get(zone)).ToArray();
|
||||
Zones = savedZones.Select(zone =>
|
||||
|
|
@ -88,6 +97,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();
|
||||
}
|
||||
|
||||
|
|
@ -96,15 +108,15 @@ public class Galaxy
|
|||
SectorBackgroundSettings background,
|
||||
CultCache cache,
|
||||
Action<string> log,
|
||||
Action<string> progressCallback = null,
|
||||
uint seed = 0)
|
||||
Action<string> progressCallback,
|
||||
uint seed)
|
||||
{
|
||||
_cache = cache;
|
||||
IsPrelude = false;
|
||||
Background = background;
|
||||
Log = log;
|
||||
var factions = cache.GetAll<Faction>();
|
||||
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
|
||||
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
|
||||
var random = new Random(Seed);
|
||||
Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray();
|
||||
foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral;
|
||||
|
|
@ -126,6 +138,8 @@ public class Galaxy
|
|||
|
||||
PlaceFactionsMain(settings.BossCount, progressCallback);
|
||||
|
||||
BuildProvinces(settings.ProvinceZones);
|
||||
|
||||
CalculateFactionInfluence(progressCallback);
|
||||
|
||||
NamePlaces(ref random, progressCallback);
|
||||
|
|
@ -134,6 +148,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));
|
||||
|
|
@ -163,15 +210,15 @@ public class Galaxy
|
|||
PlayerSettings playerSettings,
|
||||
DirectoryInfo narrativeDirectory,
|
||||
Action<string> log,
|
||||
Action<string> progressCallback = null,
|
||||
uint seed = 0)
|
||||
Action<string> progressCallback,
|
||||
uint seed)
|
||||
{
|
||||
_cache = cache;
|
||||
IsPrelude = true;
|
||||
|
||||
Background = background;
|
||||
Log = log;
|
||||
Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
|
||||
Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
|
||||
var random = new Random(Seed);
|
||||
|
||||
var factions = new List<Faction>();
|
||||
|
|
@ -255,6 +302,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");
|
||||
|
|
@ -288,7 +338,7 @@ public class Galaxy
|
|||
progressCallback?.Invoke("Placing Factions");
|
||||
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
|
||||
|
||||
// Choose some megas to have bosses placed: the factions with a boss to fly, a Boss hull and its Loadout
|
||||
// Choose some megas to have bosses placed: the factions whose boss Loadout resolves
|
||||
var bossMegas = Factions
|
||||
.Where(m => Loadouts.BossOf(_cache, m, out _) != null)
|
||||
.Take(bossCount)
|
||||
|
|
@ -302,6 +352,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)
|
||||
|
|
@ -323,6 +377,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");
|
||||
|
|
@ -570,10 +639,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;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ public sealed class GalaxyStage
|
|||
_clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue));
|
||||
}
|
||||
|
||||
// The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero.
|
||||
// The run's one minter: the scenario's seed, or the clock's when the scenario's is zero, never zero. Galaxy carries
|
||||
// what it mints and SavedGame keeps it, so Continue rolls the same drops.
|
||||
public uint Seed { get; private set; }
|
||||
|
||||
internal void SeedFrom(Scenario scenario)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ public class InputLayout
|
|||
Union(0, typeof(InputLayoutRowSpacer)),
|
||||
Union(1, typeof(InputLayoutKeyRow))
|
||||
]
|
||||
public abstract class InputLayoutRow { }
|
||||
public abstract class InputLayoutRow {
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(1)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class InputLayoutRowSpacer : InputLayoutRow
|
||||
|
|
@ -52,6 +55,9 @@ public class InputLayoutKeyRow : InputLayoutRow
|
|||
public abstract class InputLayoutColumn
|
||||
{
|
||||
[Key(0), JsonProperty("width")] public float Width;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(5)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
[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]
|
||||
|
|
@ -420,11 +420,7 @@ public abstract class EquippableItemData : CraftedItemData
|
|||
[Inspectable, CultInspectorAssetGuid, JsonProperty("actionIcon"), Key(20)]
|
||||
public string ActionBarIcon;
|
||||
|
||||
[Inspectable, JsonProperty("soundBank"), Key(21)]
|
||||
public uint SoundBank;
|
||||
|
||||
[Inspectable, JsonProperty("audioStats"), Key(22)]
|
||||
public List<AudioStat> AudioStats = new List<AudioStat>();
|
||||
// Keys 21 (soundBank) and 22 (audioStats) held Wwise ids; audio is native Unity plus AquaSynth now. Retired; never reuse them.
|
||||
|
||||
[IgnoreMember]
|
||||
public abstract HardpointType HardpointType { get; }
|
||||
|
|
@ -458,16 +454,6 @@ public abstract class EquippableItemData : CraftedItemData
|
|||
}
|
||||
}
|
||||
|
||||
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class AudioStat
|
||||
{
|
||||
[Inspectable, JsonProperty("parameter"), Key(0)]
|
||||
public uint Parameter;
|
||||
|
||||
[Inspectable, JsonProperty("stat"), Key(1)]
|
||||
public PerformanceStat Stat = new PerformanceStat();
|
||||
}
|
||||
|
||||
[CultDocument("aetheria.geardata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class GearData : EquippableItemData
|
||||
{
|
||||
|
|
@ -555,11 +541,11 @@ public class HullData : EquippableItemData
|
|||
[Inspectable, JsonProperty("visual"), Key(32)]
|
||||
public CultRecordRef<ShipAuthoring> Visual;
|
||||
|
||||
// Only bosses fly this hull. Which factions it is a boss for comes from its products' makers
|
||||
// (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. A boss's gear is its faction's
|
||||
// fixed Loadout on this hull's product (Loadouts.BossOf), never generated.
|
||||
[Inspectable, JsonProperty("boss"), Key(33)]
|
||||
public bool Boss;
|
||||
// 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,
|
||||
|
|
@ -652,6 +638,9 @@ public class HardpointData
|
|||
}
|
||||
|
||||
private static Dictionary<HardpointType, float3> _tintColors;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(8)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// What a PerformanceStat reads to resolve one number: the lot it prices quality against, and the per-context
|
||||
|
|
@ -697,6 +686,9 @@ public class StatTerm
|
|||
// The design role whose part quality this term reads; unset reads the item's effective quality (Lot.EffectiveQuality).
|
||||
// Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension.
|
||||
[Inspectable, JsonProperty("role"), Key(2)] public string Role;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(3)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -121,6 +125,9 @@ public class ItemManager
|
|||
// Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its
|
||||
// final assembly, drawn from the product's assembly distribution, else its maker's, else AssemblyDistribution's defaults,
|
||||
// and each of the design's roles is filled with a part whose quality is drawn from the product's distribution for it.
|
||||
// A binned product (maker-design-tiers-allowed) has no distributions of its own: it is its line's, derived here on every
|
||||
// mint, with each draw (the assembly and every fill) truncated below the line's mean + Bin.Floor deviations. The lot is
|
||||
// still the bin's: its Product, its title and so its brand.
|
||||
public int CreateLot(FactionProductData product) => Mint(product, null);
|
||||
|
||||
// The same lot at a stated quality: the assembly and every role fill are that quality and nothing is drawn, so a staged
|
||||
|
|
@ -130,29 +137,37 @@ public class ItemManager
|
|||
private int Mint(FactionProductData product, float? stated)
|
||||
{
|
||||
var design = ItemData.Get(product.Design);
|
||||
var assembly = product.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
|
||||
var line = product.Bin != null ? ItemData.Get(product.Bin.Line) : null;
|
||||
if (product.Bin != null && line == null)
|
||||
Log("A binned product names a line the catalog does not hold; it mints as a product with no roles of its own.");
|
||||
var source = line ?? product;
|
||||
var floor = line != null ? product.Bin.Floor : (float?) null;
|
||||
var assembly = source.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
|
||||
var lot = new Lot
|
||||
{
|
||||
Design = ItemData.RefOf<ItemData>(design),
|
||||
Origin = new Attributed { Faction = product.Manufacturer },
|
||||
Product = ItemData.RefOf(product),
|
||||
Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1),
|
||||
Quality = stated ?? clamp(Draw(assembly.Mean, assembly.StandardDeviation, floor), .01f, 1),
|
||||
Roles = new List<RoleFill>()
|
||||
};
|
||||
if (design.Roles != null)
|
||||
foreach (var role in design.Roles)
|
||||
{
|
||||
var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
|
||||
var build = source.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
|
||||
lot.Roles.Add(new RoleFill
|
||||
{
|
||||
Role = role.Name,
|
||||
Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
|
||||
Quality = stated ?? clamp(Draw(build.Mean, build.StandardDeviation, floor), .01f, 1)
|
||||
});
|
||||
}
|
||||
|
||||
return Lots.Add(lot);
|
||||
}
|
||||
|
||||
private float Draw(float mean, float deviation, float? floorDeviations) =>
|
||||
floorDeviations is { } floor ? Random.NextGaussianAbove(mean, deviation, floor) : Random.NextGaussian(mean, deviation);
|
||||
|
||||
// No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`.
|
||||
public int CreateLot(CraftedItemData design, CultRecordRef<Faction> maker, float quality)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ using CultMath;
|
|||
// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout).
|
||||
// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen
|
||||
// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which
|
||||
// keeps a loadout portable across galaxies. Authored catalog data: written in CultCache Studio or by the AetherDb and
|
||||
// editor capture commands, read at runtime.
|
||||
// keeps a loadout portable across galaxies. A faction's boss flies one (Faction.BossLoadout). Authored catalog data:
|
||||
// written in CultCache Studio or by the AetherDb and editor capture commands, read at runtime.
|
||||
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
|
||||
public class Loadout
|
||||
{
|
||||
|
|
@ -28,9 +28,13 @@ public class LoadoutSlot
|
|||
[Key(1)] public ItemRotation Rotation;
|
||||
[Key(2)] public CultRecordRef<EquippableItemData> Design;
|
||||
[Key(3)] public CultRecordRef<FactionProductData> Product; // unset: design-only, chosen at materialization
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(4)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
public enum BossRefusal { None, NoBossHull, NoLoadout }
|
||||
// Why a faction has no boss Loadout to build; see Loadouts.BossOf.
|
||||
public enum BossRefusal { None, Unset, Missing }
|
||||
|
||||
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout.
|
||||
public static class Loadouts
|
||||
|
|
@ -71,50 +75,52 @@ public static class Loadouts
|
|||
// A loadout's key derives from its name, so capturing the same name again addresses the same record.
|
||||
public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name);
|
||||
|
||||
// The one owner of what a faction's boss flies: the Loadout whose HullProduct is the product of the faction's first boss
|
||||
// hull (LoadoutGenerator.BossHullsOf, dearest first), first by Name ordinal. Keyed on the boss hull's product through
|
||||
// Loadout.HullProduct, never generated; reads no Prefab, Visual or Random. Null with the refusal when there is no boss
|
||||
// hull or no Loadout for it.
|
||||
// The one resolver of a faction's boss reference (ruling faction-boss-is-a-loadout-reference): the Loadout
|
||||
// Faction.BossLoadout names. The boss is never generated, so this reads nothing but the reference and the catalog. Null with
|
||||
// Unset when the faction sets none, and null with Missing when it sets one the catalog does not hold.
|
||||
public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
|
||||
{
|
||||
var hulls = LoadoutGenerator.BossHullsOf(items, faction);
|
||||
if (hulls.Length == 0)
|
||||
if (!faction.BossLoadout.IsSet())
|
||||
{
|
||||
refusal = BossRefusal.NoBossHull;
|
||||
refusal = BossRefusal.Unset;
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = items.RefOf(hulls[0].product).Key;
|
||||
var loadout = items.GetAll<Loadout>()
|
||||
.Where(candidate => candidate.HullProduct.IsSet() && candidate.HullProduct.Key.Equals(key))
|
||||
.OrderBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
refusal = loadout == null ? BossRefusal.NoLoadout : BossRefusal.None;
|
||||
var loadout = items.Get(faction.BossLoadout);
|
||||
refusal = loadout == null ? BossRefusal.Missing : BossRefusal.None;
|
||||
return loadout;
|
||||
}
|
||||
|
||||
// Names the field that is missing; echoes no input value and no record key.
|
||||
// Names the faction's field, and echoes no key or name of the input.
|
||||
public static string Describe(BossRefusal refusal) => refusal switch
|
||||
{
|
||||
BossRefusal.NoBossHull => "makes no ship hull with HullData.Boss set",
|
||||
BossRefusal.NoLoadout => "no Loadout's HullProduct names its boss hull's product",
|
||||
BossRefusal.Unset => "sets no Faction.BossLoadout",
|
||||
BossRefusal.Missing => "names a Faction.BossLoadout that is not in the catalog",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
// 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)
|
||||
|
|
@ -122,38 +128,53 @@ 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 is listed; on any failure
|
||||
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
|
||||
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
|
||||
// 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
|
||||
// first: what the catalog alone decides (designs, pinned products, hull kind, weapon groups), then whether the cells
|
||||
// fit, tried on a throwaway item manager with its own ledger and stream, and only then the choice of a design-only
|
||||
// slot's product (choose may draw) and the minting of lots. So a loadout that cannot be built mints no lot in the live
|
||||
// ledger and draws nothing from itemManager.Random or from choose. The one exception is a design-only slot with no
|
||||
// available product, which only choose can tell: it is found after an earlier slot's choice, and it too mints nothing.
|
||||
// How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
|
||||
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
|
||||
// boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
|
||||
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
|
||||
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
|
||||
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
|
||||
// so the next commit drops them. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
|
||||
// put; a station hull is refused. The entity is stocked with the crates its weapons draw (StockAmmunition) unless the caller
|
||||
// authors the cargo itself and passes stockAmmunition false. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
|
||||
// padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
|
||||
// one for a fit its checks rest on).
|
||||
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null)
|
||||
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null, bool stockAmmunition = true)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var reported = failures.Count;
|
||||
|
||||
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
|
||||
var hullSettled = Settle(itemManager, loadout.Hull, loadout.HullProduct, "hull", failures);
|
||||
var hullData = cache.Get(loadout.Hull);
|
||||
if (hullData is { HullType: HullType.Station })
|
||||
failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
|
||||
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
|
||||
var slotsSettled = loadout.Slots.Select(slot => Settle(itemManager, slot.Design, slot.Product, Cell(slot), failures)).ToArray();
|
||||
var groups = loadout.WeaponGroups ?? new int[0][];
|
||||
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
|
||||
if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}");
|
||||
|
|
@ -165,12 +186,58 @@ public static class Loadouts
|
|||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
// 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(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;
|
||||
|
||||
var hullBuild = Build(itemManager, hullSettled, choose, quality, failures);
|
||||
var slotBuilds = slotsSettled.Select(settled => Build(itemManager, settled, choose, quality, failures)).ToArray();
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var entity = Assemble(itemManager, liveZone, hullData, loadout, hullBuild, slotBuilds, failures, out var equipped);
|
||||
if (entity == null) return null;
|
||||
|
||||
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
|
||||
for (var g = 0; g < groupCount; g++)
|
||||
{
|
||||
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
|
||||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
entity.WeaponGroups = built;
|
||||
if (stockAmmunition) StockAmmunition(itemManager, entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
// The crates an entity's weapons reload from: LoadoutGenerator.AmmunitionCrates of each distinct AmmoType, stopping when no hold takes
|
||||
// another. The one owner of ammunition stock: every path that fits weapons onto a new entity (the generator, a Loadout's Materialize,
|
||||
// so a scenario's Fit, a boss, a console-built ship) ends here. A saved or packed entity is never stocked: its cargo is its own.
|
||||
// Draws nothing from the random stream, so adding it moves no other roll.
|
||||
public static void StockAmmunition(ItemManager itemManager, Entity entity)
|
||||
{
|
||||
foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct())
|
||||
{
|
||||
var crates = new SimpleCommodity { Data = new CultRecordRef<ItemData>(key), Quantity = LoadoutGenerator.AmmunitionCrates };
|
||||
if (!entity.CargoBays.Any(bay => bay.TryStore(crates)))
|
||||
itemManager.Log($"{entity.Name}: no room for {LoadoutGenerator.AmmunitionCrates} crates of {key.Value}.");
|
||||
}
|
||||
}
|
||||
|
||||
// The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of
|
||||
// a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every
|
||||
// misfit listed when any cell does not take its gear.
|
||||
private static Entity Assemble(ItemManager itemManager, Zone liveZone, HullData hullData, Loadout loadout,
|
||||
Func<CraftedItemInstance> hullBuild, Func<CraftedItemInstance>[] slotBuilds, List<string> failures, out EquippedItem[] equipped)
|
||||
{
|
||||
var reported = failures.Count;
|
||||
var cache = itemManager.ItemData;
|
||||
var hull = (EquippableItem) hullBuild();
|
||||
var settings = itemManager.GameplaySettings.DefaultEntitySettings;
|
||||
Entity entity = hullData.HullType == HullType.Turret
|
||||
? new OrbitalEntity(itemManager, liveZone, hull, default, settings)
|
||||
: new Ship(itemManager, liveZone, hull, settings);
|
||||
var equipped = new EquippedItem[loadout.Slots.Count];
|
||||
equipped = new EquippedItem[loadout.Slots.Count];
|
||||
for (var i = 0; i < loadout.Slots.Count; i++)
|
||||
{
|
||||
var slot = loadout.Slots[i];
|
||||
|
|
@ -187,26 +254,22 @@ public static class Loadouts
|
|||
.Concat(entity.DockingBays)
|
||||
.First(item => item.EquippableItem == unit);
|
||||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
|
||||
for (var g = 0; g < groupCount; g++)
|
||||
{
|
||||
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
|
||||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
entity.WeaponGroups = built;
|
||||
return entity;
|
||||
return failures.Count > reported ? null : entity;
|
||||
}
|
||||
|
||||
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
|
||||
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
|
||||
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
|
||||
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
|
||||
// null (nothing available) or a product of another design is a failure.
|
||||
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
|
||||
List<string> failures, float? quality = null) where T : CraftedItemData
|
||||
// What the catalog alone says about one design of a loadout: the design, and whether it pins a product, has no product
|
||||
// at all, or needs a chooser to name one. Nothing is minted and nothing drawn.
|
||||
private sealed class Settled
|
||||
{
|
||||
public CraftedItemData Data;
|
||||
public FactionProductData Pinned; // the product the loadout names, if it does
|
||||
public bool Unbranded; // a design no product makes
|
||||
public string Where;
|
||||
}
|
||||
|
||||
// Null with the failure listed.
|
||||
private static Settled Settle<T>(ItemManager itemManager, CultRecordRef<T> design, CultRecordRef<FactionProductData> product,
|
||||
string where, List<string> failures) where T : CraftedItemData
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var data = cache.Get(design);
|
||||
|
|
@ -216,35 +279,69 @@ public static class Loadouts
|
|||
return null;
|
||||
}
|
||||
|
||||
FactionProductData made;
|
||||
if (product.IsSet())
|
||||
{
|
||||
made = cache.Get(product);
|
||||
var made = cache.Get(product);
|
||||
if (made == null)
|
||||
{
|
||||
failures.Add($"{where}: product {product.Key} is not in the catalog");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key)))
|
||||
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality));
|
||||
made = choose(data);
|
||||
if (made == null)
|
||||
if (!made.Design.Key.Equals(design.Key))
|
||||
{
|
||||
failures.Add($"{where}: no available product of {data.Name}");
|
||||
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
|
||||
return null;
|
||||
}
|
||||
return new Settled { Data = data, Pinned = made, Where = where };
|
||||
}
|
||||
|
||||
if (!made.Design.Key.Equals(design.Key))
|
||||
return new Settled
|
||||
{
|
||||
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
|
||||
return null;
|
||||
Data = data, Where = where,
|
||||
Unbranded = !cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key))
|
||||
};
|
||||
}
|
||||
|
||||
// A design made with no product, at UnbrandedQuality: no maker, no draw. It is also the stand-in the fit trial builds
|
||||
// every design as, since a brand never changes a shape. A settled design that failed is never built.
|
||||
private static Func<CraftedItemInstance> Unbranded(ItemManager itemManager, Settled settled) =>
|
||||
() => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, UnbrandedQuality));
|
||||
|
||||
// How a settled design is built, or null with the failure listed; nothing is minted until the returned builder runs.
|
||||
// A design-only slot is where choose is asked, and may draw.
|
||||
private static Func<CraftedItemInstance> Build(ItemManager itemManager, Settled settled,
|
||||
Func<CraftedItemData, FactionProductData> choose, float? quality, List<string> failures)
|
||||
{
|
||||
if (settled == null) return null;
|
||||
if (settled.Unbranded)
|
||||
return () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, quality ?? UnbrandedQuality));
|
||||
var made = settled.Pinned;
|
||||
if (made == null)
|
||||
{
|
||||
made = choose(settled.Data);
|
||||
if (made == null)
|
||||
{
|
||||
failures.Add($"{settled.Where}: no available product of {settled.Data.Name}");
|
||||
return null;
|
||||
}
|
||||
if (!made.Design.Key.Equals(itemManager.ItemData.RefOf(settled.Data).Key))
|
||||
{
|
||||
failures.Add($"{settled.Where}: product {made.Name} is not a product of {settled.Data.Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made));
|
||||
}
|
||||
|
||||
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
|
||||
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
|
||||
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
|
||||
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
|
||||
// null (nothing available) or a product of another design is a failure.
|
||||
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
|
||||
List<string> failures, float? quality = null) where T : CraftedItemData =>
|
||||
Build(itemManager, Settle(itemManager, design, product, where, failures), choose, quality, failures);
|
||||
|
||||
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ public class LoadoutGenerator
|
|||
public const float ShipConsumableChance = .1f;
|
||||
public const int ShipConsumableUnits = 1;
|
||||
|
||||
// Ammunition-fed weapons reload from cargo (ruling ballistics-real-ammo), so a generated ship carries this many crates of every
|
||||
// distinct AmmoType its weapons draw: enough to fight on past the first magazine, few enough to leave the rest of the hold.
|
||||
public const int AmmunitionCrates = 3;
|
||||
|
||||
public LoadoutGenerator(
|
||||
ref Random random,
|
||||
ItemManager itemManager,
|
||||
|
|
@ -56,6 +60,7 @@ public class LoadoutGenerator
|
|||
OutfitEntity(entity);
|
||||
EquipPickup(entity);
|
||||
StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits);
|
||||
Loadouts.StockAmmunition(ItemManager, entity);
|
||||
return EntitySerializer.Pack(entity);
|
||||
}
|
||||
|
||||
|
|
@ -71,12 +76,16 @@ public class LoadoutGenerator
|
|||
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
|
||||
entity.Faction = Faction;
|
||||
OutfitEntity(entity);
|
||||
Loadouts.StockAmmunition(ItemManager, entity);
|
||||
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 +94,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);
|
||||
|
|
@ -176,27 +188,26 @@ public class LoadoutGenerator
|
|||
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : CraftedItemData
|
||||
{
|
||||
var (available, preferManufacturers) = AvailableProducts(filter, required);
|
||||
var shares = BinShares();
|
||||
return available.WeightedRandomElements(ref Random, entry =>
|
||||
(preferManufacturers ? ProductWeight(entry.product) : 1) *
|
||||
Weight(entry.product, shares, preferManufacturers) *
|
||||
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
|
||||
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
|
||||
count);
|
||||
}
|
||||
|
||||
// An item is on offer only through a product, so a design no product makes is never drawn, stocked or chosen; it reaches a ship
|
||||
// only through a Loadout (ruling off-market-is-general).
|
||||
// The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does
|
||||
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss
|
||||
// hull is never on offer, so it is never station stock, ordinary traffic or a generated scenario fit; a Boss hull
|
||||
// reaches a ship only through its faction's boss Loadout (Loadouts.BossOf).
|
||||
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
|
||||
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : CraftedItemData
|
||||
{
|
||||
var hulls = SoldHulls(ItemManager.ItemData);
|
||||
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.design is HullData { Boss: true }) &&
|
||||
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();
|
||||
|
|
@ -212,27 +223,14 @@ public class LoadoutGenerator
|
|||
return (available, preferManufacturers);
|
||||
}
|
||||
|
||||
// The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the
|
||||
// faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss
|
||||
// hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. Its readers are
|
||||
// Loadouts.BossOf and AetherDb factions.
|
||||
public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) =>
|
||||
faction == null
|
||||
? Array.Empty<(FactionProductData, HullData)>()
|
||||
: items.GetAll<FactionProductData>()
|
||||
.Where(product => items.Get(product.Manufacturer) == faction)
|
||||
.Select(product => (product, hull: items.Get(product.Design) as HullData))
|
||||
.Where(entry => entry.hull is { Boss: true, HullType: HullType.Ship })
|
||||
.OrderByDescending(entry => entry.hull.Price).ThenBy(entry => entry.hull.Name, StringComparer.Ordinal).ThenBy(entry => entry.product.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
|
||||
// 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 ||
|
||||
|
|
@ -245,16 +243,52 @@ 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));
|
||||
|
||||
// How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct.
|
||||
public float ProductWeight(FactionProductData product) => ManufacturerPreference(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
|
||||
// the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design.
|
||||
public float ProductWeight(FactionProductData product) => Weight(product, BinShares(), true);
|
||||
|
||||
// The one weight, with the share of the stock computed from shares (BinShares, once per weighting pass). A draw that has
|
||||
// fallen back to any manufacturer weighs by share alone: the maker's preference no longer applies, its bins' shares still do.
|
||||
private float Weight(FactionProductData product, Dictionary<string, float> shares, bool preferManufacturers) =>
|
||||
(preferManufacturers ? ManufacturerPreference(product.Manufacturer) : 1) * Share(product, shares);
|
||||
|
||||
// 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 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 && IsOffered(ItemManager.ItemData, product))
|
||||
.GroupBy(product => product.Bin.Line.Key.Value)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor)));
|
||||
|
||||
private float Share(FactionProductData product, Dictionary<string, float> shares)
|
||||
{
|
||||
if (product.Bin != null) return PassFraction(product.Bin.Floor);
|
||||
return max(0f, 1 - shares.GetValueOrDefault(ItemManager.ItemData.RefOf(product).Key.Value));
|
||||
}
|
||||
|
||||
// The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the
|
||||
// products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight;
|
||||
// null when none, and uniform over them when every weight is 0. Candidates are ordered by name so the draw's
|
||||
// null when none, and by share alone when every weight is 0. Candidates are ordered by name so the draw's
|
||||
// enumeration, and so its result, never depends on record keys. All of the policy is this body.
|
||||
public FactionProductData ChooseProduct(CraftedItemData design)
|
||||
{
|
||||
|
|
@ -264,8 +298,9 @@ public class LoadoutGenerator
|
|||
.OrderBy(product => product.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (candidates.Length == 0) return null;
|
||||
var uniform = candidates.All(product => ProductWeight(product) <= 0);
|
||||
return candidates.WeightedRandomElements(ref Random, product => uniform ? 1 : ProductWeight(product), 1)[0];
|
||||
var shares = BinShares();
|
||||
var uniform = candidates.All(product => Weight(product, shares, true) <= 0);
|
||||
return candidates.WeightedRandomElements(ref Random, product => Weight(product, shares, !uniform), 1)[0];
|
||||
}
|
||||
|
||||
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -92,6 +92,9 @@ public class Lot
|
|||
foreach (var fill in Roles) sum += Math.Min(fill.Quality, Quality);
|
||||
return sum / Roles.Count;
|
||||
}
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(5)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// One filled slot: the design's role name and how good the part that went into it turned out to be. Stats read it through
|
||||
|
|
@ -101,6 +104,9 @@ public class RoleFill
|
|||
{
|
||||
[Key(0)] public string Role;
|
||||
[Key(1)] public float Quality;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// Who built a lot, where, and from what. Terminus mints Attributed placeholders; Produced and Extracted are the
|
||||
|
|
|
|||
144
Assets/Scripts/ServerShared/Recipes.cs
Normal file
144
Assets/Scripts/ServerShared/Recipes.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(5)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
@ -44,6 +44,7 @@ public static class RunStart
|
|||
if (!stage.PlayerPlaced)
|
||||
failures.Add("player: the scenario places no player");
|
||||
if (failures.Count > reported) return null;
|
||||
stage.StockUnauthoredAmmunition();
|
||||
|
||||
var player = stage.PlayerShip;
|
||||
player.IsPlayerShip = true;
|
||||
|
|
@ -55,6 +56,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 +69,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,11 +45,55 @@ public class SavedGame
|
|||
[Key(11)]
|
||||
public bool IsTutorial;
|
||||
|
||||
// Read only by a save written before Standings: its entries follow galaxy.Factions order, not Factions order, so
|
||||
// Galaxy trusts it only when the two orders cannot differ (see Galaxy's save constructor).
|
||||
[Key(12)]
|
||||
public FactionRelationship[] Relationships;
|
||||
|
||||
[Key(13)]
|
||||
public int Credits;
|
||||
|
||||
// The run's seed (Galaxy.Seed), minted once by GalaxyStage.SeedFrom. Written only by RunSave.Capture and read only
|
||||
// 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;
|
||||
|
||||
// Each faction's standing, keyed by the faction itself so no list order can misplace it. Written by RunSave.Capture;
|
||||
// null in a save written before it, which then reads Relationships.
|
||||
[Key(16)]
|
||||
public List<SavedStanding> Standings;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SavedStanding
|
||||
{
|
||||
[Key(0)]
|
||||
public CultRecordRef<Faction> Faction;
|
||||
|
||||
[Key(1)]
|
||||
public FactionRelationship Relationship;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SavedProvince
|
||||
{
|
||||
[Key(0)]
|
||||
public int Seed;
|
||||
|
||||
[Key(1)]
|
||||
public CultRecordRef<ProvinceData> Type;
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
// The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records;
|
||||
|
|
@ -66,7 +110,8 @@ public static class RunSave
|
|||
DiscoveredZones = galaxy.DiscoveredZones.Select(dz => Array.IndexOf(galaxy.Zones, dz)).ToArray(),
|
||||
Background = galaxy.Background,
|
||||
Factions = factions.Select(f => cache.RefOf(f)).ToArray(),
|
||||
Relationships = galaxy.Factions.Select(f => galaxy.FactionRelationships[f]).ToArray(),
|
||||
Standings = galaxy.Factions.Select(f => new SavedStanding
|
||||
{Faction = cache.RefOf(f), Relationship = galaxy.FactionRelationships[f]}).ToList(),
|
||||
HomeZones = galaxy.HomeZones.ToDictionary(
|
||||
x => Array.IndexOf(factions, x.Key),
|
||||
x => Array.IndexOf(galaxy.Zones, x.Value)),
|
||||
|
|
@ -79,7 +124,8 @@ public static class RunSave
|
|||
Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit),
|
||||
IsTutorial = galaxy.IsPrelude,
|
||||
ActionBarBindings = actionBar,
|
||||
Credits = credits
|
||||
Credits = credits,
|
||||
Seed = galaxy.Seed
|
||||
};
|
||||
|
||||
// A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over.
|
||||
|
|
@ -90,8 +136,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);
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +164,7 @@ public static class RunSave
|
|||
var named = designs.Where(key => key.IsSet()).Distinct()
|
||||
.Where(key => cache.Get<ItemData>(key) == null)
|
||||
.Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray();
|
||||
if (named.Length == 0) return;
|
||||
if (named.Length == 0) { RequireLayouts(cache, game); return; }
|
||||
const string modHull = "mod-hull:";
|
||||
var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray();
|
||||
var others = named.Where(key => !key.StartsWith(modHull, StringComparison.Ordinal)).ToArray();
|
||||
|
|
@ -125,6 +174,35 @@ public static class RunSave
|
|||
". Reinstall them, or start a new game.");
|
||||
}
|
||||
|
||||
// Continue's second gate: a hull's cells or hardpoints edited under a saved run must still hold what the run
|
||||
// equipped on it (a package edited in place keeps its key, so the design check above passes). Each saved piece of
|
||||
// gear must sit where equipping would accept it on the hull as the catalog holds it now, by the same rules
|
||||
// (HardpointData.TakesAt for hardpoint gear, interior cells for tools); occupancy is not rechecked, the save
|
||||
// carries only what equipping admitted.
|
||||
private static void RequireLayouts(CultCache cache, SavedGame game)
|
||||
{
|
||||
var moved = new SortedSet<string>(StringComparer.Ordinal);
|
||||
void Check(EntityPack pack)
|
||||
{
|
||||
if (pack.Hull.Data.Key.IsSet() && cache.Get<ItemData>(pack.Hull.Data.Key) is HullData hull)
|
||||
foreach (var (position, item) in pack.Equipment.Concat(pack.CargoBays).Concat(pack.DockingBays))
|
||||
{
|
||||
if (!item.Data.Key.IsSet() || !(cache.Get<ItemData>(item.Data.Key) is EquippableItemData design)) continue;
|
||||
var fits = design.HardpointType == HardpointType.Tool
|
||||
? design.Shape.Coordinates.All(cell => hull.InteriorCells[position + design.Shape.Rotate(cell, item.Rotation)])
|
||||
: hull.Hardpoints.Any(hardpoint => hardpoint.TakesAt(design, position - hardpoint.Position));
|
||||
if (!fits) moved.Add(hull.Name);
|
||||
}
|
||||
foreach (var child in pack.Children) Check(child);
|
||||
}
|
||||
foreach (var zoneRef in game.Zones ?? Array.Empty<CultRecordRef<SavedZone>>())
|
||||
foreach (var pack in cache.Get(zoneRef).Contents?.Entities ?? new List<EntityPack>())
|
||||
Check(pack);
|
||||
if (moved.Count > 0)
|
||||
throw new InvalidOperationException("This run's equipment no longer fits its hulls' layouts; changed hulls: " +
|
||||
string.Join(", ", moved) + ". Restore the earlier version of the mod, or start a new game.");
|
||||
}
|
||||
|
||||
private static InvalidOperationException MalformedSave(string where) =>
|
||||
new InvalidOperationException($"This run's save is malformed: {where}. Start a new game.");
|
||||
|
||||
|
|
@ -215,6 +293,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,
|
||||
|
|
@ -224,6 +307,9 @@ public class SavedZone
|
|||
]
|
||||
public abstract class SavedActionBarBinding
|
||||
{
|
||||
|
||||
// Element identity: minted by the cache on write; "" means unset.
|
||||
[Key(2)] [CultElementId] public string Id = "";
|
||||
}
|
||||
|
||||
[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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ public sealed class ScenarioStage
|
|||
private readonly List<string> _failures = new List<string>();
|
||||
private readonly List<Placement> _placed = new List<Placement>();
|
||||
private int _placements;
|
||||
// The entities this stage built from a fit (a packed fit is a saved ship, whose cargo is its own) and those whose cargo the scenario
|
||||
// authored with Cargo: the rest are stocked with the crates their weapons draw when staging ends (StockUnauthoredAmmunition).
|
||||
private readonly HashSet<Entity> _fitted = new HashSet<Entity>();
|
||||
private readonly HashSet<Entity> _cargoAuthored = new HashSet<Entity>();
|
||||
|
||||
internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings)
|
||||
{
|
||||
|
|
@ -40,6 +44,14 @@ public sealed class ScenarioStage
|
|||
public int Credits { get; set; }
|
||||
|
||||
internal IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
// Staging's last step: a fitted entity whose cargo the scenario did not author gets the crates its weapons draw, like every other
|
||||
// newly built armed entity (Loadouts.StockAmmunition). Draws nothing, so it moves no item roll.
|
||||
internal void StockUnauthoredAmmunition()
|
||||
{
|
||||
foreach (var entity in _fitted.Where(entity => !_cargoAuthored.Contains(entity)))
|
||||
Loadouts.StockAmmunition(_items, entity);
|
||||
}
|
||||
internal bool PlayerPlaced { get; private set; }
|
||||
internal Ship PlayerShip { get; private set; }
|
||||
internal IReadOnlyList<Placement> Placed => _placed;
|
||||
|
|
@ -50,6 +62,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 +152,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,18 +160,31 @@ 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
|
||||
// chooses its product) or a product of one (pinned); a name that is neither is a failure.
|
||||
// chooses its product) or a product of one (pinned), or a commodity (one unit: the crates an AmmoType weapon reloads
|
||||
// from); a name that is none of these is a failure.
|
||||
public void Cargo(Entity entity, params string[] names)
|
||||
{
|
||||
if (entity == null) return;
|
||||
_cargoAuthored.Add(entity);
|
||||
var where = $"cargo of {entity.Name}";
|
||||
var bay = entity.CargoBays.FirstOrDefault();
|
||||
foreach (var name in names)
|
||||
{
|
||||
var commodity = _items.ItemData.GetAll<SimpleCommodityData>().FirstOrDefault(data => data.Name == name);
|
||||
if (commodity != null)
|
||||
{
|
||||
if (bay == null || !bay.TryStore(new SimpleCommodity { Data = new CultRecordRef<ItemData>(_items.ItemData.RefOf<ItemData>(commodity).Key), Quantity = 1 }))
|
||||
_failures.Add($"{where}: no room for {name}");
|
||||
continue;
|
||||
}
|
||||
var (data, product) = NamedOrProduct<CraftedItemData>(name, where, found => found is EquippableItemData || found is ConsumableItemData);
|
||||
if (data == null) continue;
|
||||
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data),
|
||||
|
|
@ -176,7 +204,8 @@ public sealed class ScenarioStage
|
|||
else
|
||||
{
|
||||
var failures = new List<string>();
|
||||
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality);
|
||||
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality, stockAmmunition: false);
|
||||
if (entity != null) _fitted.Add(entity);
|
||||
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
|
||||
if (entity == null) return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using System.Linq;
|
||||
using static CultMath.math;
|
||||
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 " +
|
||||
|
|
@ -19,6 +21,11 @@ public sealed class Arcs : Scenario
|
|||
public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 300),
|
||||
Port = float2(-400, 0), Starboard = float2(400, 0);
|
||||
|
||||
// Crates of ammunition each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: at most ten minutes of play, and in
|
||||
// practice the ~260 s a shooter holding every gun down lasts before it dies of heat; a pilot who vents heat is unmeasured) plus half again and one. The turret drew 14 Ammo with the player in its range; the player drew 27 Ammo
|
||||
// holding every gun down. The guided launchers fire only on a lock, which that run cannot give them, so they carry the default three crates.
|
||||
public const int TurretAmmoCrates = 22, PlayerAmmoCrates = 42, PlayerMissileCrates = 3;
|
||||
|
||||
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
|
||||
|
||||
public override void Stage(ScenarioStage stage)
|
||||
|
|
@ -39,7 +46,8 @@ public sealed class Arcs : Scenario
|
|||
("not if i see you first", int2(5, 10), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), Clockwise), // Fire Control Array
|
||||
("🫳🔫", int2(8, 4), Clockwise), // Autocannon
|
||||
("ChargeBlast SG", int2(0, 4), CounterClockwise)); // Charged Scatter Blaster
|
||||
("ChargeBlast SG", int2(0, 4), CounterClockwise), // Charged Scatter Blaster
|
||||
("Store-All Plus", int2(4, 8), None)); // Cargo Bay: the only 2x2 the hull has free
|
||||
var turret = stage.Fit("Turret",
|
||||
("ClearPath", int2(1, 2), None), // Light Machine Gun
|
||||
("ClearPath", int2(5, 2), None), // Light Machine Gun
|
||||
|
|
@ -48,12 +56,15 @@ public sealed class Arcs : Scenario
|
|||
("Compact Reactor", int2(3, 3), None),
|
||||
("Turret Control Module", int2(3, 5), None),
|
||||
("not if i see you first", int2(4, 5), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), None)); // Fire Control Array
|
||||
("Panopticon Prime", int2(3, 6), None), // Fire Control Array
|
||||
("Store-All Plus", int2(3, 1), None)); // Cargo Bay: the only 2x2 the turret hull has free
|
||||
|
||||
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
|
||||
var player = stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Cargo(player, Enumerable.Repeat("Ammo", PlayerAmmoCrates).Concat(Enumerable.Repeat("Missiles", PlayerMissileCrates)).ToArray());
|
||||
stage.Place(stage.Bare("Longinus"), Bow);
|
||||
stage.Place(stage.Bare("Longinus"), Stern);
|
||||
stage.Place(turret, Turret, stance: ScenarioStance.Hostile);
|
||||
// The turret's guns reload from its hold, a crate of Ammo per magazine.
|
||||
stage.Cargo(stage.Place(turret, Turret, stance: ScenarioStance.Hostile), Enumerable.Repeat("Ammo", TurretAmmoCrates).ToArray());
|
||||
stage.Place(stage.Bare("Longinus"), Port);
|
||||
stage.Place(stage.Bare("Longinus"), Starboard);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +4,10 @@ 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 " +
|
||||
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 25 m/s and only coasts: nothing steers it, " +
|
||||
"a hull coasts at its launch speed below its top speed, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +
|
||||
"(thrusting from the start carries you past it). Verify: as the Djinni crosses close, FastBlast lands some and projector " +
|
||||
"almost none; against the Djinni at range both land. Fly it again without the Fire Control Array: " +
|
||||
"with it the small gun lands more.";
|
||||
|
|
@ -15,13 +16,15 @@ public sealed class Crossing : Scenario
|
|||
|
||||
// Where the Djinni sit: the player is at Origin facing +z. Origin is 250 m or more from every body, where the zone's
|
||||
// pull is weakest (about 1.4 u/s^2; the sum of the wells never drops to nothing, and at the old origin the player sat on the star).
|
||||
// The crosser is unpiloted and placed moving at the Djinni's 50 m/s cap, so its pass is ballistic: bent by the field and slowed
|
||||
// by hull drag (it reaches the bow line at about 25 m/s relative to the player, 3.6 s in), and nothing steers it.
|
||||
// The crosser is unpiloted and placed moving at 25 m/s, well under the Djinni's 50 m/s top speed, so its pass is ballistic: a hull
|
||||
// coasts below its top speed (aetheria-gravity:ruling:no-drag-below-top-speed) and only the field bends it, so it reaches the
|
||||
// bow line at 25 m/s relative to the player, 3.6 s in, and nothing steers it. (Launched at the cap with drag slowing it to 25 m/s,
|
||||
// as this scenario was first tuned, it would now hold 50 m/s and cross in 2.6 s.)
|
||||
// CrosserAt is the centre of the start window in which the pass holds: shifting it by 10 m along either axis keeps the pass
|
||||
// inside the test's time and distance windows (CrossingHoldsAcrossTheStartWindow).
|
||||
// The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range.
|
||||
public static readonly CultMath.float2 Origin = float2(724, 124);
|
||||
public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0);
|
||||
public static readonly CultMath.float2 CrosserAt = Origin + float2(-90, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(25, 0);
|
||||
// The fighter is staged at this quality, so its gear and the range below do not ride the item stream.
|
||||
public const float StagedQuality = .6f;
|
||||
// The Lightning Projector's Range in metres at the staged fit: 996.99 after the three seconds the test settles for
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using static CultMath.math;
|
||||
using static ItemRotation;
|
||||
|
||||
|
|
@ -7,8 +8,14 @@ public sealed class Pursuit : Scenario
|
|||
// cloud breaking a sensor's lock, and well outside the 40 at which AStrongCloseSensorBurnsThrough shows a close
|
||||
// sensor burning through.
|
||||
private const float Astern = 300;
|
||||
// Crates each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: at most ten minutes of play, and in
|
||||
// practice the ~250-270 s a shooter holding every gun down lasts before it dies of heat; a pilot who vents heat is unmeasured) plus half again and one where the hold has room. The player laid four crates of Mines and drew
|
||||
// 33 Ammo; its hold takes 54 crates after five consumables and Mines, so its Ammo stops at 39. The pursuer drew 31 Ammo and one Missiles. A launcher
|
||||
// that drew under a reload carries the default three crates.
|
||||
public const int MineCrates = 7, PlayerAmmoCrates = 39, PlayerMissileCrates = 3, PursuerAmmoCrates = 47, PursuerMissileCrates = 3;
|
||||
|
||||
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.";
|
||||
|
|
@ -54,7 +61,9 @@ public sealed class Pursuit : Scenario
|
|||
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
|
||||
|
||||
var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Cargo(player, "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it");
|
||||
stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
|
||||
stage.Cargo(player, new[] { "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it" }
|
||||
.Concat(Enumerable.Repeat("Mines", MineCrates)).Concat(Enumerable.Repeat("Ammo", PlayerAmmoCrates)).Concat(Enumerable.Repeat("Missiles", PlayerMissileCrates)).ToArray());
|
||||
var hostile = stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
|
||||
stage.Cargo(hostile, Enumerable.Repeat("Ammo", PursuerAmmoCrates).Concat(Enumerable.Repeat("Missiles", PursuerMissileCrates)).ToArray());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -126,6 +130,20 @@ public class ZoneGenerationSettings
|
|||
public ExponentialCurve AsteroidCount;
|
||||
public ExponentialLerp AsteroidRotationSpeed;
|
||||
|
||||
// Trojan swarms (ruling trojans-ride-host-period-with-stations): the chance a planet or gas giant with a body for a parent
|
||||
// carries one, each swarm's length along the orbit (turns) and radial half-width (a fraction of the host's distance), and
|
||||
// the rocks round each of L4 and L5 by host distance. 0 here: a settings object built in code draws none.
|
||||
public float TrojanProbability = 0f;
|
||||
public float TrojanArc = .07f;
|
||||
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;
|
||||
|
||||
|
|
@ -169,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;
|
||||
|
|
@ -191,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);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue