diff --git a/LittleSim.sln b/LittleSim.sln
index 0e3fc5c..2d2ea9b 100644
--- a/LittleSim.sln
+++ b/LittleSim.sln
@@ -41,17 +41,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "engin
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "engine\tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{E0E37D87-4F62-41E9-9CD1-3E7432301508}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "engine\src\MrGameEng.Host\MrGameEng.Host.csproj", "{D4222C26-40D8-4465-9B26-CD6BD0722EC0}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "engine\src\MrGameEng.Host\MrGameEng.Host.csproj", "{6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "engine\tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{41F0249B-106E-4D56-B022-73D2C2D74807}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "engine\tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "engine\tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "engine\tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{95630217-333B-4263-84AE-5EE5961FC307}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Web", "src\LittleSim.Web\LittleSim.Web.csproj", "{270A9E52-E0E3-4885-B662-79E35AF6F7B4}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Web", "src\LittleSim.Web\LittleSim.Web.csproj", "{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -243,78 +243,78 @@ Global
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x64.Build.0 = Release|Any CPU
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.ActiveCfg = Release|Any CPU
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.Build.0 = Release|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x64.ActiveCfg = Debug|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x64.Build.0 = Debug|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x86.ActiveCfg = Debug|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x86.Build.0 = Debug|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|Any CPU.Build.0 = Release|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x64.ActiveCfg = Release|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x64.Build.0 = Release|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x86.ActiveCfg = Release|Any CPU
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x86.Build.0 = Release|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x64.ActiveCfg = Debug|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x64.Build.0 = Debug|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x86.ActiveCfg = Debug|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x86.Build.0 = Debug|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|Any CPU.Build.0 = Release|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x64.ActiveCfg = Release|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x64.Build.0 = Release|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x86.ActiveCfg = Release|Any CPU
- {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x86.Build.0 = Release|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x64.ActiveCfg = Debug|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x64.Build.0 = Debug|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x86.ActiveCfg = Debug|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x86.Build.0 = Debug|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|Any CPU.Build.0 = Release|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.ActiveCfg = Release|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.Build.0 = Release|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.ActiveCfg = Release|Any CPU
- {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.Build.0 = Release|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.ActiveCfg = Debug|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.Build.0 = Debug|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.ActiveCfg = Debug|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.Build.0 = Debug|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.Build.0 = Release|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.ActiveCfg = Release|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.Build.0 = Release|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.ActiveCfg = Release|Any CPU
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.Build.0 = Release|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.ActiveCfg = Debug|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.Build.0 = Debug|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.ActiveCfg = Debug|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.Build.0 = Debug|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.Build.0 = Release|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.ActiveCfg = Release|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.Build.0 = Release|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x86.ActiveCfg = Release|Any CPU
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x86.Build.0 = Release|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x64.ActiveCfg = Debug|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x64.Build.0 = Debug|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x86.ActiveCfg = Debug|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x86.Build.0 = Debug|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|Any CPU.Build.0 = Release|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x64.ActiveCfg = Release|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x64.Build.0 = Release|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x86.ActiveCfg = Release|Any CPU
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x86.Build.0 = Release|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Debug|x64.Build.0 = Debug|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Debug|x86.Build.0 = Debug|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Release|x64.ActiveCfg = Release|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Release|x64.Build.0 = Release|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Release|x86.ActiveCfg = Release|Any CPU
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}.Release|x86.Build.0 = Release|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Debug|x64.Build.0 = Debug|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Debug|x86.Build.0 = Debug|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x64.ActiveCfg = Release|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x64.Build.0 = Release|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x86.ActiveCfg = Release|Any CPU
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x86.Build.0 = Release|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x64.Build.0 = Debug|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x86.Build.0 = Debug|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x64.ActiveCfg = Release|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x64.Build.0 = Release|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x86.ActiveCfg = Release|Any CPU
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x86.Build.0 = Release|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x64.Build.0 = Debug|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x86.Build.0 = Debug|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x64.ActiveCfg = Release|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x64.Build.0 = Release|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x86.ActiveCfg = Release|Any CPU
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x86.Build.0 = Release|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Debug|x64.Build.0 = Debug|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Debug|x86.Build.0 = Debug|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Release|Any CPU.Build.0 = Release|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Release|x64.ActiveCfg = Release|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Release|x64.Build.0 = Release|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Release|x86.ActiveCfg = Release|Any CPU
+ {95630217-333B-4263-84AE-5EE5961FC307}.Release|x86.Build.0 = Release|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x64.Build.0 = Debug|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x86.Build.0 = Debug|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x64.ActiveCfg = Release|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x64.Build.0 = Release|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x86.ActiveCfg = Release|Any CPU
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -337,11 +337,11 @@ Global
{86ECA4A0-46A2-45F4-8CF4-8FB8BB5DC050} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{E0E37D87-4F62-41E9-9CD1-3E7432301508} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
- {D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
- {41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
- {BEFB468B-6784-468E-9B60-EB44AB078D15} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
- {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
- {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
- {270A9E52-E0E3-4885-B662-79E35AF6F7B4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
+ {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
+ {841EAE51-E86B-4BFB-A70C-A67E4D492DD7} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
+ {D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
+ {95630217-333B-4263-84AE-5EE5961FC307} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {1FF0DF78-22E4-4169-A9DC-233FFAEA5B14} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/Mods/Core/Defs/genes.json b/Mods/Core/Defs/genes.json
new file mode 100644
index 0000000..247801b
--- /dev/null
+++ b/Mods/Core/Defs/genes.json
@@ -0,0 +1,73 @@
+{
+ "type": "Gene",
+ // Организм-агностичные гены: каждый ген задаёт, как генерируются/мутируют аллели и как ген
+ // вкладывается в признаки (effects: имя признака → формула; value — выраженное значение гена).
+ // Этот набор покрывает измерения генома растения (его используют системы роста/жизненного цикла).
+ "defs": [
+ { "defName": "BaseNumericGene", "abstract": true, "kind": "Numeric",
+ "spread": 0.08, "mutationChance": 0.05, "mutationMagnitude": 0.12 },
+
+ { "defName": "GeneOptimalLight", "parent": "BaseNumericGene", "label": "gene.optimalLight",
+ "default": 0.6, "min": 0.0, "max": 1.0, "tags": ["environment", "light"],
+ "effects": { "optimalLight": "value" } },
+ { "defName": "GeneLightTolerance", "parent": "BaseNumericGene", "label": "gene.lightTolerance",
+ "default": 0.5, "min": 0.05, "max": 1.0, "tags": ["environment", "light"],
+ "effects": { "lightTolerance": "value" } },
+ { "defName": "GeneOptimalTemperature", "parent": "BaseNumericGene", "label": "gene.optimalTemperature",
+ "default": 14, "min": -20, "max": 45, "tags": ["environment", "temperature"],
+ "effects": { "optimalTemperature": "value" } },
+ { "defName": "GeneTemperatureTolerance", "parent": "BaseNumericGene", "label": "gene.temperatureTolerance",
+ "default": 16, "min": 2, "max": 40, "tags": ["environment", "temperature"],
+ "effects": { "temperatureTolerance": "value" } },
+ { "defName": "GeneOptimalFertility", "parent": "BaseNumericGene", "label": "gene.optimalFertility",
+ "default": 1.4, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
+ "effects": { "optimalFertility": "value" } },
+ { "defName": "GeneFertilityTolerance", "parent": "BaseNumericGene", "label": "gene.fertilityTolerance",
+ "default": 0.9, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
+ "effects": { "fertilityTolerance": "value" } },
+
+ { "defName": "GeneVigor", "parent": "BaseNumericGene", "label": "gene.vigor",
+ "default": 1.0, "min": 0.1, "max": 3.0, "tags": ["growth"],
+ "effects": { "vigor": "value" } },
+ { "defName": "GeneLifespan", "parent": "BaseNumericGene", "label": "gene.lifespan",
+ "default": 160, "min": 5, "max": 500, "tags": ["lifecycle"],
+ "effects": { "lifespan": "value" } },
+ { "defName": "GeneDispersalRange", "parent": "BaseNumericGene", "label": "gene.dispersalRange",
+ "default": 2, "min": 1, "max": 8, "tags": ["lifecycle", "reproduction"],
+ "effects": { "dispersalRange": "value" } },
+ { "defName": "GeneReproduceInterval", "parent": "BaseNumericGene", "label": "gene.reproduceInterval",
+ "default": 14, "min": 1, "max": 60, "tags": ["lifecycle", "reproduction"],
+ "effects": { "reproduceInterval": "value" } },
+ { "defName": "GeneSelfPollination", "parent": "BaseNumericGene", "label": "gene.selfPollination",
+ "default": 0.2, "min": 0.0, "max": 1.0, "tags": ["reproduction"],
+ "effects": { "selfPollination": "value" } },
+ { "defName": "GeneMutationRate", "parent": "BaseNumericGene", "label": "gene.mutationRate",
+ "default": 0.05, "min": 0.0, "max": 1.0, "tags": ["reproduction"],
+ "effects": { "mutationRate": "value" } },
+
+ // --- Контент (фаза G4): плодоношение, добыча, цвет ---
+ { "defName": "GeneFruitYield", "parent": "BaseNumericGene", "label": "gene.fruitYield",
+ "default": 0, "min": 0, "max": 12, "tags": ["fruiting"],
+ "effects": { "fruitYield": "value" } },
+ { "defName": "GeneFruitSeason", "parent": "BaseNumericGene", "label": "gene.fruitSeason",
+ "default": 1, "min": 0, "max": 3, "spread": 0, "mutationChance": 0, "tags": ["fruiting"],
+ "effects": { "fruitSeason": "value" } },
+ { "defName": "GeneHarvestAmount", "parent": "BaseNumericGene", "label": "gene.harvestAmount",
+ "default": 1, "min": 0, "max": 50, "tags": ["harvest"],
+ "effects": { "harvestAmount": "value" } },
+ { "defName": "GeneLeafHue", "parent": "BaseNumericGene", "label": "gene.leafHue",
+ "default": 0.33, "min": 0, "max": 1, "spread": 0.04, "tags": ["morphology", "color"],
+ "effects": { "leafHue": "value" } },
+
+ // Производный ген: признак собирается группировкой по регэкспу — сумма всех генов-толерантностей
+ // (демонстрация gsom-функций фазы G5). Собственное значение гена не используется.
+ { "defName": "GeneHardiness", "parent": "BaseNumericGene", "label": "gene.hardiness",
+ "default": 0, "min": 0, "max": 1, "spread": 0, "mutationChance": 0, "tags": ["derived"],
+ "effects": { "hardiness": "gsum('Gene.*Tolerance')" } },
+
+ // Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе.
+ { "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph",
+ "variants": 2, "variantWeights": [0.82, 0.18], "mutationChance": 0.05, "tags": ["morphology"],
+ "effects": { "variant": "value" } }
+ ]
+}
diff --git a/Mods/Core/Defs/patches.json b/Mods/Core/Defs/patches.json
new file mode 100644
index 0000000..81677c8
--- /dev/null
+++ b/Mods/Core/Defs/patches.json
@@ -0,0 +1,8 @@
+{
+ "type": "Patch",
+ // Контент-патчи (фаза G5): применяются ко всем дефам типа defType, чьё имя подходит под regex match,
+ // проставляя поля set. Демонстрация: даём кустам древесину при сборе (веточки).
+ "patches": [
+ { "defType": "Plant", "match": "Bush.*", "set": { "harvestProduct": "ProductWood" } }
+ ]
+}
diff --git a/Mods/Core/Defs/plants.json b/Mods/Core/Defs/plants.json
index 1a83da0..d39427c 100644
--- a/Mods/Core/Defs/plants.json
+++ b/Mods/Core/Defs/plants.json
@@ -2,8 +2,12 @@
"type": "Plant",
"defs": [
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
+ "harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 16,
- "optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0, "spread": 0.08 },
+ "optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0,
+ "lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
+ "mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
+ "fruitYield": 5, "fruitSeason": 2, "harvestAmount": 10, "leafHue": 0.30 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
@@ -15,16 +19,24 @@
{ "defName": "TreeGrayPineA", "parent": "BaseTree", "label": "plant.pine", "texture": "things/plant/treegraypine/TreeGrayPineA" },
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
+ "harvestProduct": "ProductGrass",
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 12,
- "optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3, "spread": 0.1 },
+ "optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
+ "lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
+ "mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
+ "harvestAmount": 2, "leafHue": 0.36 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" },
{ "sizeCells": 1.2, "label": "plant.stage.mature" }
] },
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
+ "fruitProduct": "ProductBerry",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 13,
- "optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0, "spread": 0.1 },
+ "optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0,
+ "lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
+ "mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
+ "fruitYield": 3, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.32 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" },
{ "sizeCells": 1.4, "label": "plant.stage.mature" }
diff --git a/Mods/Core/Defs/products.json b/Mods/Core/Defs/products.json
new file mode 100644
index 0000000..f43d352
--- /dev/null
+++ b/Mods/Core/Defs/products.json
@@ -0,0 +1,11 @@
+{
+ "type": "Product",
+ // Продукты сбора/плодоношения растений. На них ссылаются PlantDef.harvestProduct / fruitProduct;
+ // количество задаётся генами (harvestAmount / fruitYield).
+ "defs": [
+ { "defName": "ProductWood", "label": "product.wood", "kind": "material" },
+ { "defName": "ProductGrass", "label": "product.grass", "kind": "material" },
+ { "defName": "ProductBerry", "label": "product.berry", "kind": "food" },
+ { "defName": "ProductAcorn", "label": "product.acorn", "kind": "food" }
+ ]
+}
diff --git a/Mods/Core/Defs/terrain.json b/Mods/Core/Defs/terrain.json
index f2caabf..a53f681 100644
--- a/Mods/Core/Defs/terrain.json
+++ b/Mods/Core/Defs/terrain.json
@@ -17,6 +17,6 @@
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] }
] },
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
- "surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2 }
+ "surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
]
}
diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json
index a0f9cc7..e57fb66 100644
--- a/Mods/Core/Languages/en/ui.json
+++ b/Mods/Core/Languages/en/ui.json
@@ -67,6 +67,10 @@
"plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling",
"plant.stage.mature": "mature",
+ "product.wood": "wood",
+ "product.grass": "grass",
+ "product.berry": "berries",
+ "product.acorn": "acorn",
"pawn.being": "being",
"pawn.bear": "bear",
"pawn.deer": "deer",
diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json
index 80d0929..b7982f4 100644
--- a/Mods/Core/Languages/ru/ui.json
+++ b/Mods/Core/Languages/ru/ui.json
@@ -67,6 +67,10 @@
"plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое",
+ "product.wood": "древесина",
+ "product.grass": "трава",
+ "product.berry": "ягоды",
+ "product.acorn": "жёлудь",
"pawn.being": "житель",
"pawn.bear": "медведь",
"pawn.deer": "олень",
diff --git a/Mods/Core/Textures/things/pawn/animal/guineapig/AEXP_GuineaPig_SkinSet.xml b/Mods/Core/Textures/things/pawn/animal/guineapig/AEXP_GuineaPig_SkinSet.xml
index c6fc478..2b4521b 100644
--- a/Mods/Core/Textures/things/pawn/animal/guineapig/AEXP_GuineaPig_SkinSet.xml
+++ b/Mods/Core/Textures/things/pawn/animal/guineapig/AEXP_GuineaPig_SkinSet.xml
@@ -1,7 +1,6 @@
-
true
@@ -9,24 +8,21 @@
0
GuineaPig
-
+
0.4
GuineaPig1
-
+
0.4
GuineaPig2
-
+
0.4
GuineaPig3
-
-
-
+
-
diff --git a/Mods/Core/Textures/things/pawn/animal/horse/AEXP_Horse_SkinSet.xml b/Mods/Core/Textures/things/pawn/animal/horse/AEXP_Horse_SkinSet.xml
index 8616b84..d00f5f3 100644
--- a/Mods/Core/Textures/things/pawn/animal/horse/AEXP_Horse_SkinSet.xml
+++ b/Mods/Core/Textures/things/pawn/animal/horse/AEXP_Horse_SkinSet.xml
@@ -1,7 +1,6 @@
-
true
@@ -9,38 +8,36 @@
0.12
Horse
-
+
0.25
Horse1
-
+
0.4
Horse2
-
+
0.4
Horse3
-
+
0.4
Horse4
-
+
0.35
Horse5
-
+
0.25
Horse6
-
-
+
-
diff --git a/engine b/engine
index 3438ed7..d044caf 160000
--- a/engine
+++ b/engine
@@ -1 +1 @@
-Subproject commit 3438ed77f66d0a88d5dda640bfbd7a60b4a2de2f
+Subproject commit d044cafad93177e0fbffb79872bde12afcf03f9c
diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs
index 2b4fac9..871c3f3 100644
--- a/src/LittleSim/App/WorldSave.cs
+++ b/src/LittleSim/App/WorldSave.cs
@@ -1,7 +1,33 @@
using System.Text.Json;
+using MrGameEng.Genetics;
namespace LittleSim.App;
+/// Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном.
+public sealed class PlantSave
+{
+ /// Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры.
+ public string Species { get; set; } = "";
+
+ /// Позиция X в мировых координатах.
+ public float X { get; set; }
+
+ /// Позиция Y в мировых координатах.
+ public float Y { get; set; }
+
+ /// Накопленный возраст в игровых днях.
+ public float AgeDays { get; set; }
+
+ /// Текущая стадия роста.
+ public int Stage { get; set; }
+
+ /// Плодородность клетки.
+ public float CellFertility { get; set; }
+
+ /// Геном особи: аллели по id генов (переменного состава).
+ public Dictionary Genome { get; set; } = new();
+}
+
/// Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по ).
public sealed class PawnSave
{
@@ -70,6 +96,9 @@ public sealed class WorldSave
/// Снимок жителей.
public List Pawns { get; set; } = [];
+ /// Снимок всех растений (геномы, позиции, возраст, стадия).
+ public List Plants { get; set; } = [];
+
/// Конфиг мира для пересоздания сцены.
public WorldConfig ToConfig() =>
new()
@@ -90,6 +119,7 @@ public sealed class SaveStore
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
+ IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах
};
private readonly string _directory;
diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs
index abb0694..07fdee2 100644
--- a/src/LittleSim/Content/GameContent.cs
+++ b/src/LittleSim/Content/GameContent.cs
@@ -1,5 +1,6 @@
using MrGameEng.Atlases;
using MrGameEng.Core;
+using MrGameEng.Genetics;
using MrGameEng.Mods;
namespace LittleSim.Content;
@@ -58,9 +59,19 @@ public sealed class GameContent
var defs = new DefDatabase();
defs.RegisterType("Terrain");
+ defs.RegisterType("Gene");
+ defs.RegisterType("Product");
defs.RegisterType("Plant");
defs.RegisterType("Pawn");
defs.RegisterType("WorldPreset");
+ // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
+ defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+ defs.RegisterValidator(
+ "Product",
+ "defName",
+ "^Product",
+ "product defName must start with 'Product'"
+ );
defs.Load(mods);
var languages = new LanguageManager(defaultLanguage: "ru");
diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs
index a33e1d7..781b6a0 100644
--- a/src/LittleSim/Content/GameDefs.cs
+++ b/src/LittleSim/Content/GameDefs.cs
@@ -27,6 +27,9 @@ public sealed class TerrainDef : Def
/// Суша: здесь появляются жители, животные и растения.
public bool IsLand { get; init; }
+ /// Загораживает свет (горы) — базовый окклюдер для лайтмапа/теней.
+ public bool BlocksLight { get; init; }
+
/// Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл.
public string? Surface { get; init; }
@@ -63,6 +66,17 @@ public sealed class PlantGrowthStage
public string? Label { get; init; }
}
+///
+/// Продукт (Defs/products.json): что растение даёт при сборе (древесина, трава) или плодоношении
+/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество
+/// определяют гены. — ключ локализации (product.*).
+///
+public sealed class ProductDef : Def
+{
+ /// Категория продукта (например "material" или "food") — для будущей экономики/инвентаря.
+ public string Kind { get; init; } = "";
+}
+
///
/// Базовый геном вида (вложенный объект ): оптимумы и толерантности по
/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами
@@ -91,8 +105,38 @@ public sealed class GenomeDef
/// Базовая бодрость роста (множитель скорости в идеальных условиях).
public float Vigor { get; init; } = 1f;
+ /// Продолжительность жизни (игровых дней) до смерти от старости.
+ public float Lifespan { get; init; } = 80f;
+
+ /// Дальность расселения семян (в клетках).
+ public float DispersalRange { get; init; } = 2f;
+
+ /// Средний интервал между попытками дать семя (игровых дней).
+ public float ReproduceInterval { get; init; } = 8f;
+
+ /// Способность к самоопылению (0..1): шанс дать семя без партнёра.
+ public float SelfPollination { get; init; } = 0.4f;
+
+ /// Темп мутаций (0..1): вероятность сдвига аллеля у потомка.
+ public float MutationRate { get; init; } = 0.05f;
+
+ /// Доля рецессивного аллеля морфы в стартовой популяции (0..1).
+ public float VariantChance { get; init; } = 0.15f;
+
/// Доля разброса аллелей вокруг базы при генерации особи.
public float Spread { get; init; } = 0.08f;
+
+ /// Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит).
+ public float FruitYield { get; init; }
+
+ /// Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима).
+ public float FruitSeason { get; init; } = 1f;
+
+ /// Сколько продукта даёт сбор растения (масштаб ).
+ public float HarvestAmount { get; init; } = 1f;
+
+ /// Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета.
+ public float LeafHue { get; init; } = 0.33f;
}
/// Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.
@@ -112,6 +156,12 @@ public sealed class PlantDef : Def
/// Базовый геном вида: оптимумы/толерантности по среде и бодрость роста.
public GenomeDef Genome { get; init; } = new();
+
+ /// Имя , выдаваемого при сборе растения (дерево/трава); null — несборное.
+ public string? HarvestProduct { get; init; }
+
+ /// Имя , выдаваемого плодами (ягоды/жёлудь); null — не плодоносит.
+ public string? FruitProduct { get; init; }
}
///
diff --git a/src/LittleSim/Content/PlantSet.cs b/src/LittleSim/Content/PlantSet.cs
index 74872cb..b92622e 100644
--- a/src/LittleSim/Content/PlantSet.cs
+++ b/src/LittleSim/Content/PlantSet.cs
@@ -1,4 +1,5 @@
using Microsoft.Xna.Framework.Graphics;
+using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Content;
@@ -23,6 +24,9 @@ public sealed class PlantSet
/// Стадии роста по порядку; последняя — терминальная.
public required Stage[] Stages { get; init; }
+ /// Набор генов вида: базовые значения для генерации особи (геном из общих ).
+ public required GenomeTemplate Template { get; init; }
+
/// День, с которого растение достигает последней (зрелой) стадии.
public float MaturityDays => Stages[^1].EnterDay;
@@ -42,15 +46,28 @@ public sealed class PlantSet
private readonly Species[] _species;
private readonly Dictionary _index = new();
+ /// Общий реестр генов (по id) для скрещивания и вычисления признаков растений.
+ public IReadOnlyDictionary GeneRegistry { get; }
+
/// Строит таблицу из всех (не-abstract) мода.
public PlantSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
{
+ var genes = content
+ .Defs.All()
+ .ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
+ GeneRegistry = genes;
+
var defs = content.Defs.All().ToArray();
_species = new Species[defs.Length];
for (var i = 0; i < defs.Length; i++)
{
var def = defs[i];
- _species[i] = new Species { Def = def, Stages = BuildStages(def, atlases, device) };
+ _species[i] = new Species
+ {
+ Def = def,
+ Stages = BuildStages(def, atlases, device),
+ Template = BuildTemplate(def.Genome, genes),
+ };
_index[def] = i;
}
}
@@ -64,6 +81,50 @@ public sealed class PlantSet
/// Индекс вида по дефу.
public int IndexOf(PlantDef def) => _index[def];
+ // Переводит базовый геном вида (числа из plants.json) в набор генов: каждое число — центр
+ // аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида.
+ private static GenomeTemplate BuildTemplate(
+ GenomeDef g,
+ IReadOnlyDictionary genes
+ )
+ {
+ GeneDef Gene(string id) =>
+ genes.TryGetValue(id, out var def)
+ ? def
+ : throw new KeyNotFoundException(
+ $"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?"
+ );
+
+ GenomeTemplate.Entry Numeric(string id, float baseValue) =>
+ new(Gene(id), baseValue, g.Spread);
+
+ return new GenomeTemplate([
+ Numeric("GeneOptimalLight", g.OptimalLight),
+ Numeric("GeneLightTolerance", g.LightTolerance),
+ Numeric("GeneOptimalTemperature", g.OptimalTemperature),
+ Numeric("GeneTemperatureTolerance", g.TemperatureTolerance),
+ Numeric("GeneOptimalFertility", g.OptimalFertility),
+ Numeric("GeneFertilityTolerance", g.FertilityTolerance),
+ Numeric("GeneVigor", g.Vigor),
+ Numeric("GeneLifespan", g.Lifespan),
+ Numeric("GeneDispersalRange", g.DispersalRange),
+ Numeric("GeneReproduceInterval", g.ReproduceInterval),
+ Numeric("GeneSelfPollination", g.SelfPollination),
+ Numeric("GeneMutationRate", g.MutationRate),
+ // Контент (G4): плодоношение, добыча, цвет.
+ Numeric("GeneFruitYield", g.FruitYield),
+ new GenomeTemplate.Entry(Gene("GeneFruitSeason"), g.FruitSeason, 0f), // сезон фиксирован по виду
+ Numeric("GeneHarvestAmount", g.HarvestAmount),
+ new GenomeTemplate.Entry(Gene("GeneLeafHue"), g.LeafHue, 0.04f),
+ new GenomeTemplate.Entry(
+ Gene("GeneMorph"),
+ 0f,
+ 0f,
+ [1f - g.VariantChance, g.VariantChance]
+ ),
+ ]);
+ }
+
private static Stage[] BuildStages(PlantDef def, ModAtlases atlases, GraphicsDevice device)
{
// Без явных стадий растение существует как одна терминальная стадия из базовой текстуры.
diff --git a/src/LittleSim/Program.cs b/src/LittleSim/Program.cs
index f6b7ae6..2e8d078 100644
--- a/src/LittleSim/Program.cs
+++ b/src/LittleSim/Program.cs
@@ -1,8 +1,35 @@
+using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
+using MrGameEng.Genetics;
using MrGameEng.Host;
+// Контент-линт без окна/GPU: грузит моды/дефы (патчи и валидаторы тоже), компилирует формулы генов
+// (включая группировку gsum) и выходит. Удобно для CI.
+if (args.Contains("--check-content"))
+{
+ try
+ {
+ var content = GameContent.Load();
+ var genes = content.Defs.All();
+ var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
+ var traits = Phenotype.Compute(Genome.Generate(genes, new Random(1)), registry);
+ Console.WriteLine(
+ $"content ok: {content.Defs.TypeKeys.Count} def types, {genes.Count} genes, "
+ + $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits "
+ + $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})"
+ );
+ return;
+ }
+ catch (Exception error)
+ {
+ Console.Error.WriteLine($"content FAILED: {error.Message}");
+ Environment.Exit(1);
+ return;
+ }
+}
+
// --connect [ws://host:port] — после загрузки контента сразу в сетевую сцену
// (см. MultiplayerScene); без аргументов — обычный запуск в главное меню.
string? connect = null;
diff --git a/src/LittleSim/Scenes/BootScene.cs b/src/LittleSim/Scenes/BootScene.cs
index 5a37af5..92f8d28 100644
--- a/src/LittleSim/Scenes/BootScene.cs
+++ b/src/LittleSim/Scenes/BootScene.cs
@@ -1,3 +1,4 @@
+using System;
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
diff --git a/src/LittleSim/Scenes/ContentHelpers.cs b/src/LittleSim/Scenes/ContentHelpers.cs
index 3f43486..07f90f0 100644
--- a/src/LittleSim/Scenes/ContentHelpers.cs
+++ b/src/LittleSim/Scenes/ContentHelpers.cs
@@ -36,29 +36,19 @@ internal static class ScatterSpawner
var def = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]);
var index = plants.IndexOf(def);
- var species = plants[index];
var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize;
var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize;
- var age = random.NextSingle() * species.MaturityDays;
- var stage = species.StageAt(age);
- var resolved = species.Stages[stage];
+ var age = random.NextSingle() * plants[index].MaturityDays;
- var sprite = new Sprite(resolved.Region, GameLayers.Beings);
- sprite.CenterOrigin();
- scene.Store.CreateEntity(
- new Transform2D(
- new Vector2(px, py),
- scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width)
- ),
- sprite,
- new PlantGrowth
- {
- Species = index,
- Stage = stage,
- AgeDays = age,
- CellFertility = terrain.Fertility,
- },
- PlantGenome.FromDef(def.Genome, random)
+ PlantFactory.Create(
+ scene.Store,
+ plants,
+ index,
+ new Vector2(px, py),
+ age,
+ plants[index].Template.Generate(random),
+ terrain.Fertility,
+ cellSize
);
}
}
diff --git a/src/LittleSim/Scenes/CreditsScene.cs b/src/LittleSim/Scenes/CreditsScene.cs
index e09dfe0..b4e63e5 100644
--- a/src/LittleSim/Scenes/CreditsScene.cs
+++ b/src/LittleSim/Scenes/CreditsScene.cs
@@ -1,47 +1,47 @@
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using MrGameEng.Input;
-using MrGameEng.UI;
-
-namespace LittleSim.Scenes;
-
-/// Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.
-public sealed class CreditsScene : Scene
-{
- protected override void OnLoad()
- {
- var lang = Context.Services.Get().Languages;
- var input = this.UseInput();
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
-
- var column = Ui.Column(12);
- column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
- column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
- column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
- column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
- column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
- column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
-
- desktop.Root = Ui.Screen(column);
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
- }
- }
-}
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+
+namespace LittleSim.Scenes;
+
+/// Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.
+public sealed class CreditsScene : Scene
+{
+ protected override void OnLoad()
+ {
+ var lang = Context.Services.Get().Languages;
+ var input = this.UseInput();
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+
+ var column = Ui.Column(12);
+ column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
+ column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
+ column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
+ column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
+ column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
+ column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
+
+ desktop.Root = Ui.Screen(column);
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/LoadGameScene.cs b/src/LittleSim/Scenes/LoadGameScene.cs
index 2a49ae5..be85763 100644
--- a/src/LittleSim/Scenes/LoadGameScene.cs
+++ b/src/LittleSim/Scenes/LoadGameScene.cs
@@ -1,111 +1,111 @@
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using MrGameEng.Input;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Экран загрузки: список сохранений из (имя, размер, дата) с
-/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
-///
-public sealed class LoadGameScene : Scene
-{
- private readonly SaveStore _store = new();
- private GameContent _content = null!;
- private Desktop _desktop = null!;
-
- protected override void OnLoad()
- {
- _content = Context.Services.Get();
- var input = this.UseInput();
- _desktop = this.UseUI();
- Rebuild();
-
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private void Rebuild()
- {
- var lang = _content.Languages;
- var column = Ui.Column(8);
- column.Widgets.Add(Ui.Title(lang.Get("load.title")));
- column.Widgets.Add(new Label { Height = 8 });
-
- var saves = _store.List();
- if (saves.Count == 0)
- {
- column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
- }
-
- foreach (var entry in saves)
- {
- var save = entry.Save;
- var row = Ui.Row(8);
- row.Widgets.Add(
- new Label
- {
- TextColor = Ui.Accent,
- Text =
- $"{save.Name} — {save.Width}×{save.Height} — "
- + save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
- }
- );
- var file = entry.FileName;
- row.Widgets.Add(
- Ui.Button(
- lang.Get("load.load"),
- () =>
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(
- new WorldScene(save.ToConfig(), save),
- Transitions.Fade(0.6f)
- );
- }
- },
- width: 130
- )
- );
- row.Widgets.Add(
- Ui.Button(
- lang.Get("load.delete"),
- () =>
- {
- _store.Delete(file);
- Rebuild();
- },
- width: 130
- )
- );
- column.Widgets.Add(row);
- }
-
- column.Widgets.Add(new Label { Height = 8 });
- column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
-
- _desktop.Root = Ui.Screen(column);
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
- }
- }
-}
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Экран загрузки: список сохранений из (имя, размер, дата) с
+/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
+///
+public sealed class LoadGameScene : Scene
+{
+ private readonly SaveStore _store = new();
+ private GameContent _content = null!;
+ private Desktop _desktop = null!;
+
+ protected override void OnLoad()
+ {
+ _content = Context.Services.Get();
+ var input = this.UseInput();
+ _desktop = this.UseUI();
+ Rebuild();
+
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private void Rebuild()
+ {
+ var lang = _content.Languages;
+ var column = Ui.Column(8);
+ column.Widgets.Add(Ui.Title(lang.Get("load.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+
+ var saves = _store.List();
+ if (saves.Count == 0)
+ {
+ column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
+ }
+
+ foreach (var entry in saves)
+ {
+ var save = entry.Save;
+ var row = Ui.Row(8);
+ row.Widgets.Add(
+ new Label
+ {
+ TextColor = Ui.Accent,
+ Text =
+ $"{save.Name} — {save.Width}×{save.Height} — "
+ + save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
+ }
+ );
+ var file = entry.FileName;
+ row.Widgets.Add(
+ Ui.Button(
+ lang.Get("load.load"),
+ () =>
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(
+ new WorldScene(save.ToConfig(), save),
+ Transitions.Fade(0.6f)
+ );
+ }
+ },
+ width: 130
+ )
+ );
+ row.Widgets.Add(
+ Ui.Button(
+ lang.Get("load.delete"),
+ () =>
+ {
+ _store.Delete(file);
+ Rebuild();
+ },
+ width: 130
+ )
+ );
+ column.Widgets.Add(row);
+ }
+
+ column.Widgets.Add(new Label { Height = 8 });
+ column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
+
+ _desktop.Root = Ui.Screen(column);
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/MainMenuScene.cs b/src/LittleSim/Scenes/MainMenuScene.cs
index 429873e..64dbc57 100644
--- a/src/LittleSim/Scenes/MainMenuScene.cs
+++ b/src/LittleSim/Scenes/MainMenuScene.cs
@@ -1,44 +1,44 @@
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using MrGameEng.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
-/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
-///
-public sealed class MainMenuScene : Scene
-{
- protected override void OnLoad()
- {
- var content = Context.Services.Get();
- var lang = content.Languages;
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
-
- var column = Ui.Column(12);
- column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
- column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
- column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
- column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
- column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
- column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
- column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
- column.Widgets.Add(
- Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get().Exit())
- );
-
- desktop.Root = Ui.Screen(column);
- }
-
- private void Go(Scene scene)
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(scene, Transitions.Fade(0.4f));
- }
- }
-}
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
+/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
+///
+public sealed class MainMenuScene : Scene
+{
+ protected override void OnLoad()
+ {
+ var content = Context.Services.Get();
+ var lang = content.Languages;
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+
+ var column = Ui.Column(12);
+ column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
+ column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
+ column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
+ column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
+ column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
+ column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
+ column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
+ column.Widgets.Add(
+ Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get().Exit())
+ );
+
+ desktop.Root = Ui.Screen(column);
+ }
+
+ private void Go(Scene scene)
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(scene, Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/NewWorldScene.cs b/src/LittleSim/Scenes/NewWorldScene.cs
index eafdb76..a379f2a 100644
--- a/src/LittleSim/Scenes/NewWorldScene.cs
+++ b/src/LittleSim/Scenes/NewWorldScene.cs
@@ -1,167 +1,167 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using MrGameEng.Input;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Окно настройки нового мира: имя, размер (пресеты из Core-мода),
-/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает .
-///
-public sealed class NewWorldScene : Scene
-{
- private const int MinSmoothing = 1;
- private const int MaxSmoothing = 8;
-
- private WorldPresetDef _preset = null!;
- private int _smoothing = 4;
- private TextBox _name = null!;
- private TextBox _seed = null!;
- private readonly List _refreshers = [];
-
- protected override void OnLoad()
- {
- var content = Context.Services.Get();
- var lang = content.Languages;
- var input = this.UseInput();
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
-
- var presets = content.Defs.All().OrderBy(p => p.Order).ToList();
- _preset = presets[0];
-
- var column = Ui.Column(10);
- column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
- column.Widgets.Add(new Label { Height = 8 });
-
- _name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
- column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
-
- // Размер — сегменты пресетов.
- var sizeRow = Ui.Row(6);
- sizeRow.Widgets.Add(new Label { Text = lang.Get("newworld.size"), TextColor = Ui.Muted });
- foreach (var preset in presets)
- {
- var value = preset;
- var button = new TextButton { Text = lang.Get(preset.Label) };
- button.Click += (_, _) =>
- {
- _preset = value;
- Refresh();
- };
- sizeRow.Widgets.Add(button);
- _refreshers.Add(() =>
- button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
- );
- }
-
- column.Widgets.Add(sizeRow);
-
- // Сид + случайно.
- _seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
- var seedRow = Ui.Row(6);
- seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
- seedRow.Widgets.Add(_seed);
- seedRow.Widgets.Add(
- Ui.Button(
- lang.Get("newworld.random"),
- () => _seed.Text = Random.Shared.Next().ToString(),
- width: 120
- )
- );
- column.Widgets.Add(seedRow);
-
- // Сглаживание −/+.
- var smoothRow = Ui.Row(6);
- var smoothLabel = new Label { TextColor = Ui.Muted };
- _refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
- var minus = new TextButton { Text = "−" };
- minus.Click += (_, _) =>
- {
- _smoothing = Math.Max(MinSmoothing, _smoothing - 1);
- Refresh();
- };
- var plus = new TextButton { Text = "+" };
- plus.Click += (_, _) =>
- {
- _smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
- Refresh();
- };
- smoothRow.Widgets.Add(smoothLabel);
- smoothRow.Widgets.Add(minus);
- smoothRow.Widgets.Add(plus);
- column.Widgets.Add(smoothRow);
-
- column.Widgets.Add(new Label { Height = 8 });
- var buttons = Ui.Row(10);
- buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
- buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
- column.Widgets.Add(buttons);
-
- desktop.Root = Ui.Screen(column);
- Refresh();
-
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private static Widget LabeledRow(string label, Widget control)
- {
- var row = Ui.Row(6);
- row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
- row.Widgets.Add(control);
- return row;
- }
-
- private void Refresh()
- {
- foreach (var refresh in _refreshers)
- {
- refresh();
- }
- }
-
- private void Create()
- {
- if (Context.Scenes.IsTransitioning)
- {
- return;
- }
-
- var seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
- var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
- var config = new WorldConfig
- {
- Name = name,
- Width = _preset.Width,
- Height = _preset.Height,
- Population = _preset.Population,
- Seed = seed,
- SmoothPasses = _smoothing,
- };
- Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Окно настройки нового мира: имя, размер (пресеты из Core-мода),
+/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает .
+///
+public sealed class NewWorldScene : Scene
+{
+ private const int MinSmoothing = 1;
+ private const int MaxSmoothing = 8;
+
+ private WorldPresetDef _preset = null!;
+ private int _smoothing = 4;
+ private TextBox _name = null!;
+ private TextBox _seed = null!;
+ private readonly List _refreshers = [];
+
+ protected override void OnLoad()
+ {
+ var content = Context.Services.Get();
+ var lang = content.Languages;
+ var input = this.UseInput();
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+
+ var presets = content.Defs.All().OrderBy(p => p.Order).ToList();
+ _preset = presets[0];
+
+ var column = Ui.Column(10);
+ column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+
+ _name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
+ column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
+
+ // Размер — сегменты пресетов.
+ var sizeRow = Ui.Row(6);
+ sizeRow.Widgets.Add(new Label { Text = lang.Get("newworld.size"), TextColor = Ui.Muted });
+ foreach (var preset in presets)
+ {
+ var value = preset;
+ var button = new TextButton { Text = lang.Get(preset.Label) };
+ button.Click += (_, _) =>
+ {
+ _preset = value;
+ Refresh();
+ };
+ sizeRow.Widgets.Add(button);
+ _refreshers.Add(() =>
+ button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
+ );
+ }
+
+ column.Widgets.Add(sizeRow);
+
+ // Сид + случайно.
+ _seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
+ var seedRow = Ui.Row(6);
+ seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
+ seedRow.Widgets.Add(_seed);
+ seedRow.Widgets.Add(
+ Ui.Button(
+ lang.Get("newworld.random"),
+ () => _seed.Text = Random.Shared.Next().ToString(),
+ width: 120
+ )
+ );
+ column.Widgets.Add(seedRow);
+
+ // Сглаживание −/+.
+ var smoothRow = Ui.Row(6);
+ var smoothLabel = new Label { TextColor = Ui.Muted };
+ _refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
+ var minus = new TextButton { Text = "−" };
+ minus.Click += (_, _) =>
+ {
+ _smoothing = Math.Max(MinSmoothing, _smoothing - 1);
+ Refresh();
+ };
+ var plus = new TextButton { Text = "+" };
+ plus.Click += (_, _) =>
+ {
+ _smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
+ Refresh();
+ };
+ smoothRow.Widgets.Add(smoothLabel);
+ smoothRow.Widgets.Add(minus);
+ smoothRow.Widgets.Add(plus);
+ column.Widgets.Add(smoothRow);
+
+ column.Widgets.Add(new Label { Height = 8 });
+ var buttons = Ui.Row(10);
+ buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
+ buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
+ column.Widgets.Add(buttons);
+
+ desktop.Root = Ui.Screen(column);
+ Refresh();
+
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private static Widget LabeledRow(string label, Widget control)
+ {
+ var row = Ui.Row(6);
+ row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
+ row.Widgets.Add(control);
+ return row;
+ }
+
+ private void Refresh()
+ {
+ foreach (var refresh in _refreshers)
+ {
+ refresh();
+ }
+ }
+
+ private void Create()
+ {
+ if (Context.Scenes.IsTransitioning)
+ {
+ return;
+ }
+
+ var seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
+ var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
+ var config = new WorldConfig
+ {
+ Name = name,
+ Width = _preset.Width,
+ Height = _preset.Height,
+ Population = _preset.Population,
+ Seed = seed,
+ SmoothPasses = _smoothing,
+ };
+ Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/PauseMenu.cs b/src/LittleSim/Scenes/PauseMenu.cs
index a3aced3..6071706 100644
--- a/src/LittleSim/Scenes/PauseMenu.cs
+++ b/src/LittleSim/Scenes/PauseMenu.cs
@@ -1,168 +1,168 @@
-using System;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using MrGameEng.Audio;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using Myra.Graphics2D.Brushes;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
-/// Открытие ставит игру на паузу через , закрытие — снимает.
-/// Кнопки: Продолжить / Настройки (общая ) / Сохранить /
-/// Главное меню / Выход. добавляется в корневую панель сцены.
-///
-internal sealed class PauseMenu
-{
- private readonly EngineContext _context;
- private readonly GameContent _content;
- private readonly GameSpeed _speed;
- private readonly GameHost _host;
- private readonly AudioManager _audio;
- private readonly Func _onSave;
- private readonly Action _onMainMenu;
- private readonly Action _onQuit;
- private readonly Panel _overlay;
- private GameSettings _settings = GameSettingsStore.Load();
- private bool _inSettings;
- private string? _savedToast;
-
- public PauseMenu(
- EngineContext context,
- GameContent content,
- GameSpeed speed,
- Func onSave,
- Action onMainMenu,
- Action onQuit
- )
- {
- _context = context;
- _content = content;
- _speed = speed;
- _host = (GameHost)context.Services.Get();
- _audio = context.Services.Get();
- _onSave = onSave;
- _onMainMenu = onMainMenu;
- _onQuit = onQuit;
-
- _overlay = new Panel
- {
- Visible = false,
- Background = new SolidBrush(new Color(0, 0, 0, 200)),
- HorizontalAlignment = HorizontalAlignment.Stretch,
- VerticalAlignment = VerticalAlignment.Stretch,
- };
- }
-
- /// Виджет оверлея для добавления в корень сцены.
- public Widget Root => _overlay;
-
- /// Открыт ли оверлей паузы.
- public bool IsOpen { get; private set; }
-
- ///
- /// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
- /// меню паузы.
- ///
- public void Toggle()
- {
- if (!IsOpen)
- {
- Open();
- }
- else if (_inSettings)
- {
- _inSettings = false;
- ShowButtons();
- }
- else
- {
- Close();
- }
- }
-
- private void Open()
- {
- IsOpen = true;
- _inSettings = false;
- _savedToast = null;
- _speed.Pause();
- ShowButtons();
- _overlay.Visible = true;
- }
-
- private void Close()
- {
- IsOpen = false;
- _inSettings = false;
- _overlay.Visible = false;
- _speed.Resume();
- }
-
- private void ShowButtons()
- {
- var lang = _content.Languages;
- var column = Ui.Column(10);
- column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
- column.Widgets.Add(new Label { Height = 8 });
- column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
- column.Widgets.Add(
- Ui.Button(
- lang.Get("pause.settings"),
- () =>
- {
- _inSettings = true;
- ShowSettings();
- }
- )
- );
- column.Widgets.Add(Ui.Button(lang.Get("pause.save"), Save));
- column.Widgets.Add(Ui.Button(lang.Get("pause.mainmenu"), _onMainMenu));
- column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit));
-
- if (_savedToast is not null)
- {
- column.Widgets.Add(
- new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
- );
- }
-
- SetContent(column);
- }
-
- private void ShowSettings() =>
- SetContent(
- SettingsPanel.Build(
- _settings,
- _content.Languages,
- onApply: () =>
- {
- GameSettingsStore.Save(_settings);
- GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
- ShowSettings(); // язык мог смениться
- },
- onBack: () =>
- {
- _inSettings = false;
- ShowButtons();
- }
- )
- );
-
- private void Save()
- {
- _savedToast = _onSave();
- ShowButtons();
- }
-
- private void SetContent(Widget content)
- {
- _overlay.Widgets.Clear();
- _overlay.Widgets.Add(content);
- }
-}
+using System;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using MrGameEng.Audio;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using Myra.Graphics2D.Brushes;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
+/// Открытие ставит игру на паузу через , закрытие — снимает.
+/// Кнопки: Продолжить / Настройки (общая ) / Сохранить /
+/// Главное меню / Выход. добавляется в корневую панель сцены.
+///
+internal sealed class PauseMenu
+{
+ private readonly EngineContext _context;
+ private readonly GameContent _content;
+ private readonly GameSpeed _speed;
+ private readonly GameHost _host;
+ private readonly AudioManager _audio;
+ private readonly Func _onSave;
+ private readonly Action _onMainMenu;
+ private readonly Action _onQuit;
+ private readonly Panel _overlay;
+ private GameSettings _settings = GameSettingsStore.Load();
+ private bool _inSettings;
+ private string? _savedToast;
+
+ public PauseMenu(
+ EngineContext context,
+ GameContent content,
+ GameSpeed speed,
+ Func onSave,
+ Action onMainMenu,
+ Action onQuit
+ )
+ {
+ _context = context;
+ _content = content;
+ _speed = speed;
+ _host = (GameHost)context.Services.Get();
+ _audio = context.Services.Get();
+ _onSave = onSave;
+ _onMainMenu = onMainMenu;
+ _onQuit = onQuit;
+
+ _overlay = new Panel
+ {
+ Visible = false,
+ Background = new SolidBrush(new Color(0, 0, 0, 200)),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ };
+ }
+
+ /// Виджет оверлея для добавления в корень сцены.
+ public Widget Root => _overlay;
+
+ /// Открыт ли оверлей паузы.
+ public bool IsOpen { get; private set; }
+
+ ///
+ /// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
+ /// меню паузы.
+ ///
+ public void Toggle()
+ {
+ if (!IsOpen)
+ {
+ Open();
+ }
+ else if (_inSettings)
+ {
+ _inSettings = false;
+ ShowButtons();
+ }
+ else
+ {
+ Close();
+ }
+ }
+
+ private void Open()
+ {
+ IsOpen = true;
+ _inSettings = false;
+ _savedToast = null;
+ _speed.Pause();
+ ShowButtons();
+ _overlay.Visible = true;
+ }
+
+ private void Close()
+ {
+ IsOpen = false;
+ _inSettings = false;
+ _overlay.Visible = false;
+ _speed.Resume();
+ }
+
+ private void ShowButtons()
+ {
+ var lang = _content.Languages;
+ var column = Ui.Column(10);
+ column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+ column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
+ column.Widgets.Add(
+ Ui.Button(
+ lang.Get("pause.settings"),
+ () =>
+ {
+ _inSettings = true;
+ ShowSettings();
+ }
+ )
+ );
+ column.Widgets.Add(Ui.Button(lang.Get("pause.save"), Save));
+ column.Widgets.Add(Ui.Button(lang.Get("pause.mainmenu"), _onMainMenu));
+ column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit));
+
+ if (_savedToast is not null)
+ {
+ column.Widgets.Add(
+ new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
+ );
+ }
+
+ SetContent(column);
+ }
+
+ private void ShowSettings() =>
+ SetContent(
+ SettingsPanel.Build(
+ _settings,
+ _content.Languages,
+ onApply: () =>
+ {
+ GameSettingsStore.Save(_settings);
+ GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
+ ShowSettings(); // язык мог смениться
+ },
+ onBack: () =>
+ {
+ _inSettings = false;
+ ShowButtons();
+ }
+ )
+ );
+
+ private void Save()
+ {
+ _savedToast = _onSave();
+ ShowButtons();
+ }
+
+ private void SetContent(Widget content)
+ {
+ _overlay.Widgets.Clear();
+ _overlay.Widgets.Add(content);
+ }
+}
diff --git a/src/LittleSim/Scenes/SettingsScene.cs b/src/LittleSim/Scenes/SettingsScene.cs
index 59583cb..ce1e79f 100644
--- a/src/LittleSim/Scenes/SettingsScene.cs
+++ b/src/LittleSim/Scenes/SettingsScene.cs
@@ -1,66 +1,66 @@
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Audio;
-using MrGameEng.Core;
-using MrGameEng.Host;
-using MrGameEng.Input;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
-/// . «Применить» сохраняет в settings.json и применяет к движку
-/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
-///
-public sealed class SettingsScene : Scene
-{
- private GameContent _content = null!;
- private AudioManager _audio = null!;
- private GameHost _host = null!;
- private Desktop _desktop = null!;
- private GameSettings _settings = GameSettingsStore.Load();
-
- protected override void OnLoad()
- {
- _content = Context.Services.Get();
- _audio = Context.Services.Get();
- _host = (GameHost)Context.Services.Get();
- var input = this.UseInput();
-
- _desktop = this.UseUI();
- Rebuild();
-
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private void Rebuild() =>
- _desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
-
- private void Apply()
- {
- GameSettingsStore.Save(_settings);
- GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
- Rebuild(); // язык мог смениться — перестраиваем подписи
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
- }
- }
-}
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Audio;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
+/// . «Применить» сохраняет в settings.json и применяет к движку
+/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
+///
+public sealed class SettingsScene : Scene
+{
+ private GameContent _content = null!;
+ private AudioManager _audio = null!;
+ private GameHost _host = null!;
+ private Desktop _desktop = null!;
+ private GameSettings _settings = GameSettingsStore.Load();
+
+ protected override void OnLoad()
+ {
+ _content = Context.Services.Get();
+ _audio = Context.Services.Get();
+ _host = (GameHost)Context.Services.Get();
+ var input = this.UseInput();
+
+ _desktop = this.UseUI();
+ Rebuild();
+
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private void Rebuild() =>
+ _desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
+
+ private void Apply()
+ {
+ GameSettingsStore.Save(_settings);
+ GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
+ Rebuild(); // язык мог смениться — перестраиваем подписи
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs
index f8f7684..eba5384 100644
--- a/src/LittleSim/Scenes/WorldScene.cs
+++ b/src/LittleSim/Scenes/WorldScene.cs
@@ -1,312 +1,617 @@
-using System;
-using System.Collections.Generic;
-using Friflo.Engine.ECS;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.Sim;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Assets;
-using MrGameEng.Core;
-using MrGameEng.DevConsole;
-using MrGameEng.Graphics;
-using MrGameEng.Host;
-using MrGameEng.Input;
-using MrGameEng.Inspector;
-using MrGameEng.Lighting;
-using MrGameEng.Tilemaps;
-using MrGameEng.UI;
-using Myra.Graphics2D;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
-/// (размер/сид/масштаб деталей) процедурной генерацией движка
-/// (). Каждый тип клетки рисуется текстурой-поверхностью из атласа
-/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
-/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
-///
-public sealed class WorldScene : Scene
-{
- public const int CellSize = 16;
-
- ///
- /// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
- /// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
- ///
- public const float SecondsPerDay = 480f;
-
- private readonly WorldConfig _config;
- private readonly WorldSave? _save;
- private readonly RectF _bounds;
-
- private GameSpeed _speed = null!;
- private PauseMenu _pause = null!;
- private readonly List _speedRefreshers = [];
-
- /// Новый мир из конфига.
- public WorldScene(WorldConfig config)
- : this(config, null) { }
-
- /// Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).
- public WorldScene(WorldConfig config, WorldSave? save)
- {
- _config = config;
- _save = save;
- _bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
- }
-
- protected override void OnLoad()
- {
- var assets = Context.Services.GetOrDefault() ?? Context.UseAssets();
- var content = Context.Services.Get();
- var atlases = Context.Services.Get();
- var device = Context.GetGraphicsDevice();
- var input = this.UseInput();
- _speed = Context.Services.Get();
- _speed.SetStep(0); // новый мир/загрузка стартуют на x1
-
- var renderer = this.UseRenderer2D(
- new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
- );
- GameLayers.EnsureRegistered(renderer);
- this.UseTilemaps();
-
- var calendar = Context.UseCalendar(SecondsPerDay);
- var climate = Context.UseClimate(ClimateSettings.Default);
- var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
-
- // Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
- var plants = new PlantSet(content, atlases, device);
- var random = new Random(_config.Seed);
- BuildTerrain(content, atlases, device, assets, plants, random);
-
- var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
-
- // HUD, полоса скорости и меню-пауза в одной корневой панели.
- var desktop = this.UseUI();
- var hudLabel = new Label { Left = 10, Top = 8 };
- var speedBar = BuildSpeedBar(content);
- _pause = new PauseMenu(
- Context,
- content,
- _speed,
- onSave: SaveWorld,
- onMainMenu: () => Switch(new MainMenuScene()),
- onQuit: () => Context.Services.Get().Exit()
- );
- desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
-
- this.UseInspector(renderer);
- var console = this.UseDevConsole();
- RegisterCommands(console, content, atlases);
-
- UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
- UpdateSystems.Add(
- new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
- );
- UpdateSystems.Add(
- new HudSystem(
- Context,
- content.Languages,
- hudLabel,
- "hud.world",
- _config.Seed,
- calendar,
- climate
- )
- );
- UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
-
- Log.Info(
- $"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
- + (_save is null ? " (new)" : " (loaded)")
- );
- }
-
- ///
- /// Строит одну сущность-: тайлсет из дефов рельефа (поверхность из
- /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
- /// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
- ///
- private void BuildTerrain(
- GameContent content,
- ModAtlases atlases,
- Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
- AssetManager assets,
- PlantSet plants,
- Random random
- )
- {
- var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
-
- // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
- var tiles = new TileSet();
- var tileByDef = new Dictionary();
- foreach (var terrain in content.Terrains.All)
- {
- tileByDef[terrain] = terrain.Surface is { } surface
- ? tiles.Add(atlases.GetRegion(device, surface))
- : tiles.Add(white, terrain.Tint);
- }
-
- // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
- var heights = WorldGenerator.Generate(
- _config.Width,
- _config.Height,
- _config.Seed,
- _config.SmoothPasses
- );
- var grid = new TileGrid(_config.Width, _config.Height);
- for (var x = 0; x < _config.Width; x++)
- {
- for (var y = 0; y < _config.Height; y++)
- {
- var terrain = content.Terrains.Classify(heights[x, y]);
- grid[x, y] = tileByDef[terrain];
- ScatterSpawner.Spawn(
- this,
- content,
- plants,
- terrain,
- new Point(x, y),
- CellSize,
- random
- );
- }
- }
-
- Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
- }
-
- private HorizontalStackPanel BuildSpeedBar(GameContent content)
- {
- var bar = Ui.Row(6);
- bar.HorizontalAlignment = HorizontalAlignment.Center;
- bar.VerticalAlignment = VerticalAlignment.Bottom;
- bar.Margin = new Thickness(0, 0, 0, 12);
-
- var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
- pause.Click += (_, _) => _speed.Pause();
- bar.Widgets.Add(pause);
- _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
-
- for (var i = 0; i < _speed.Steps.Count; i++)
- {
- var index = i;
- var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
- button.Click += (_, _) => _speed.SetStep(index);
- bar.Widgets.Add(button);
- _speedRefreshers.Add(() =>
- button.TextColor =
- !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
- );
- }
-
- void Refresh()
- {
- foreach (var refresh in _speedRefreshers)
- {
- refresh();
- }
- }
-
- _speed.Changed += Refresh;
- RegisterUnload(() => _speed.Changed -= Refresh);
- Refresh();
- return bar;
- }
-
- private void Hotkeys(InputManager input)
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- _pause.Toggle();
- }
-
- if (_pause.IsOpen)
- {
- return;
- }
-
- if (input.IsKeyPressed(Keys.Space))
- {
- _speed.TogglePause();
- }
-
- if (input.IsKeyPressed(Keys.D1))
- {
- _speed.SetStep(0);
- }
-
- if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
- {
- _speed.SetStep(1);
- }
-
- if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
- {
- _speed.SetStep(2);
- }
- }
-
- private string SaveWorld()
- {
- // Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
- var save = new WorldSave
- {
- Name = _config.Name,
- Width = _config.Width,
- Height = _config.Height,
- Seed = _config.Seed,
- SmoothPasses = _config.SmoothPasses,
- Population = _config.Population,
- SavedUtc = DateTime.UtcNow,
- ElapsedSeconds = Context.Clock.TotalTime,
- };
-
- new SaveStore().Write(save);
- Log.Info($"World '{_config.Name}' saved");
- return _config.Name;
- }
-
- private void Switch(Scene scene)
- {
- if (!Context.Scenes.IsTransitioning)
- {
- _speed.Resume();
- Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
- }
- }
-
- private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
- {
- ContentCommands.Register(console, content, atlases);
- console.Register(
- "regen",
- "regen [seed] — regenerate the world with a new seed",
- (c, args) =>
- {
- if (Context.Scenes.IsTransitioning)
- {
- return;
- }
-
- var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
- c.WriteLine($"regenerating world, seed {seed}");
- Context.Scenes.Switch(
- new WorldScene(_config with { Seed = seed }),
- Transitions.Fade(0.6f)
- );
- }
- );
- console.Register(
- "menu",
- "menu — return to the main menu",
- (_, _) => Switch(new MainMenuScene())
- );
- }
-}
+using System;
+using System.Collections.Generic;
+using Friflo.Engine.ECS;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.Sim;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Assets;
+using MrGameEng.Core;
+using MrGameEng.DevConsole;
+using MrGameEng.Formulas;
+using MrGameEng.Genetics;
+using MrGameEng.Graphics;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.Inspector;
+using MrGameEng.Lighting;
+using MrGameEng.Tilemaps;
+using MrGameEng.UI;
+using Myra.Graphics2D;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
+/// (размер/сид/масштаб деталей) процедурной генерацией движка
+/// (). Каждый тип клетки рисуется текстурой-поверхностью из атласа
+/// (вода — тонированным тайлом). Поверх растёт растительность с геномом (рост по свету/температуре/
+/// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости
+/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1).
+///
+public sealed class WorldScene : Scene
+{
+ public const int CellSize = 16;
+
+ ///
+ /// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
+ /// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
+ ///
+ public const float SecondsPerDay = 480f;
+
+ /// Максимум растений на клетку — потолок плотности для размножения.
+ public const int DensityCap = 4;
+
+ private readonly WorldConfig _config;
+ private readonly WorldSave? _save;
+ private readonly RectF _bounds;
+
+ private GameSpeed _speed = null!;
+ private PauseMenu _pause = null!;
+ private readonly List _speedRefreshers = [];
+
+ private PlantSet _plants = null!;
+ private float[] _cellFertility = [];
+ private bool[] _cellLand = [];
+ private bool[] _cellOccluderBase = []; // горы (статично из террейна)
+ private bool[] _cellOccluder = []; // горы + зрелые деревья (пересобирается лайтмапом)
+
+ /// Новый мир из конфига.
+ public WorldScene(WorldConfig config)
+ : this(config, null) { }
+
+ /// Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).
+ public WorldScene(WorldConfig config, WorldSave? save)
+ {
+ _config = config;
+ _save = save;
+ _bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
+ }
+
+ protected override void OnLoad()
+ {
+ var assets = Context.Services.GetOrDefault() ?? Context.UseAssets();
+ var content = Context.Services.Get();
+ var atlases = Context.Services.Get();
+ var device = Context.GetGraphicsDevice();
+ var input = this.UseInput();
+ _speed = Context.Services.Get();
+ _speed.SetStep(0); // новый мир/загрузка стартуют на x1
+
+ var renderer = this.UseRenderer2D(
+ new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
+ );
+ GameLayers.EnsureRegistered(renderer);
+ this.UseTilemaps();
+
+ var calendar = Context.UseCalendar(SecondsPerDay);
+ var climate = Context.UseClimate(ClimateSettings.Default);
+ var dayNight = new DayNight(calendar, DayNightSettings.Default);
+
+ // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
+ _plants = new PlantSet(content, atlases, device);
+ var loadingPlants = _save?.Plants is { Count: > 0 };
+ BuildTerrain(
+ content,
+ atlases,
+ device,
+ assets,
+ loadingPlants ? null : new Random(_config.Seed)
+ );
+ if (loadingPlants)
+ {
+ RestorePlants(content);
+ }
+
+ // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
+ // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
+ var lighting = this.UseLighting(
+ renderer,
+ dayNight,
+ _config.Width,
+ _config.Height,
+ CellSize,
+ Vector2.Zero,
+ BuildOccluders
+ );
+
+ var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
+
+ // HUD, полоса скорости и меню-пауза в одной корневой панели.
+ var desktop = this.UseUI();
+ var hudLabel = new Label { Left = 10, Top = 8 };
+ var speedBar = BuildSpeedBar(content);
+ _pause = new PauseMenu(
+ Context,
+ content,
+ _speed,
+ onSave: SaveWorld,
+ onMainMenu: () => Switch(new MainMenuScene()),
+ onQuit: () => Context.Services.Get().Exit()
+ );
+ desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
+
+ this.UseInspector(renderer);
+ var console = this.UseDevConsole();
+ RegisterCommands(console, content, atlases);
+ console.Register(
+ "light",
+ "light [radius] — place a point light at the cursor (night shadows demo)",
+ (c, args) =>
+ {
+ var radius =
+ args.Length > 0
+ ? float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture)
+ : 120f;
+ var mouse = Mouse.GetState();
+ var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
+ Store.CreateEntity(
+ Transform2D.At(world),
+ new PointLight
+ {
+ Radius = radius,
+ Color = Color.White,
+ Intensity = 0.9f,
+ }
+ );
+ c.WriteLine($"light at {world.X:0},{world.Y:0} r{radius:0}");
+ }
+ );
+
+ UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, lighting, CellSize));
+ UpdateSystems.Add(new PlantFruitingSystem(_plants, calendar, climate));
+ UpdateSystems.Add(
+ new PlantLifecycleSystem(
+ Store,
+ _plants,
+ calendar,
+ Context.Clock,
+ _config.Width,
+ _config.Height,
+ CellSize,
+ _cellFertility,
+ _cellLand,
+ DensityCap,
+ _config.Seed
+ )
+ );
+ UpdateSystems.Add(
+ new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
+ );
+ UpdateSystems.Add(
+ new HudSystem(
+ Context,
+ content.Languages,
+ hudLabel,
+ "hud.world",
+ _config.Seed,
+ calendar,
+ climate
+ )
+ );
+ UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
+
+ Log.Info(
+ $"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
+ + (_save is null ? " (new)" : " (loaded)")
+ );
+ }
+
+ ///
+ /// Строит сущность- (тайлсет из дефов рельефа + сетка по карте высот) и
+ /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если
+ /// задан (новый мир) — рассыпает растительность по дефам;
+ /// при загрузке передаётся null (растения восстанавливаются из сейва отдельно).
+ ///
+ private void BuildTerrain(
+ GameContent content,
+ ModAtlases atlases,
+ Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
+ AssetManager assets,
+ Random? scatterRandom
+ )
+ {
+ var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
+
+ // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
+ var tiles = new TileSet();
+ var tileByDef = new Dictionary();
+ foreach (var terrain in content.Terrains.All)
+ {
+ tileByDef[terrain] = terrain.Surface is { } surface
+ ? tiles.Add(atlases.GetRegion(device, surface))
+ : tiles.Add(white, terrain.Tint);
+ }
+
+ // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
+ var heights = WorldGenerator.Generate(
+ _config.Width,
+ _config.Height,
+ _config.Seed,
+ _config.SmoothPasses
+ );
+ var grid = new TileGrid(_config.Width, _config.Height);
+ var cells = _config.Width * _config.Height;
+ _cellFertility = new float[cells];
+ _cellLand = new bool[cells];
+ _cellOccluderBase = new bool[cells];
+ _cellOccluder = new bool[cells];
+ for (var x = 0; x < _config.Width; x++)
+ {
+ for (var y = 0; y < _config.Height; y++)
+ {
+ var terrain = content.Terrains.Classify(heights[x, y]);
+ grid[x, y] = tileByDef[terrain];
+ var cell = y * _config.Width + x;
+ _cellFertility[cell] = terrain.Fertility;
+ _cellLand[cell] = terrain.IsLand;
+ _cellOccluderBase[cell] = terrain.BlocksLight;
+ if (scatterRandom is not null)
+ {
+ ScatterSpawner.Spawn(
+ this,
+ content,
+ _plants,
+ terrain,
+ new Point(x, y),
+ CellSize,
+ scatterRandom
+ );
+ }
+ }
+ }
+
+ Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
+ }
+
+ private HorizontalStackPanel BuildSpeedBar(GameContent content)
+ {
+ var bar = Ui.Row(6);
+ bar.HorizontalAlignment = HorizontalAlignment.Center;
+ bar.VerticalAlignment = VerticalAlignment.Bottom;
+ bar.Margin = new Thickness(0, 0, 0, 12);
+
+ var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
+ pause.Click += (_, _) => _speed.Pause();
+ bar.Widgets.Add(pause);
+ _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
+
+ for (var i = 0; i < _speed.Steps.Count; i++)
+ {
+ var index = i;
+ var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
+ button.Click += (_, _) => _speed.SetStep(index);
+ bar.Widgets.Add(button);
+ _speedRefreshers.Add(() =>
+ button.TextColor =
+ !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
+ );
+ }
+
+ void Refresh()
+ {
+ foreach (var refresh in _speedRefreshers)
+ {
+ refresh();
+ }
+ }
+
+ _speed.Changed += Refresh;
+ RegisterUnload(() => _speed.Changed -= Refresh);
+ Refresh();
+ return bar;
+ }
+
+ private void Hotkeys(InputManager input)
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ _pause.Toggle();
+ }
+
+ if (_pause.IsOpen)
+ {
+ return;
+ }
+
+ if (input.IsKeyPressed(Keys.Space))
+ {
+ _speed.TogglePause();
+ }
+
+ if (input.IsKeyPressed(Keys.D1))
+ {
+ _speed.SetStep(0);
+ }
+
+ if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
+ {
+ _speed.SetStep(1);
+ }
+
+ if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
+ {
+ _speed.SetStep(2);
+ }
+ }
+
+ private string SaveWorld()
+ {
+ var save = new WorldSave
+ {
+ Name = _config.Name,
+ Width = _config.Width,
+ Height = _config.Height,
+ Seed = _config.Seed,
+ SmoothPasses = _config.SmoothPasses,
+ Population = _config.Population,
+ SavedUtc = DateTime.UtcNow,
+ ElapsedSeconds = Context.Clock.TotalTime,
+ };
+
+ // Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном.
+ Store
+ .Query()
+ .ForEachEntity(
+ (
+ ref PlantGrowth grow,
+ ref PlantOrganism org,
+ ref Transform2D transform,
+ Entity _
+ ) =>
+ {
+ save.Plants.Add(
+ new PlantSave
+ {
+ Species = _plants[grow.Species].Def.DefName,
+ X = transform.Position.X,
+ Y = transform.Position.Y,
+ AgeDays = grow.AgeDays,
+ Stage = grow.Stage,
+ CellFertility = grow.CellFertility,
+ Genome = org.Genome.ToDictionary(),
+ }
+ );
+ }
+ );
+
+ new SaveStore().Write(save);
+ Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)");
+ return _config.Name;
+ }
+
+ private void RestorePlants(GameContent content)
+ {
+ var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
+ foreach (var plant in _save!.Plants)
+ {
+ if (!content.Defs.TryGet(plant.Species, out var def))
+ {
+ continue; // вид пропал (мод убрали) — пропускаем растение
+ }
+
+ var index = _plants.IndexOf(def);
+ var genome = plant.Genome is { Count: > 0 }
+ ? new Genome(plant.Genome)
+ : _plants[index].Template.Generate(fallback);
+ PlantFactory.Create(
+ Store,
+ _plants,
+ index,
+ new Vector2(plant.X, plant.Y),
+ plant.AgeDays,
+ genome,
+ plant.CellFertility,
+ CellSize
+ );
+ }
+ }
+
+ // Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
+ private bool[] BuildOccluders()
+ {
+ Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length);
+ Store
+ .Query()
+ .ForEachEntity(
+ (ref PlantGrowth grow, ref Transform2D transform, Entity _) =>
+ {
+ var species = _plants[grow.Species];
+ if (
+ species.Def.TrunkRadiusCells <= 0f
+ || grow.Stage < species.Stages.Length - 1
+ )
+ {
+ return; // затеняют только зрелые деревья (со стволом)
+ }
+
+ var cx = Math.Clamp(
+ (int)(transform.Position.X / CellSize),
+ 0,
+ _config.Width - 1
+ );
+ var cy = Math.Clamp(
+ (int)(transform.Position.Y / CellSize),
+ 0,
+ _config.Height - 1
+ );
+ _cellOccluder[cy * _config.Width + cx] = true;
+ }
+ );
+ return _cellOccluder;
+ }
+
+ private void Switch(Scene scene)
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ _speed.Resume();
+ Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
+ }
+ }
+
+ private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
+ {
+ ContentCommands.Register(console, content, atlases);
+ console.Register(
+ "regen",
+ "regen [seed] — regenerate the world with a new seed",
+ (c, args) =>
+ {
+ if (Context.Scenes.IsTransitioning)
+ {
+ return;
+ }
+
+ var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
+ c.WriteLine($"regenerating world, seed {seed}");
+ Context.Scenes.Switch(
+ new WorldScene(_config with { Seed = seed }),
+ Transitions.Fade(0.6f)
+ );
+ }
+ );
+ console.Register(
+ "formula",
+ "formula — compile and evaluate an expression (gene-formula engine demo)",
+ (c, args) =>
+ {
+ if (args.Length == 0)
+ {
+ c.WriteLine(
+ "usage: formula e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)"
+ );
+ return;
+ }
+
+ var expression = string.Join(' ', args);
+ try
+ {
+ c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}");
+ }
+ catch (FormulaException e)
+ {
+ c.WriteLine($"error: {e.Message}");
+ }
+ }
+ );
+ console.Register(
+ "gene",
+ "gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child",
+ (c, args) => RunGeneDemo(c, content, args)
+ );
+ console.Register(
+ "plant",
+ "plant [seed] — sample a species genome and show its gene-driven traits/products",
+ (c, args) => RunPlantDemo(c, content, args)
+ );
+ console.Register(
+ "menu",
+ "menu — return to the main menu",
+ (_, _) => Switch(new MainMenuScene())
+ );
+ }
+
+ // Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели,
+ // выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей.
+ private static void RunGeneDemo(DevConsole console, GameContent content, string[] args)
+ {
+ var genes = content.Defs.All();
+ if (genes.Count == 0)
+ {
+ console.WriteLine("no Gene defs loaded");
+ return;
+ }
+
+ var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
+ var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
+ var random = new Random(seed);
+
+ console.WriteLine($"genome from {genes.Count} genes, seed {seed}:");
+ var parentA = Genome.Generate(genes, random);
+ foreach (var gene in genes)
+ {
+ var allele = parentA[gene.DefName];
+ console.WriteLine(
+ $" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}"
+ );
+ }
+
+ console.WriteLine("traits:");
+ foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key))
+ {
+ console.WriteLine($" {trait} = {value:0.###}");
+ }
+
+ var parentB = Genome.Generate(genes, random);
+ var child = Genome.Breed(parentA, parentB, registry, random);
+ var childTraits = Phenotype.Compute(child, registry);
+ console.WriteLine(
+ $"bred child: {child.Alleles.Count} genes, "
+ + $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, "
+ + $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, "
+ + $"variant={childTraits.GetValueOrDefault("variant"):0}"
+ );
+ }
+
+ // Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и
+ // печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов).
+ private void RunPlantDemo(DevConsole console, GameContent content, string[] args)
+ {
+ var species = content.Defs.NamesOf("Plant");
+ if (args.Length == 0)
+ {
+ console.WriteLine("usage: plant [seed] e.g. plant TreeOakA");
+ console.WriteLine($"species: {string.Join(", ", species)}");
+ return;
+ }
+
+ if (!content.Defs.TryGet(args[0], out var def))
+ {
+ console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}");
+ return;
+ }
+
+ var index = _plants.IndexOf(def);
+ var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next();
+ var genome = _plants[index].Template.Generate(new Random(seed));
+ var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry));
+
+ console.WriteLine(
+ $"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:"
+ );
+ console.WriteLine(
+ $" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, "
+ + $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}"
+ );
+
+ if (def.HarvestProduct is { } harvest)
+ {
+ console.WriteLine(
+ $" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}"
+ );
+ }
+
+ if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f)
+ {
+ var season = content.Languages.Get(
+ $"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}"
+ );
+ console.WriteLine(
+ $" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}"
+ );
+ }
+ else
+ {
+ console.WriteLine(" barren (no fruit)");
+ }
+ }
+
+ private static string ProductLabel(GameContent content, string productDefName) =>
+ content.Defs.TryGet(productDefName, out var product)
+ ? content.Languages.Get(product.Label)
+ : productDefName;
+}
diff --git a/src/LittleSim/Sim/PlantFactory.cs b/src/LittleSim/Sim/PlantFactory.cs
new file mode 100644
index 0000000..412247a
--- /dev/null
+++ b/src/LittleSim/Sim/PlantFactory.cs
@@ -0,0 +1,71 @@
+using Friflo.Engine.ECS;
+using LittleSim.Content;
+using LittleSim.Scenes;
+using Microsoft.Xna.Framework;
+using MrGameEng.Genetics;
+using MrGameEng.Graphics;
+
+namespace LittleSim.Sim;
+
+///
+/// Единая точка создания сущности-растения: спрайт стадии по возрасту, признаки из генома формулами
+/// генов, тинт по морфе и компоненты роста/организма. Переиспользуется начальным скаттером,
+/// размножением и загрузкой сейва — чтобы все растения собирались одинаково.
+///
+public static class PlantFactory
+{
+ // Тинт рецессивной морфы (домножается на текстуру) — делает вариант визуально отличимым.
+ private static readonly Color VariantTint = new(214, 150, 176);
+
+ // Якоря оттенка листвы: тёплый жёлто-зелёный → холодный сине-зелёный (по гену leafHue).
+ private static readonly Color LeafWarm = new(170, 200, 90);
+ private static readonly Color LeafCool = new(90, 170, 150);
+
+ /// Создаёт растение вида с заданным возрастом и геномом.
+ public static Entity Create(
+ EntityStore store,
+ PlantSet plants,
+ int species,
+ Vector2 position,
+ float ageDays,
+ Genome genome,
+ float cellFertility,
+ int cellSize
+ )
+ {
+ var sp = plants[species];
+ var stage = sp.StageAt(ageDays);
+ var resolved = sp.Stages[stage];
+ var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, plants.GeneRegistry));
+
+ var sprite = new Sprite(resolved.Region, GameLayers.Beings);
+ sprite.CenterOrigin();
+ sprite.Color = Tint(traits);
+
+ return store.CreateEntity(
+ new Transform2D(
+ position,
+ scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width)
+ ),
+ sprite,
+ new PlantGrowth
+ {
+ Species = species,
+ Stage = stage,
+ AgeDays = ageDays,
+ CellFertility = cellFertility,
+ },
+ new PlantOrganism { Genome = genome, Traits = traits },
+ new Fruiting()
+ );
+ }
+
+ // Цвет растения из генов: лёгкий оттенок листвы (ген leafHue) поверх текстуры; рецессивная морфа
+ // дополнительно сдвигает тинт в свою сторону.
+ private static Color Tint(in PlantPhenotype traits)
+ {
+ var leaf = Color.Lerp(LeafWarm, LeafCool, Math.Clamp(traits.LeafHue, 0f, 1f));
+ var tint = Color.Lerp(Color.White, leaf, 0.35f);
+ return traits.IsVariant ? Color.Lerp(tint, VariantTint, 0.6f) : tint;
+ }
+}
diff --git a/src/LittleSim/Sim/PlantFruitingSystem.cs b/src/LittleSim/Sim/PlantFruitingSystem.cs
new file mode 100644
index 0000000..8579d00
--- /dev/null
+++ b/src/LittleSim/Sim/PlantFruitingSystem.cs
@@ -0,0 +1,62 @@
+using Friflo.Engine.ECS.Systems;
+using LittleSim.Content;
+using MrGameEng.AI;
+using MrGameEng.Core;
+
+namespace LittleSim.Sim;
+
+///
+/// Плодоношение (фаза G4): у зрелого растения с признаком > 0
+/// в его генетический сезон () копятся зрелые плоды до потолка
+/// ; вне сезона плоды опадают. Полностью управляется генами через
+/// признаки — система лишь применяет их к компоненту . Незрелые/бесплодные
+/// растения остаются с нулём.
+///
+public sealed class PlantFruitingSystem(PlantSet plants, Calendar calendar, Climate climate)
+ : QuerySystem
+{
+ // За сколько игровых дней набирается/опадает полный урожай.
+ private const float RipenDays = 10f;
+ private const float DropDays = 20f;
+
+ protected override void OnUpdate()
+ {
+ var deltaDays = Tick.deltaTime / calendar.SecondsPerDay;
+ if (deltaDays <= 0f)
+ {
+ return; // пауза
+ }
+
+ var season = (int)climate.Season;
+
+ foreach (var (growths, organisms, fruits, _) in Query.Chunks)
+ {
+ var g = growths.Span;
+ var orgs = organisms.Span;
+ var f = fruits.Span;
+ for (var i = 0; i < g.Length; i++)
+ {
+ ref var traits = ref orgs[i].Traits;
+ var mature = g[i].Stage >= plants[g[i].Species].Stages.Length - 1;
+ if (!mature || traits.FruitYield < 1f)
+ {
+ f[i].RipeFruit = 0f; // незрелое или бесплодное — плодов нет
+ continue;
+ }
+
+ ref var ripe = ref f[i].RipeFruit;
+ if (season == traits.FruitSeason)
+ {
+ ripe = MathF.Min(
+ traits.FruitYield,
+ ripe + deltaDays * (traits.FruitYield / RipenDays)
+ );
+ }
+ else
+ {
+ ripe = MathF.Max(0f, ripe - deltaDays * (traits.FruitYield / DropDays));
+ }
+ }
+ }
+ }
+}
diff --git a/src/LittleSim/Sim/PlantGenome.cs b/src/LittleSim/Sim/PlantGenome.cs
deleted file mode 100644
index a7971db..0000000
--- a/src/LittleSim/Sim/PlantGenome.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using Friflo.Engine.ECS;
-using LittleSim.Content;
-
-namespace LittleSim.Sim;
-
-///
-/// Диплоидный геном растения: по паре аллелей на ген. Для числовых генов экспрессия гибридная —
-/// фенотип есть среднее двух аллелей (дискретные доминант/рецессивные гены добавятся с размножением).
-/// Аллели — наследуемые данные (правятся в инспекторе); фенотип-свойства читает система роста.
-///
-public struct PlantGenome : IComponent
-{
- /// Аллели оптимума освещённости (0..1).
- public float OptimalLightA;
- public float OptimalLightB;
-
- /// Аллели толерантности по свету.
- public float LightToleranceA;
- public float LightToleranceB;
-
- /// Аллели оптимума температуры (°C).
- public float OptimalTemperatureA;
- public float OptimalTemperatureB;
-
- /// Аллели толерантности по температуре (°C).
- public float TemperatureToleranceA;
- public float TemperatureToleranceB;
-
- /// Аллели оптимума плодородности.
- public float OptimalFertilityA;
- public float OptimalFertilityB;
-
- /// Аллели толерантности по плодородности.
- public float FertilityToleranceA;
- public float FertilityToleranceB;
-
- /// Аллели бодрости роста.
- public float VigorA;
- public float VigorB;
-
- /// Фенотип: оптимальная освещённость.
- public readonly float OptimalLight => (OptimalLightA + OptimalLightB) * 0.5f;
-
- /// Фенотип: толерантность по свету.
- public readonly float LightTolerance => (LightToleranceA + LightToleranceB) * 0.5f;
-
- /// Фенотип: оптимальная температура.
- public readonly float OptimalTemperature => (OptimalTemperatureA + OptimalTemperatureB) * 0.5f;
-
- /// Фенотип: толерантность по температуре.
- public readonly float TemperatureTolerance =>
- (TemperatureToleranceA + TemperatureToleranceB) * 0.5f;
-
- /// Фенотип: оптимальная плодородность.
- public readonly float OptimalFertility => (OptimalFertilityA + OptimalFertilityB) * 0.5f;
-
- /// Фенотип: толерантность по плодородности.
- public readonly float FertilityTolerance => (FertilityToleranceA + FertilityToleranceB) * 0.5f;
-
- /// Фенотип: бодрость роста.
- public readonly float Vigor => (VigorA + VigorB) * 0.5f;
-
- ///
- /// Генерирует особь из базового генома вида: каждый аллель — база ± относительный разброс
- /// (). Сидируется переданным — детерминизм.
- ///
- public static PlantGenome FromDef(GenomeDef def, Random random)
- {
- float Allele(float baseValue) =>
- baseValue + (random.NextSingle() * 2f - 1f) * def.Spread * baseValue;
-
- return new PlantGenome
- {
- OptimalLightA = Allele(def.OptimalLight),
- OptimalLightB = Allele(def.OptimalLight),
- LightToleranceA = Allele(def.LightTolerance),
- LightToleranceB = Allele(def.LightTolerance),
- OptimalTemperatureA = Allele(def.OptimalTemperature),
- OptimalTemperatureB = Allele(def.OptimalTemperature),
- TemperatureToleranceA = Allele(def.TemperatureTolerance),
- TemperatureToleranceB = Allele(def.TemperatureTolerance),
- OptimalFertilityA = Allele(def.OptimalFertility),
- OptimalFertilityB = Allele(def.OptimalFertility),
- FertilityToleranceA = Allele(def.FertilityTolerance),
- FertilityToleranceB = Allele(def.FertilityTolerance),
- VigorA = Allele(def.Vigor),
- VigorB = Allele(def.Vigor),
- };
- }
-}
diff --git a/src/LittleSim/Sim/PlantGrowthSystem.cs b/src/LittleSim/Sim/PlantGrowthSystem.cs
index aafdc72..e7f200c 100644
--- a/src/LittleSim/Sim/PlantGrowthSystem.cs
+++ b/src/LittleSim/Sim/PlantGrowthSystem.cs
@@ -11,17 +11,18 @@ namespace LittleSim.Sim;
///
/// Двигает рост растений по игровому календарю: прирост возраста = пригодность среды для генов
/// растения. Скорость = бодрость · пригодность(свет) · пригодность(температура) ·
-/// пригодность(плодородность), каждая пригодность — гауссов «колокол» вокруг оптимума из генов
-/// (см. ). Свет/температура в фазе B глобальные (день/ночь и сезон); стадии
-/// и спрайт переключаются по накопленному возрасту как прежде. Терминальную стадию пропускает.
+/// пригодность(плодородность), каждая пригодность — гауссов «колокол» вокруг оптимума из
+/// признаков (), вычисленных формулами генов. Свет локальный (лайтмап),
+/// температура глобальная (сезон/день-ночь); стадии и спрайт переключаются по накопленному
+/// возрасту как прежде. Терминальную стадию пропускает.
///
public sealed class PlantGrowthSystem(
PlantSet plants,
Calendar calendar,
Climate climate,
- DayNight dayNight,
+ Lighting lighting,
int cellSize
-) : QuerySystem
+) : QuerySystem
{
protected override void OnUpdate()
{
@@ -31,14 +32,13 @@ public sealed class PlantGrowthSystem(
return; // пауза — рост стоит
}
- // В фазе B свет и температура глобальные — считаем раз за кадр (фаза D даст локальный свет).
- var light = dayNight.Intensity;
+ // Температура глобальная; свет — локальный (лайтмап: подлесок под кронами темнее).
var temperature = climate.Temperature;
- foreach (var (growths, genomes, sprites, transforms, _) in Query.Chunks)
+ foreach (var (growths, organisms, sprites, transforms, _) in Query.Chunks)
{
var g = growths.Span;
- var dna = genomes.Span;
+ var orgs = organisms.Span;
var s = sprites.Span;
var t = transforms.Span;
for (var i = 0; i < g.Length; i++)
@@ -47,22 +47,24 @@ public sealed class PlantGrowthSystem(
var stages = plants[grow.Species].Stages;
if (grow.Stage >= stages.Length - 1)
{
- continue; // уже зрелое — рост окончен
+ grow.AgeDays += deltaDays; // зрелое: старение в реальном времени → смерть по старости
+ continue;
}
- ref var gene = ref dna[i];
+ ref var traits = ref orgs[i].Traits;
+ var light = lighting.SampleAt(t[i].Position);
var rate =
- gene.Vigor
- * Suitability.Gaussian(light, gene.OptimalLight, gene.LightTolerance)
+ traits.Vigor
+ * Suitability.Gaussian(light, traits.OptimalLight, traits.LightTolerance)
* Suitability.Gaussian(
temperature,
- gene.OptimalTemperature,
- gene.TemperatureTolerance
+ traits.OptimalTemperature,
+ traits.TemperatureTolerance
)
* Suitability.Gaussian(
grow.CellFertility,
- gene.OptimalFertility,
- gene.FertilityTolerance
+ traits.OptimalFertility,
+ traits.FertilityTolerance
);
grow.AgeDays += deltaDays * rate;
diff --git a/src/LittleSim/Sim/PlantLifecycleSystem.cs b/src/LittleSim/Sim/PlantLifecycleSystem.cs
new file mode 100644
index 0000000..1f6ff2c
--- /dev/null
+++ b/src/LittleSim/Sim/PlantLifecycleSystem.cs
@@ -0,0 +1,316 @@
+using Friflo.Engine.ECS;
+using Friflo.Engine.ECS.Systems;
+using LittleSim.Content;
+using Microsoft.Xna.Framework;
+using MrGameEng.Core;
+using MrGameEng.Genetics;
+using MrGameEng.Graphics;
+
+namespace LittleSim.Sim;
+
+///
+/// Жизненный цикл растений: размножение (наследование по Менделю) и смерть от старости. Запускается
+/// не каждый кадр, а раз в игрового дня. За тик: строит по-клеточную сетку
+/// (счётчик плотности + «представитель» вида/генома), собирает умерших по возрасту, затем зрелые с
+/// вероятностью elapsed/ReproduceInterval сеют потомка — приоритет перекрёстного опыления
+/// (зрелый сосед того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в
+/// случайную клетку радиуса, если это суша и не превышен лимит плотности. Структурные изменения
+/// (создание/удаление сущностей) применяются после проходов по запросу — это безопасно.
+///
+public sealed class PlantLifecycleSystem : BaseSystem
+{
+ private const float IntervalDays = 0.25f;
+
+ private readonly EntityStore _store;
+ private readonly PlantSet _plants;
+ private readonly Calendar _calendar;
+ private readonly GameClock _clock;
+ private readonly int _width;
+ private readonly int _height;
+ private readonly int _cellSize;
+ private readonly float[] _cellFertility;
+ private readonly bool[] _cellLand;
+ private readonly int _densityCap;
+ private readonly Random _rng;
+ private readonly ArchetypeQuery _query;
+
+ private readonly int[] _count;
+ private readonly CellRep[] _rep;
+ private readonly List _deaths = [];
+ private readonly List _candidates = [];
+ private readonly List _births = [];
+ private float _accumulator;
+
+ public PlantLifecycleSystem(
+ EntityStore store,
+ PlantSet plants,
+ Calendar calendar,
+ GameClock clock,
+ int width,
+ int height,
+ int cellSize,
+ float[] cellFertility,
+ bool[] cellLand,
+ int densityCap,
+ int seed
+ )
+ {
+ _store = store;
+ _plants = plants;
+ _calendar = calendar;
+ _clock = clock;
+ _width = width;
+ _height = height;
+ _cellSize = cellSize;
+ _cellFertility = cellFertility;
+ _cellLand = cellLand;
+ _densityCap = densityCap;
+ _rng = new Random(seed);
+ _query = store.Query();
+ _count = new int[width * height];
+ _rep = new CellRep[width * height];
+ }
+
+ protected override void OnUpdateGroup()
+ {
+ _accumulator += _clock.DeltaTime / _calendar.SecondsPerDay; // прошло игровых дней (с учётом паузы/скорости)
+ if (_accumulator < IntervalDays)
+ {
+ return;
+ }
+
+ var elapsed = _accumulator;
+ _accumulator = 0f;
+
+ BuildGridAndCollect();
+ Reproduce(elapsed);
+ Apply();
+ }
+
+ // Проход 1: сетка плотности + представители, сбор умерших и зрелых-кандидатов.
+ private void BuildGridAndCollect()
+ {
+ Array.Clear(_count);
+ for (var c = 0; c < _rep.Length; c++)
+ {
+ _rep[c].Species = -1;
+ }
+
+ _deaths.Clear();
+ _candidates.Clear();
+ _births.Clear();
+
+ foreach (var (growths, organisms, transforms, entities) in _query.Chunks)
+ {
+ var g = growths.Span;
+ var orgs = organisms.Span;
+ var t = transforms.Span;
+ for (var i = 0; i < g.Length; i++)
+ {
+ ref var grow = ref g[i];
+ ref var org = ref orgs[i];
+ if (grow.AgeDays > org.Traits.Lifespan)
+ {
+ _deaths.Add(entities.EntityAt(i)); // умер от старости
+ continue;
+ }
+
+ var pos = t[i].Position;
+ var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1);
+ var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1);
+ var cell = cy * _width + cx;
+ _count[cell]++;
+
+ var mature = grow.Stage >= _plants[grow.Species].Stages.Length - 1;
+ _rep[cell] = new CellRep
+ {
+ Species = grow.Species,
+ Mature = mature,
+ Genome = org.Genome,
+ MutationRate = org.Traits.MutationRate,
+ };
+ if (mature)
+ {
+ _candidates.Add(
+ new Candidate
+ {
+ Cell = cell,
+ Species = grow.Species,
+ Genome = org.Genome,
+ Traits = org.Traits,
+ }
+ );
+ }
+ }
+ }
+ }
+
+ // Проход 2: зрелые сеют потомка (перекрёстно или само-) в подходящую клетку радиуса.
+ private void Reproduce(float elapsed)
+ {
+ foreach (var cand in _candidates)
+ {
+ var interval = MathF.Max(0.5f, cand.Traits.ReproduceInterval);
+ if (_rng.NextSingle() >= elapsed / interval)
+ {
+ continue; // в этот тик не сеет
+ }
+
+ var range = Math.Clamp((int)MathF.Round(cand.Traits.DispersalRange), 1, 6);
+ var cx = cand.Cell % _width;
+ var cy = cand.Cell / _width;
+
+ if (!TryFindPartner(cx, cy, range, cand.Species, out var partner))
+ {
+ if (_rng.NextSingle() >= cand.Traits.SelfPollination)
+ {
+ continue; // нет партнёра и самоопыление не удалось
+ }
+
+ // самоопыление — партнёр это сам кандидат
+ partner = new Partner
+ {
+ Genome = cand.Genome,
+ MutationRate = cand.Traits.MutationRate,
+ };
+ }
+
+ if (!TryPickTarget(cx, cy, range, out var tcell))
+ {
+ continue; // некуда сеять (нет суши/свободного места рядом)
+ }
+
+ // Темп мутаций потомка — средний эволюционирующий признак родителей (ген управляет мутацией).
+ var mutationChance = (cand.Traits.MutationRate + partner.MutationRate) * 0.5f;
+ var child = Genome.Breed(
+ cand.Genome,
+ partner.Genome,
+ _plants.GeneRegistry,
+ _rng,
+ mutationChance
+ );
+ var tx = tcell % _width;
+ var ty = tcell / _width;
+ var position = new Vector2(
+ (tx + 0.3f + _rng.NextSingle() * 0.4f) * _cellSize,
+ (ty + 0.3f + _rng.NextSingle() * 0.4f) * _cellSize
+ );
+ _births.Add(
+ new Birth
+ {
+ Species = cand.Species,
+ Position = position,
+ Genome = child,
+ Fertility = _cellFertility[tcell],
+ }
+ );
+ _count[tcell]++; // резервируем место, чтобы не переполнить клетку за тик
+ }
+ }
+
+ private bool TryFindPartner(int cx, int cy, int range, int species, out Partner partner)
+ {
+ for (var dy = -range; dy <= range; dy++)
+ {
+ for (var dx = -range; dx <= range; dx++)
+ {
+ if (dx == 0 && dy == 0)
+ {
+ continue; // партнёр — другая клетка (приоритет перекрёстного)
+ }
+
+ var nx = cx + dx;
+ var ny = cy + dy;
+ if (nx < 0 || nx >= _width || ny < 0 || ny >= _height)
+ {
+ continue;
+ }
+
+ ref var rep = ref _rep[ny * _width + nx];
+ if (rep.Species == species && rep.Mature)
+ {
+ partner = new Partner { Genome = rep.Genome, MutationRate = rep.MutationRate };
+ return true;
+ }
+ }
+ }
+
+ partner = default;
+ return false;
+ }
+
+ private bool TryPickTarget(int cx, int cy, int range, out int cell)
+ {
+ for (var attempt = 0; attempt < 6; attempt++)
+ {
+ var tx = cx + _rng.Next(-range, range + 1);
+ var ty = cy + _rng.Next(-range, range + 1);
+ if (tx < 0 || tx >= _width || ty < 0 || ty >= _height)
+ {
+ continue;
+ }
+
+ var candidate = ty * _width + tx;
+ if (_cellLand[candidate] && _count[candidate] < _densityCap)
+ {
+ cell = candidate;
+ return true;
+ }
+ }
+
+ cell = -1;
+ return false;
+ }
+
+ private void Apply()
+ {
+ foreach (var entity in _deaths)
+ {
+ entity.DeleteEntity();
+ }
+
+ foreach (var birth in _births)
+ {
+ PlantFactory.Create(
+ _store,
+ _plants,
+ birth.Species,
+ birth.Position,
+ ageDays: 0f,
+ birth.Genome,
+ birth.Fertility,
+ _cellSize
+ );
+ }
+ }
+
+ private struct CellRep
+ {
+ public int Species;
+ public bool Mature;
+ public Genome Genome;
+ public float MutationRate;
+ }
+
+ private struct Candidate
+ {
+ public int Cell;
+ public int Species;
+ public Genome Genome;
+ public PlantPhenotype Traits;
+ }
+
+ private struct Partner
+ {
+ public Genome Genome;
+ public float MutationRate;
+ }
+
+ private struct Birth
+ {
+ public int Species;
+ public Vector2 Position;
+ public Genome Genome;
+ public float Fertility;
+ }
+}
diff --git a/src/LittleSim/Sim/PlantOrganism.cs b/src/LittleSim/Sim/PlantOrganism.cs
new file mode 100644
index 0000000..8376415
--- /dev/null
+++ b/src/LittleSim/Sim/PlantOrganism.cs
@@ -0,0 +1,87 @@
+using System.Collections.Generic;
+using Friflo.Engine.ECS;
+using MrGameEng.Genetics;
+
+namespace LittleSim.Sim;
+
+///
+/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. genes.json):
+/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую.
+/// Имена полей соответствуют именам признаков в эффектах генов.
+///
+public struct PlantPhenotype
+{
+ public float OptimalLight;
+ public float LightTolerance;
+ public float OptimalTemperature;
+ public float TemperatureTolerance;
+ public float OptimalFertility;
+ public float FertilityTolerance;
+ public float Vigor;
+ public float Lifespan;
+ public float DispersalRange;
+ public float ReproduceInterval;
+ public float SelfPollination;
+ public float MutationRate;
+
+ // --- Контент (G4): плодоношение, добыча, цвет ---
+ public float FruitYield;
+ public int FruitSeason;
+ public float HarvestAmount;
+ public float LeafHue;
+
+ /// Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.
+ public bool IsVariant;
+
+ /// Собирает фенотип из карты признаков, посчитанной .
+ public static PlantPhenotype FromTraits(IReadOnlyDictionary traits)
+ {
+ float T(string name) => traits.GetValueOrDefault(name);
+ return new PlantPhenotype
+ {
+ OptimalLight = T("optimalLight"),
+ LightTolerance = T("lightTolerance"),
+ OptimalTemperature = T("optimalTemperature"),
+ TemperatureTolerance = T("temperatureTolerance"),
+ OptimalFertility = T("optimalFertility"),
+ FertilityTolerance = T("fertilityTolerance"),
+ Vigor = T("vigor"),
+ Lifespan = T("lifespan"),
+ DispersalRange = T("dispersalRange"),
+ ReproduceInterval = T("reproduceInterval"),
+ SelfPollination = T("selfPollination"),
+ MutationRate = T("mutationRate"),
+ FruitYield = T("fruitYield"),
+ FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)),
+ HarvestAmount = T("harvestAmount"),
+ LeafHue = T("leafHue"),
+ IsVariant = T("variant") >= 0.5f,
+ };
+ }
+}
+
+///
+/// Компонент-организм растения: его управляемый (для размножения, сейва и
+/// пересчёта) и выраженный фенотип , по которому работают системы. Заменяет
+/// прежний фиксированный PlantGenome — теперь геном переменного состава и признаки из формул
+/// генов (фаза G3). Геном — ссылочный объект, поэтому компонент несёт ссылку, а не копию.
+///
+public struct PlantOrganism : IComponent
+{
+ /// Геном особи (аллели по генам); общий источник истины для признаков и размножения.
+ public Genome Genome;
+
+ /// Выраженные признаки — то, что читают системы роста/жизненного цикла.
+ public PlantPhenotype Traits;
+}
+
+///
+/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его
+/// сезон плодоношения (признак ) до потолка
+/// , вне сезона опадает. Управляется PlantFruitingSystem.
+///
+public struct Fruiting : IComponent
+{
+ /// Накоплено зрелых плодов (0..FruitYield).
+ public float RipeFruit;
+}