Compare commits

..
Author SHA1 Message Date
Leonid PershinandClaude Fable 5 e57c45bfd3 Merge main (gene system G1-G5, phases C-D) into the multiplayer branch
Main independently adopted the engine Core/Host split in G1, so the
scene adaptations conflicted with identical changes here — resolved in
main's favor (its scenes also carry the gene-driven world). Kept from
this branch: BootScene's --connect path into MultiplayerScene, the
buildAtlases switch in GameContent.Load (with the Task.Run lambda fix
its optional parameter requires), net.* localization keys, and the
Net/Server/Web projects in the solution. Program.cs now has both CLI
modes: --check-content (main) and --connect (here). The engine pointer
lands on d044caf, which linearly contains both lines of work.

Verified on the merged tree: 366 engine tests pass, --check-content
reports genes/traits intact, and a live --listen server replicates
moving pawns to the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:23:51 +03:00
Leonid PershinandClaude Opus 4.8 d52eff07f8 Remove docs/гены.md (gene-system design doc)
The gene system it described (phases G1–G5) is fully implemented; the
design doc is no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 04:09:45 +03:00
Leonid PershinandClaude Opus 4.8 37d8c976a3 G5: bump engine (regex tooling), showcase grouping/patch/validators
Engine pointer -> d044caf (formula gene grouping, content patches, def
field validation). Game-side demonstration of all three:

- genes.json: GeneHardiness whose effect is gsum('Gene.*Tolerance') — a
  derived trait summing all tolerance genes via regex grouping.
- patches.json: a content patch giving every Bush* plant a wood harvest
  product, applied to Core's own defs at load.
- GameContent registers load-time validators: Gene/Product defNames must
  carry their type prefix.
- Program: a --check-content headless mode that loads all mod defs (running
  patches + validators) and computes a sample genome's traits (compiling
  every gene formula, grouping included) — a CI-friendly content lint.

Verified: --check-content reports 6 def types, 18 genes, 8 plants, 18
traits with hardiness computed from the gene group. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 04:00:26 +03:00
Leonid PershinandClaude Opus 4.8 7fc4bcd301 G4: gene-driven content — fruiting, harvest products, leaf color
New plant content, all driven by genes through the trait layer (no engine
change — the genetics machinery already supports it).

- ProductDef + products.json (wood, grass, berries, acorn) — what plants
  yield; localized labels (ru/en).
- New numeric genes (genes.json): GeneFruitYield, GeneFruitSeason,
  GeneHarvestAmount, GeneLeafHue, each declaring its trait via formula.
  GenomeDef carries the per-species bases (PlantSet maps them into the
  species template); PlantDef gains harvestProduct / fruitProduct.
- PlantPhenotype gains FruitYield/FruitSeason/HarvestAmount/LeafHue.
  PlantFactory tints the sprite from the leaf-hue gene (combined with the
  morph variant). Plants carry a Fruiting component; PlantFruitingSystem
  ripens fruit on mature plants during their gene-chosen season and drops
  it off-season.
- plants.json: trees give wood + acorns (autumn), bushes berries (summer),
  grass gives grass — amounts/season/hue from genes.
- 'plant <species> [seed]' console command samples a species genome and
  prints its gene-driven traits and products.

Build clean; def JSON validated; boot smoke loads the new content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:50:03 +03:00
Leonid PershinandClaude Fable 5 580cb6ccc9 Browser multiplayer client: promote the KNI spike to src/LittleSim.Web
LittleSim.Web (Blazor WASM + KNI/WebGL) is a real network client now: it
connects to the dedicated server over WebSocket (ClientWebSocket maps to
the browser socket), applies MrGameEng.Net delta snapshots into its
EntityStore and draws pawns through SpriteBatch — fatigue dims them just
like on desktop. The server address comes from ?server=ws://host:port
in the page URL, defaulting to the page's host on port 9050. The net
contract is mirrored in NetContract.cs (KNI and DesktopGL assemblies
can't mix until the graphics libraries build per platform) with loud
keep-in-sync comments on both sides.

Both clients now smooth replicated positions between 10 Hz snapshots:
NetLerp + NetSmoothingSystem lerp the visual position toward the latest
server position every frame (exponential, ~0.25 s to converge).

Verified against a live LittleSim.Server --listen: the browser client
connects (server log), draws ~3.3k lit pixels of pawns whose layout
changes between samples, and survives 400+ ticks without errors. Found
along the way: requestAnimationFrame freezes in hidden windows — the
game loop only runs while the tab is visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:04:11 +03:00
Leonid PershinandClaude Opus 4.8 9c2c2d7fd0 G3: migrate plants onto genes/traits (drop fixed PlantGenome)
Plants now carry a managed Genome and read formula-computed traits
instead of the hardcoded PlantGenome struct. Engine pointer -> ead2251
(GenomeTemplate + Breed mutationChance override).

- PlantOrganism { Genome; PlantPhenotype Traits } replaces PlantGenome.
  Traits (optima/tolerances, vigor, lifespan, dispersal, reproduce
  interval, self-pollination, mutation rate, morph variant) come from
  Phenotype.Compute over the gene effect formulas in genes.json.
- PlantSet builds a per-species GenomeTemplate by mapping each species'
  existing plants.json genome numbers onto the shared GeneDefs, and
  exposes the gene registry. Species values unchanged — only reinterpreted
  through genes now.
- Growth and lifecycle systems read PlantOrganism.Traits; breeding goes
  through Genome.Breed with the registry and a mutation chance averaged
  from the parents' evolvable mutationRate trait. Scatter generates from
  the species template.
- Save stores the genome as a geneId->Allele map; load rebuilds it
  (empty/legacy saves regenerate from the template — dev saves disposable).

Behaviour matches phases A-D, now fully data-driven through genes and
formulas. Full suite green; boot smoke test loads genes + plants cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:59:46 +03:00
Leonid PershinandClaude Fable 5 e8273f9ffa Multiplayer: networked dedicated server + desktop client over MrGameEng.Net
Engine bump brings MrGameEng.Net (WebSocket transport, RFC 6455 server,
delta component replication) — this commit is its showcase.

LittleSim.Server --listen runs the world online: 24 pawns wander, tire
and rest on the engine's fixed-tick HeadlessHost (the same Wander/
Needs/Decision systems the windowed world uses), a WebSocketServer
accepts clients and a ReplicationServer ships Transform2D + PawnNeeds
deltas at 10 snapshots/s per the shared NetSchema. --probe is the CLI
check: it connects, listens for a second and prints replicated pawns
with positions sampled twice to show the world is alive.

The game gains MultiplayerScene (dotnet run --project src/LittleSim --
--connect [ws://host:port]): simulation stays on the server, the client
applies snapshots into its scene store, decorates spawned entities with
sprites and reuses PawnAppearanceSystem so replicated fatigue darkens
pawns locally. HUD strings go through ru/en localization; the `net`
console command reports connection state and entity count. Esc returns
to the main menu.

Verified end to end: probe sees 24 pawns moving between samples; the
windowed client connects and runs against a live local server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:45:35 +03:00
Leonid PershinandClaude Opus 4.8 91a00b4305 G2: bump engine (gene foundation), load genes + gene demo command
Engine pointer -> bc18db5: brings in the organism-agnostic gene
foundation (GeneDef, managed Genome, Phenotype trait computation) plus a
parallel MrGameEng.Net library the game does not use.

Game wiring: register the "Gene" def type and ship Mods/Core/Defs/
genes.json — a full gene set covering the plant phenotype dimensions
(environment optima/tolerances, vigor, lifecycle, reproduction) plus a
discrete morph gene, each declaring its trait effects as formulas. A
dev-console 'gene [seed]' command generates a genome from these defs,
prints its alleles, expressed values and computed traits, then breeds a
child — demonstrating the whole pipeline end to end. These genes seed
the G3 migration of plants onto traits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:43:50 +03:00
Leonid PershinandClaude Opus 4.8 32bae03869 G1: bump engine (formula engine + Host split), wire formula demo
Engine pointer -> 10898b0, which brings in two engine changes:
- the gene-system formula engine (Core): a data-driven expression
  evaluator that compiles a def string once and evaluates it
  allocation-free against a variable context;
- a parallel refactor splitting the platform out of Core into a new
  MrGameEng.Host library.

Game migration for the Host split: reference MrGameEng.Host (+ add it
and its test project to the solution), import MrGameEng.Host where
GameHost/GameHostOptions/Input are used, switch Transition.Fade ->
Transitions.Fade, and read the device via context.GetGraphicsDevice()
now that EngineContext is platform-free.

Showcase the formula engine with a dev-console 'formula <expr>' command
that compiles and evaluates an expression. Also adds docs/гены.md, the
gene-system design doc (phases G1-G5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:30:44 +03:00
Leonid PershinandClaude Fable 5 ce8f8ba2ed KNI spike: engine core + Friflo render via WebGL in the browser
spikes/KniWeb (outside LittleSim.sln): a kni-blazor-gl template project
(KNI 4.2.9001, net8.0) referencing MrGameEng.Core directly. A mini-host
in the GameHost mold drives EngineContext/GameClock/Scene phases over
KNI's Game; the scene moves 300 Friflo entities in the update phase and
draws them with SpriteBatch (WebGL). Verified in a real browser: sprites
render and animate, browser console is clean.

Decision (docs/web-client.md): path A — KNI — is the primary route for
the web client; the core runs in Blazor WASM unchanged thanks to the
Core/Host split. Known follow-ups: per-platform compilation of the
graphics libraries against nkast.* packages, shader compatibility for
Renderer2D, HTTP-served content instead of the filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:27:40 +03:00
Leonid PershinandClaude Fable 5 8a7e2cce52 Adopt the engine Core/Host split; add LittleSim.Server headless prototype
Engine bump: MrGameEng.Core is now platform-free (Friflo only), the
windowed MonoGame host lives in the new MrGameEng.Host library, and the
core gains HeadlessHost — a fixed-timestep loop without a window or GPU.

Game side: scenes switch to Transitions.Fade from the Host library,
WorldScene reads the graphics device via Context.GetGraphicsDevice(),
GameContent.Load(buildAtlases: false) skips atlas building for headless
runs.

LittleSim.Server is the dedicated-server seed and the showcase for
HeadlessHost: it loads mods/defs without textures and fast-forwards the
world calendar and climate on a fixed tick (~3.6M ticks/s in Debug):

  dotnet run --project src/LittleSim.Server -- --days 10 --tps 60

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:13:40 +03:00
64 changed files with 4698 additions and 375 deletions
+11
View File
@@ -16,6 +16,12 @@ Game design docs live in `docs/` and are written in **Russian**. Engine rules li
```
engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
src/LittleSim the game (net8.0); references engine projects directly
src/LittleSim.Server dedicated server: the world headless (engine HeadlessHost) +
WebSocket replication (MrGameEng.Net); also fast-forward and probe modes
src/LittleSim.Web browser client (Blazor WASM + KNI/WebGL): connects to the dedicated
server, renders replicated pawns; mirrors the net contract (NetContract.cs)
because KNI and DesktopGL assemblies can't mix — keep in sync with
src/LittleSim/Net/NetSchema.cs. Outside LittleSim.sln's test flow.
Mods/Core the game's own content as a mod: About, Defs, Languages, Textures
Cache/ runtime-built atlases (gitignored)
docs/ концепт, симуляция, моды, roadmap (Russian)
@@ -28,6 +34,11 @@ LittleSim.sln game + engine sources + engine tests — one window for everythin
git submodule update --init # after fresh clone
dotnet build LittleSim.sln
dotnet run --project src/LittleSim -c Release # measure perf in Release only
dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fast-forward
dotnet run --project src/LittleSim.Server -- --listen # multiplayer world (WebSocket)
dotnet run --project src/LittleSim -- --connect # client → ws://localhost:9050
dotnet run --project src/LittleSim.Server -- --probe # CLI check of a running server
dotnet run --project src/LittleSim.Web # browser client (?server=ws://…)
dotnet test LittleSim.sln # runs the engine test suites
```
+90
View File
@@ -41,6 +41,18 @@ 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", "{6EFAFBB4-F11A-42B9-A8AB-B98E735431F6}"
EndProject
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}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}"
EndProject
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}") = "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", "{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -231,6 +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
{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
@@ -253,5 +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}
{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
+73
View File
@@ -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" } }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"type": "Patch",
// Контент-патчи (фаза G5): применяются ко всем дефам типа defType, чьё имя подходит под regex match,
// проставляя поля set. Демонстрация: даём кустам древесину при сборе (веточки).
"patches": [
{ "defType": "Plant", "match": "Bush.*", "set": { "harvestProduct": "ProductWood" } }
]
}
+9 -3
View File
@@ -2,10 +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,
"lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08 },
"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" },
@@ -17,20 +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,
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1 },
"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,
"lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1 },
"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" }
+11
View File
@@ -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" }
]
}
+10 -12
View File
@@ -8,7 +8,6 @@
"season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
"hud.paused": "PAUSED",
"menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
"menu.newworld": "New World",
@@ -16,7 +15,6 @@
"menu.settings": "Settings",
"menu.credits": "Credits",
"menu.quit": "Quit",
"newworld.title": "New World",
"newworld.name": "Name",
"newworld.defaultname": "New World",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Terrain smoothing",
"newworld.create": "Create",
"newworld.back": "Back",
"load.title": "Load",
"load.empty": "No saves yet",
"load.load": "Load",
"load.delete": "Delete",
"load.back": "Back",
"settings.title": "Settings",
"settings.language": "Language",
"settings.fullscreen": "Fullscreen",
@@ -41,7 +37,6 @@
"settings.volume": "Volume",
"settings.apply": "Apply",
"settings.back": "Back",
"pause.title": "Paused",
"pause.resume": "Resume",
"pause.settings": "Settings",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Main menu",
"pause.quit": "Quit",
"pause.saved": "Saved: {0}",
"speed.pause": "⏸",
"credits.title": "Credits",
"credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine",
"credits.back": "Back",
"preset.small": "Small",
"preset.medium": "Medium",
"preset.large": "Large",
"terrain.deepwater": "deep water",
"terrain.water": "water",
"terrain.sand": "sand",
"terrain.grass": "grass",
"terrain.forest": "forest",
"terrain.mountain": "mountains",
"plant.oak": "oak",
"plant.birch": "birch",
"plant.pine": "pine",
@@ -77,7 +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",
@@ -86,5 +79,10 @@
"pawn.boar": "boar",
"pawn.wolf": "wolf",
"pawn.muffalo": "muffalo",
"pawn.squirrel": "squirrel"
"pawn.squirrel": "squirrel",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…",
"net.connected": "connected",
"net.failed": "connection failed",
"net.lost": "connection lost"
}
+10 -12
View File
@@ -8,7 +8,6 @@
"season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.paused": "ПАУЗА",
"menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
"menu.newworld": "Новый мир",
@@ -16,7 +15,6 @@
"menu.settings": "Настройки",
"menu.credits": "Авторы",
"menu.quit": "Выход",
"newworld.title": "Новый мир",
"newworld.name": "Название",
"newworld.defaultname": "Новый мир",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Сглаживание рельефа",
"newworld.create": "Создать",
"newworld.back": "Назад",
"load.title": "Загрузка",
"load.empty": "Сохранений пока нет",
"load.load": "Загрузить",
"load.delete": "Удалить",
"load.back": "Назад",
"settings.title": "Настройки",
"settings.language": "Язык",
"settings.fullscreen": "Полный экран",
@@ -41,7 +37,6 @@
"settings.volume": "Громкость",
"settings.apply": "Применить",
"settings.back": "Назад",
"pause.title": "Пауза",
"pause.resume": "Продолжить",
"pause.settings": "Настройки",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Главное меню",
"pause.quit": "Выход",
"pause.saved": "Сохранено: {0}",
"speed.pause": "⏸",
"credits.title": "Авторы",
"credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng",
"credits.back": "Назад",
"preset.small": "Маленький",
"preset.medium": "Средний",
"preset.large": "Большой",
"terrain.deepwater": "глубокая вода",
"terrain.water": "вода",
"terrain.sand": "песок",
"terrain.grass": "трава",
"terrain.forest": "лес",
"terrain.mountain": "горы",
"plant.oak": "дуб",
"plant.birch": "берёза",
"plant.pine": "сосна",
@@ -77,7 +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": "олень",
@@ -86,5 +79,10 @@
"pawn.boar": "кабан",
"pawn.wolf": "волк",
"pawn.muffalo": "муффало",
"pawn.squirrel": "белка"
"pawn.squirrel": "белка",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…",
"net.connected": "подключено",
"net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно"
}
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<SkinSet>
<lifeStage>
<appliesToAll>true</appliesToAll>
<variants>
@@ -24,9 +23,6 @@
<commonality>0.4</commonality>
<texName>GuineaPig3</texName>
</skin>
</variants>
</lifeStage>
</SkinSet>
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<SkinSet>
<lifeStage>
<appliesToAll>true</appliesToAll>
<variants>
@@ -39,8 +38,6 @@
<commonality>0.25</commonality>
<texName>Horse6</texName>
</skin>
</variants>
</lifeStage>
</SkinSet>
+68
View File
@@ -0,0 +1,68 @@
# Веб-клиент: spike KNI и решение A/B
Дата: 2026-06-12. Спайк жил в `spikes/KniWeb`; после успеха повышен до
**`src/LittleSim.Web`** — рабочего браузерного клиента мультиплеера (Blazor WASM + KNI):
он подключается к `LittleSim.Server --listen` по WebSocket, применяет дельта-снапшоты
`MrGameEng.Net` и рисует жителей через WebGL со сглаживанием позиций. Адрес сервера —
`?server=ws://host:port` в URL страницы (по умолчанию — хост страницы, порт 9050).
Сетевой контракт там продублирован бинарным зеркалом (`NetContract.cs`) — KNI- и
DesktopGL-сборки нельзя смешивать, пока графика движка не собирается пер-платформенно.
## Вопрос
Путь A — «MonoGame в браузере» через [KNI](https://github.com/kniEngine/kni)
(форк MonoGame с платформой Blazor WebAssembly/WebGL, те же неймспейсы
`Microsoft.Xna.Framework.*`). Путь B — тонкий веб-клиент без MonoGame
(TypeScript/PixiJS поверх сетевой репликации). Спайк проверял минимальную
жизнеспособность пути A: **ядро движка + Friflo + WebGL-спрайт в браузере**.
## Что сделано
`dotnet new kni-blazor-gl` (пакет шаблонов `nkast.Kni.Templates`, KNI 4.2.9001,
net8.0) + ProjectReference на `engine/src/MrGameEng.Core` + мини-хост в духе
`GameHost` поверх KNI `Game`. Сцена: 300 сущностей в Friflo `EntityStore`,
`QuerySystem` двигает их в Update-фазе (отскок от краёв, seed 42), Draw-фаза
рисует через KNI `SpriteBatch` (WebGL).
## Результат — путь A жизнеспособен
- **`MrGameEng.Core` работает в Blazor WASM без изменений**: `EngineContext`,
`GameClock`, `Scene`/`SceneManager`, тайминг переходов — всё ядро завелось
как есть (заслуга расслоения Core/Host: в ядре нет ни MonoGame, ни платформы).
- **Friflo.Engine.ECS 3.6 работает в wasm**: создание сущностей, архетипы,
`QuerySystem`, `ForEachEntity` — без ошибок в консоли браузера.
- **KNI 4.2.9001 рендерит через WebGL** с XNA-API: `Game`,
`GraphicsDeviceManager`, `SpriteBatch`, `Texture2D.SetData` — совпадает с
кодом, который пишется под десктопный MonoGame.
- Сборка тривиальна: обычный `Microsoft.NET.Sdk.BlazorWebAssembly` проект,
никаких wasm-workload-плясок не понадобилось.
## Известные ограничения пути A (работа на этапе «веб-клиент»)
1. **Пер-платформенная компиляция библиотек движка.** `MrGameEng.Graphics`,
`Content`, `Audio`, `UI` ссылаются на `MonoGame.Framework.DesktopGL`; для
веба их надо собирать против пакетов `nkast.*` (типы те же по API, но другие
сборки). Решение — msbuild-условие (`KniPlatform=BlazorGL` → nkast-пакеты),
без изменения исходников.
2. **Шейдеры.** `Renderer2D` использует прекомпилированные `dotnet-mgfxc`
эффекты — KNI имеет собственный компилятор эффектов; совместимость надо
проверять отдельным спайком, прежде чем тащить батчер в веб.
3. **Нет файловой системы.** Моды/дефы/атласы в браузер приезжают по HTTP;
текущая схема «собрать атласы при старте из PNG» в вебе не работает —
атласы пре-билдятся и кладутся в `wwwroot` (или приезжают с сервера).
4. **Потоки.** `Task.Run`-загрузка контента и `Thread.Sleep`-пейсинг не для
браузера (клиенту `HeadlessHost.Run` и не нужен — цикл гонит
`requestAnimationFrame` через KNI).
5. **Производительность не мерялась** (300 спрайтов — гладко); бюджет
сущностей в wasm-интерпретаторе будет заметно ниже десктопного, замерять
на реальной сцене с включённым AOT.
## Решение
**Путь A (KNI)** — основной для веб-клиента: переиспользуем ядро, сцены и
в перспективе графику движка; код игры один на все платформы. Путь B остаётся
запасным, если упрёмся в шейдеры (п. 2) или производительность (п. 5).
Порядок работ не меняется: сначала `MrGameEng.Net` (WebSocket-транспорт +
репликация — нужен любому пути) и сетевой мультиплеер на десктопе, затем
`MrGameEng.Host.Web` поверх KNI по образцу спайка.
+1 -1
Submodule engine updated: 79d406a9f4...d044cafad9
+126
View File
@@ -0,0 +1,126 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Server;
/// <summary>
/// Серверный мир: жители бродят, устают и отдыхают (те же системы, что в WorldScene) на
/// фиксированном тике <see cref="HeadlessHost"/> — без окна, GPU и спрайтов. С
/// <see cref="WebSocketServer"/> мир ещё и реплицируется подключённым клиентам по схеме
/// <see cref="NetSchema"/> (10 снапшотов в секунду, дельты). Демонстрация MrGameEng.Net.
/// </summary>
internal sealed class HeadlessWorldScene : Scene
{
/// <summary>Жителей в серверном мире.</summary>
public const int PawnCount = 24;
/// <summary>Сид мира — рельефа пока нет, но блуждание детерминировано им.</summary>
public const int Seed = 424242;
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly GameContent _content;
private readonly WebSocketServer? _server;
/// <summary>Мир без сети (fast-forward) или, с <paramref name="server"/>, онлайн-мир.</summary>
public HeadlessWorldScene(GameContent content, WebSocketServer? server = null)
{
_content = content;
_server = server;
}
protected override void OnLoad()
{
Context.Services.Add(_content);
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var replication = _server is null ? null : new ReplicationServer(NetSchema.Create(), Store);
var random = new Random(Seed);
for (var i = 0; i < PawnCount; i++)
{
var position = new Vector2(
random.NextSingle() * Bounds.Width,
random.NextSingle() * Bounds.Height
);
var transform = new Transform2D(position, scale: new Vector2(12f));
var needs = new PawnNeeds { Energy = 0.4f + random.NextSingle() * 0.6f };
if (replication is null)
{
Store.CreateEntity(transform, new Wander(), new PawnBrain(), needs);
}
else
{
Store.CreateEntity(
transform,
new Wander(),
new PawnBrain(),
needs,
new NetId { Value = replication.NextNetId() }
);
}
}
UpdateSystems.Add(new PawnDecisionSystem());
UpdateSystems.Add(new PawnNeedsSystem());
UpdateSystems.Add(new WanderSystem(Seed, Bounds));
UpdateSystems.Add(new DayReportSystem(calendar, climate));
if (_server is not null && replication is not null)
{
UpdateSystems.Add(new NetworkSystem(_server, replication));
}
}
/// <summary>Принимает новые соединения и шлёт дельта-снапшоты с сетевой частотой.</summary>
private sealed class NetworkSystem(WebSocketServer server, ReplicationServer replication)
: BaseSystem
{
// 60 тиков симуляции / 6 = 10 снапшотов в секунду.
private const int SendEveryTicks = 6;
private int _ticks;
protected override void OnUpdateGroup()
{
while (server.TryAcceptConnection(out var connection))
{
Log.Info(
$"Клиент #{connection.Id} подключился ({server.Connections.Count} онлайн)"
);
}
if (++_ticks % SendEveryTicks == 0)
{
replication.Send(server.Connections);
}
}
}
/// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary>
private sealed class DayReportSystem(Calendar calendar, Climate climate) : BaseSystem
{
private int _lastDay;
protected override void OnUpdateGroup()
{
if (calendar.Day == _lastDay)
{
return;
}
_lastDay = calendar.Day;
Log.Info(
$"День {calendar.Day} | год {climate.Year}, день года {climate.DayOfYear + 1}, "
+ $"{climate.Season} | {climate.Temperature:+0.0;-0.0;0.0} °C"
);
}
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LittleSim\LittleSim.csproj" />
</ItemGroup>
</Project>
+151
View File
@@ -0,0 +1,151 @@
using System.Diagnostics;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Server;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
// Дедикейтед-сервер LittleSim: мир без окна и GPU на HeadlessHost движка.
// dotnet run --project src/LittleSim.Server — fast-forward N дней
// dotnet run --project src/LittleSim.Server -- --days 30 — то же, явное число дней
// dotnet run --project src/LittleSim.Server -- --listen [--port N] — онлайн-мир (WebSocket)
// dotnet run --project src/LittleSim.Server -- --probe [--port N] — проверка: подключиться
// к серверу и показать реплицированных жителей
var days = ReadOption("--days", 10);
var ticksPerSecond = ReadOption("--tps", 60);
var port = ReadOption("--port", NetSchema.DefaultPort);
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
if (Array.IndexOf(args, "--probe") >= 0)
{
return await Probe(port);
}
var content = GameContent.Load(buildAtlases: false);
Log.Info(
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
+ $"дефов: {content.Defs.All<TerrainDef>().Count} террейна, "
+ $"{content.Defs.All<PlantDef>().Count} растений, "
+ $"{content.Defs.All<PawnDef>().Count} жителей"
);
if (Array.IndexOf(args, "--listen") >= 0)
{
using var socketServer = new WebSocketServer(port);
socketServer.Start();
using var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = true },
new HeadlessWorldScene(content, socketServer)
);
using var cancel = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancel.Cancel();
};
Log.Info(
$"Мир онлайн: ws://localhost:{port}, {HeadlessWorldScene.PawnCount} жителей, "
+ $"{ticksPerSecond} тиков/с. Клиент: dotnet run --project src/LittleSim -- --connect. "
+ "Ctrl+C — остановка."
);
host.Run(cancel.Token);
Log.Info($"Сервер остановлен на тике {host.TickCount}.");
return 0;
}
using (
var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
new HeadlessWorldScene(content)
)
)
{
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
var stopwatch = Stopwatch.StartNew();
host.RunTicks(ticks);
Log.Info(
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
);
}
return 0;
// Подключается к работающему серверу, секунду слушает снапшоты и показывает жителей —
// консольная проверка репликации без игрового клиента.
async Task<int> Probe(int probePort)
{
var store = new EntityStore();
var replication = new ReplicationClient(NetSchema.Create(), store);
var uri = new Uri($"ws://localhost:{probePort}/");
Log.Info($"Подключение к {uri}…");
WebSocketClient connection;
try
{
connection = await WebSocketClient.ConnectAsync(
uri,
new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token
);
}
catch (Exception exception)
{
Log.Error($"Не подключилось: {exception.GetBaseException().Message}");
return 1;
}
using (connection)
{
await Task.Delay(1000);
replication.Pump(connection);
var first = SamplePositions(store);
Log.Info($"Снапшот получен: {replication.EntityCount} жителей");
await Task.Delay(2000);
replication.Pump(connection);
var second = SamplePositions(store);
for (var i = 0; i < first.Count; i++)
{
Log.Info(
$" житель {first[i].NetId}: ({first[i].X:F0}, {first[i].Y:F0}) → "
+ $"({second[i].X:F0}, {second[i].Y:F0})"
);
}
var moved = first.Where((p, i) => p.X != second[i].X || p.Y != second[i].Y).Count();
Log.Info($"Двигались {moved} из {first.Count} показанных — мир жив.");
return replication.EntityCount > 0 && moved > 0 ? 0 : 1;
}
}
static List<(int NetId, float X, float Y)> SamplePositions(EntityStore store)
{
var result = new List<(int, float, float)>();
foreach (var entity in store.Entities)
{
if (result.Count == 5)
{
break;
}
if (entity.HasComponent<NetId>() && entity.HasComponent<Transform2D>())
{
var position = entity.GetComponent<Transform2D>().Position;
result.Add((entity.GetComponent<NetId>().Value, position.X, position.Y));
}
}
return result;
}
int ReadOption(string name, int fallback)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value)
? value
: fallback;
}
+12
View File
@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,15 @@
#----------------------------- Global Properties ----------------------------#
/outputDir:bin/$(Platform)
/intermediateDir:obj/$(Platform)
/platform:BlazorGL
/config:
/profile:Reach
/compress:True
#-------------------------------- References --------------------------------#
#---------------------------------- Content ---------------------------------#
+1
View File
@@ -0,0 +1 @@
<Project></Project>
+63
View File
@@ -0,0 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>LittleSim.Web</RootNamespace>
<AssemblyName>LittleSim.Web</AssemblyName>
<DefineConstants>$(DefineConstants);BLAZORGL</DefineConstants>
<KniPlatform>BlazorGL</KniPlatform>
</PropertyGroup>
<PropertyGroup>
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
</PropertyGroup>
<ItemGroup>
<Compile Include="Pages\Index.razor.cs" />
<Compile Include="Program.cs" />
<Compile Include="LittleSimWebGame.cs" />
<Compile Include="NetContract.cs" />
<Compile Include="WorldViewScene.cs" />
</ItemGroup>
<ItemGroup>
<!--
Веб-клиент собирается против KNI (форк MonoGame с платформой Blazor/WebGL), поэтому
ссылается только на платформо-независимые библиотеки движка: Core и Net.
Графические библиотеки движка (DesktopGL) сюда подключать нельзя — у KNI свои
сборки с теми же неймспейсами.
-->
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="nkast.Xna.Framework" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Content" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Graphics" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Audio" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Media" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Input" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Game" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Devices" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Storage" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.XR" Version="4.2.9001" />
<PackageReference Include="nkast.Kni.Platform.Blazor.GL" Version="4.2.9001.2" />
<PackageReference Include="nkast.Xna.Framework.Content.Pipeline.Builder" Version="4.2.9001" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.17" />
<PackageReference
Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer"
Version="8.0.17"
PrivateAssets="all"
/>
</ItemGroup>
<ItemGroup>
<KniContentReference Include="Content\LittleSimWebContent.mgcb" />
</ItemGroup>
</Project>
+62
View File
@@ -0,0 +1,62 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace LittleSim.Web;
/// <summary>
/// Веб-хост LittleSim поверх KNI (BlazorGL/WebGL) — браузерный аналог
/// MrGameEng.Host.GameHost: владеет EngineContext ядра, гонит GameClock и фазы сцены.
/// Цикл тикается из requestAnimationFrame (см. Pages/Index.razor.cs). Когда графика
/// движка научится собираться под KNI, этот класс переедет в MrGameEng.Host.Web.
/// </summary>
public class LittleSimWebGame : Game
{
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
public EngineContext Context { get; } = new EngineContext();
private readonly Uri _server;
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch = null!;
private Texture2D _pixel = null!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
public LittleSimWebGame(Uri server)
{
_server = server;
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}
/// <inheritdoc />
protected override void Initialize()
{
base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
}
/// <inheritdoc />
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
_pixel = new Texture2D(GraphicsDevice, 1, 1);
_pixel.SetData(new[] { Color.White });
}
/// <inheritdoc />
protected override void Update(GameTime gameTime)
{
Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds);
Context.Scenes.Update(Context.Clock);
base.Update(gameTime);
}
/// <inheritdoc />
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(new Color(12, 16, 24));
Context.Scenes.Draw(Context.Clock);
base.Draw(gameTime);
}
}
+7
View File
@@ -0,0 +1,7 @@
@inherits LayoutComponentBase
<div class="page">
<main>
@Body
</main>
</div>
+98
View File
@@ -0,0 +1,98 @@
.page
{
position: relative;
display: flex;
flex-direction: column;
}
main
{
flex: 1;
}
.sidebar
{
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row
{
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link
{
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover
{
text-decoration: underline;
}
.top-row ::deep a:first-child
{
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px)
{
.top-row:not(.auth)
{
display: none;
}
.top-row.auth
{
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link
{
margin-left: 0;
}
}
@media (min-width: 641px)
{
.page
{
flex-direction: row;
}
.sidebar
{
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row
{
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child
{
flex: 1;
text-align: right;
width: 0;
}
.top-row, article
{
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
+42
View File
@@ -0,0 +1,42 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Net;
namespace LittleSim.Web;
// ВНИМАНИЕ: бинарное зеркало сетевого контракта десктопа (src/LittleSim/Net/NetSchema.cs).
// Веб-клиент не может ссылаться на LittleSim/MrGameEng.Graphics (они собраны против
// MonoGame DesktopGL, а тут KNI), поэтому реплицируемые компоненты продублированы
// со СТРОГО тем же лейаутом и порядком регистрации. Меняешь схему там — меняй здесь.
// Уйдёт после пер-платформенной сборки графических библиотек (см. docs/web-client.md).
/// <summary>Зеркало MrGameEng.Graphics.Transform2D: Position(8) + Rotation(4) + Scale(8).</summary>
public struct NetTransform : IComponent
{
/// <summary>Позиция в мировых координатах.</summary>
public Vector2 Position;
/// <summary>Поворот в радианах.</summary>
public float Rotation;
/// <summary>Масштаб (у жителей — размер квада в пикселях).</summary>
public Vector2 Scale;
}
/// <summary>Зеркало LittleSim.Sim.PawnNeeds: Energy(4).</summary>
public struct NetPawnNeeds : IComponent
{
/// <summary>Запас сил жителя в [0, 1] — затемняет спрайт.</summary>
public float Energy;
}
/// <summary>Схема репликации веб-клиента — порядок тот же, что в NetSchema десктопа.</summary>
public static class WebNetSchema
{
/// <summary>Порт сервера по умолчанию (NetSchema.DefaultPort).</summary>
public const int DefaultPort = 9050;
/// <summary>Transform2D → NetTransform, PawnNeeds → NetPawnNeeds.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<NetTransform>().Register<NetPawnNeeds>();
}
+20
View File
@@ -0,0 +1,20 @@
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</div>
+60
View File
@@ -0,0 +1,60 @@
using System;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
namespace LittleSim.Web.Pages
{
public partial class Index
{
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private Game? _game;
protected override void OnAfterRender(bool firstRender)
{
base.OnAfterRender(firstRender);
if (firstRender)
{
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
}
}
[JSInvokable]
public void TickDotNet()
{
if (_game == null)
{
_game = new LittleSimWebGame(ResolveServerUri());
_game.Run();
}
_game.Tick();
}
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию —
// хост самой страницы на порту LittleSim.Server.
private Uri ResolveServerUri()
{
var page = new Uri(Navigation.Uri);
var query = page.Query.TrimStart('?');
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separator = pair.IndexOf('=');
if (separator > 0 && pair[..separator] == "server")
{
return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..]));
}
}
return new UriBuilder
{
Scheme = "ws",
Host = page.Host,
Port = WebNetSchema.DefaultPort,
}.Uri;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace LittleSim.Web
{
internal class Program
{
private static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient()
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
});
await builder.Build().RunAsync();
}
}
}
@@ -0,0 +1,30 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:56897",
"sslPort": 0
}
},
"profiles": {
"KniWebSpike": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System;
using System.Threading.Tasks;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
using MrGameEng.Net;
namespace LittleSim.Web;
/// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
/// кадра, усталость затемняет квадратик жителя.
/// </summary>
public sealed class WorldViewScene : Scene
{
private readonly Uri _server;
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
{
_server = server;
_spriteBatch = spriteBatch;
_pixel = pixel;
}
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
private struct NetLerp : IComponent
{
public Vector2 Visual;
public Vector2 Target;
public bool Initialized;
}
protected override void OnLoad()
{
_replication = new ReplicationClient(WebNetSchema.Create(), Store);
_replication.EntitySpawned += entity => entity.AddComponent(new NetLerp());
UpdateSystems.Add(new CallbackSystem(Pump));
UpdateSystems.Add(new NetSmoothingSystem());
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
Log.Info($"LittleSim.Web: connecting to {_server}…");
_connecting = WebSocketClient.ConnectAsync(_server);
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
{
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
}
else
{
_connection = finished.Result;
Log.Info($"Connected to {_server}");
}
}
if (_connection is not null)
{
_replication.Pump(_connection);
}
}
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
private sealed class CallbackSystem(Action update) : BaseSystem
{
protected override void OnUpdateGroup() => update();
}
/// <summary>Та же экспонента, что в NetSmoothingSystem десктопа (LittleSim/Net/NetSmoothing.cs).</summary>
private sealed class NetSmoothingSystem : QuerySystem<NetTransform, NetLerp>
{
private const float Rate = 12f;
protected override void OnUpdate()
{
var blend = 1f - MathF.Exp(-Rate * Tick.deltaTime);
Query.ForEachEntity(
(ref NetTransform transform, ref NetLerp lerp, Entity _) =>
{
if (!lerp.Initialized)
{
lerp.Visual = lerp.Target = transform.Position;
lerp.Initialized = true;
return;
}
if (transform.Position != lerp.Visual)
{
lerp.Target = transform.Position;
}
lerp.Visual = Vector2.Lerp(lerp.Visual, lerp.Target, blend);
transform.Position = lerp.Visual;
}
);
}
}
/// <summary>Житель — квадратик размером Scale, затемняющийся с усталостью (как на десктопе).</summary>
private sealed class PawnDrawSystem(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
: QuerySystem<NetTransform, NetPawnNeeds>
{
private const float MinBrightness = 0.45f;
protected override void OnUpdate()
{
var batch = spriteBatch();
var white = pixel();
batch.Begin();
Query.ForEachEntity(
(ref NetTransform transform, ref NetPawnNeeds needs, Entity _) =>
{
var size = transform.Scale;
var brightness =
MinBrightness + (1f - MinBrightness) * Math.Clamp(needs.Energy, 0f, 1f);
batch.Draw(
white,
new Rectangle(
(int)(transform.Position.X - size.X / 2f),
(int)(transform.Position.Y - size.Y / 2f),
(int)size.X,
(int)size.Y
),
Color.White * brightness
);
}
);
batch.End();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using nkast.Wasm.Canvas
@using LittleSim.Web
+97
View File
@@ -0,0 +1,97 @@
html, body
{
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus
{
outline: none;
}
a, .btn-link
{
color: #0077cc;
}
.btn-primary
{
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.content
{
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox])
{
outline: 1px solid #26b050;
}
.invalid
{
outline: 1px solid red;
}
.validation-message
{
color: red;
}
#blazor-error-ui
{
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss
{
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary
{
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after
{
content: "An error has occurred."
}
#theCanvas
{
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
/* Disable text highlighting and magnifying glass on iPhone/webkit */
-webkit-user-select: none;
}
#canvas
{
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>LittleSim</title>
<base href="./" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link href="LittleSim.Web.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<div id="loading" style="display: table-cell; margin: auto; width:100vw; height:100vh; vertical-align: middle; background: #ffcc10;">
<div style="display: block; margin: auto; width: 9em; color: white;font-family: 'Segoe UI', sans-serif;">
<div style="text-align: center; font-size: 0.85em;">Made with<br/><a href="https://github.com/kniEngine/kni"><img src="kni.png" border="0" alt="Kni"></a></div>
<div style="text-align: center; font-size: 1.8em;">loading&nbsp;<marquee style="width:0.9em; vertical-align: bottom;">.&nbsp;.&nbsp;.&nbsp;&nbsp;&nbsp;</marquee></div>
</div>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">x</a>
</div>
<script src="_framework/blazor.webassembly.js" autostart="false"></script>
<script type="module">
import { BrotliDecode } from './js/decode.min.js';
window.BrotliDecode = BrotliDecode;
// Set this to enable Brotli (.br) decompression on static webServers
// that don't support content compression and http://.
var enableBrotliDecompression = false;
Blazor.start({
loadBootResource: function (type, name, defaultUri, integrity)
{
if (enableBrotliDecompression === true && type !== 'dotnetjs' && location.hostname !== 'localhost')
{
return (async function()
{
const response = await fetch(defaultUri + '.br', { cache: 'no-cache' });
if (!response.ok)
throw new Error(response.statusText);
const originalResponseBuffer = await response.arrayBuffer();
const originalResponseArray = new Int8Array(originalResponseBuffer);
const contentType = (type === 'dotnetwasm')
? 'application/wasm'
: 'application/octet-stream';
const decompressedResponseArray = BrotliDecode(originalResponseArray);
return new Response(decompressedResponseArray,
{ headers: { 'content-type': contentType }
});
})();
}
}
});
</script>
<script src="_content/nkast.Wasm.JSInterop/js/JSObject.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Window.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Document.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Navigator.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Gamepad.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Media.8.0.11.js"></script>
<script src="_content/nkast.Wasm.XHR/js/XHR.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Canvas/js/Canvas.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Canvas/js/CanvasGLContext.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Audio/js/Audio.8.0.11.js"></script>
<script src="_content/nkast.Wasm.XR/js/XR.8.0.11.js"></script>
<script>
function tickJS()
{
window.theInstance.invokeMethod('TickDotNet');
window.requestAnimationFrame(tickJS);
}
window.initRenderJS = (instance) =>
{
window.theInstance = instance;
// set initial canvas size
var canvas = document.getElementById('theCanvas');
var holder = document.getElementById('canvasHolder');
canvas.width = holder.clientWidth;
canvas.height = holder.clientHeight;
// disable context menu on right click
canvas.addEventListener("contextmenu", e => e.preventDefault());
// begin game loop
window.requestAnimationFrame(tickJS);
};
window.addEventListener("keydown", function(event)
{
// Prevent Arrows Keys and Spacebar scrolling the outer page
// when running inside an iframe. e.g: itch.io embedding.
if ([32, 37, 38, 39, 40].indexOf(event.keyCode) > -1)
event.preventDefault();
});
window.addEventListener("wheel", function(event)
{
// Prevent Mousewheel scrolling the outer page
// when running inside an iframe. e.g: itch.io embedding.
event.preventDefault();
}, { passive: false });
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
// micProcessor.js
class MicProcessor extends AudioWorkletProcessor
{
constructor()
{
super();
// global variables for testing
var sampleRate = globalThis.sampleRate;
var currentFrame = globalThis.currentFrame;
var currentTime = globalThis.currentTime;
var currentRenderQuantum = globalThis.currentRenderQuantum;
this.SampleRate = sampleRate;
this.TargetSamples = Math.floor(this.SampleRate * 0.1); // 100ms
this.Buffer = new Float32Array(this.TargetSamples);
this.BufferIndex = 0;
this.port.onmessage = (event) =>
{
var data = event.data;
if (typeof data === 'number')
{
//this.port.postMessage(data); // echo back test
}
if (data instanceof Uint8Array)
{
}
};
}
process(inputs, outputs, parameters)
{
var inChannel0 = inputs[0][0];
if (!inChannel0) return true;
let srcIndex = 0;
var srcLen = inChannel0.length;
while (srcIndex < srcLen)
{
var remaining = this.TargetSamples - this.BufferIndex;
var copyCount = Math.min(remaining, srcLen - srcIndex);
this.Buffer.set(
inChannel0.subarray(srcIndex, srcIndex + copyCount),
this.BufferIndex);
this.BufferIndex += copyCount;
srcIndex += copyCount;
if (this.BufferIndex >= this.TargetSamples)
{
this.SendBuffer();
this.BufferIndex = 0;
}
}
return true;
}
SendBuffer()
{
// convert to 16-6bit PCM
var int16 = new Int16Array(this.TargetSamples);
for (var i = 0; i < this.TargetSamples; i++)
{
int16[i] = this.Buffer[i] * 32767;
}
var byteArray = new Uint8Array(int16.buffer);
this.port.postMessage(byteArray, [byteArray.buffer]);
}
}
registerProcessor('mic-processor', MicProcessor);
@@ -0,0 +1,87 @@
// streamProcessor.js
class StreamProcessor extends AudioWorkletProcessor
{
constructor()
{
super();
this.queue = [];
this.port.onmessage = (event) =>
{
var data = event.data;
if (typeof data === 'number')
{
if (data === 2)
{
this.queue = [];
}
}
if (data instanceof Uint8Array)
{
const buffer = new Int16Array(data.buffer, data.byteOffset, data.length / 2);
buffer.offset = 0;
this.queue.push(buffer);
}
};
}
process(inputs, outputs, parameters)
{
const output = outputs[0];
const channelCount = output.length;
const sampleCount = output[0].length;
let written = 0;
while (written < sampleCount && this.queue.length > 0)
{
const buffer = this.queue[0];
const offset = buffer.offset;
const available = buffer.length - offset;
const needed = sampleCount - written;
const copyCount = Math.min(available, needed);
for (let i = 0; i < copyCount; i++)
{
for (let c = 0; c < channelCount; c++)
{
const channel = output[c];
let value = (buffer[offset+i] / 32767);
channel[written+i] = value;
}
}
written += copyCount;
buffer.offset += copyCount;
if (buffer.offset >= buffer.length)
{
this.queue.shift();
this.port.postMessage(1);
}
}
// Fill remaining samples with silence
if (written < sampleCount)
{
for (let c = 0; c < channelCount; c++)
{
const channel = output[c];
for (let i = written; i < sampleCount; i++)
{
let value = 0;
channel[i] = value;
}
}
}
return true;
}
}
registerProcessor("stream-processor", StreamProcessor);
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

+4 -4
View File
@@ -1,5 +1,5 @@
using System.Text.Json;
using LittleSim.Sim;
using MrGameEng.Genetics;
namespace LittleSim.App;
@@ -24,8 +24,8 @@ public sealed class PlantSave
/// <summary>Плодородность клетки.</summary>
public float CellFertility { get; set; }
/// <summary>Геном особи (аллели сериализуются как поля структуры).</summary>
public PlantGenome Genome { get; set; }
/// <summary>Геном особи: аллели по id генов (переменного состава).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
}
/// <summary>Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по <see cref="DefName"/>).</summary>
@@ -119,7 +119,7 @@ public sealed class SaveStore
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
IncludeFields = true, // аллели генома (PlantGenome) — публичные поля структуры
IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах
};
private readonly string _directory;
+21 -1
View File
@@ -1,5 +1,6 @@
using MrGameEng.Atlases;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Mods;
namespace LittleSim.Content;
@@ -44,8 +45,10 @@ public sealed class GameContent
/// <summary>
/// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и
/// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов.
/// С <paramref name="buildAtlases"/> = false атласы не собираются — headless-режим
/// (дедикейтед-сервер) текстур не рисует.
/// </summary>
public static GameContent Load()
public static GameContent Load(bool buildAtlases = true)
{
var modsRoot =
ModLoader.FindModsRoot(AppContext.BaseDirectory)
@@ -56,9 +59,19 @@ public sealed class GameContent
var defs = new DefDatabase();
defs.RegisterType<TerrainDef>("Terrain");
defs.RegisterType<GeneDef>("Gene");
defs.RegisterType<ProductDef>("Product");
defs.RegisterType<PlantDef>("Plant");
defs.RegisterType<PawnDef>("Pawn");
defs.RegisterType<WorldPresetDef>("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");
@@ -71,6 +84,8 @@ public sealed class GameContent
"Cache",
"Atlases"
);
if (buildAtlases)
{
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
var result = AtlasBuilder.Build(
new AtlasBuildOptions
@@ -84,6 +99,11 @@ public sealed class GameContent
Log.Info(
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
);
}
else
{
Log.Info("Atlases: skipped (headless)");
}
return new GameContent(
mods,
+29
View File
@@ -66,6 +66,17 @@ public sealed class PlantGrowthStage
public string? Label { get; init; }
}
/// <summary>
/// Продукт (Defs/products.json): что растение даёт при сборе (древесина, трава) или плодоношении
/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество
/// определяют гены. <see cref="Def.Label"/> — ключ локализации (product.*).
/// </summary>
public sealed class ProductDef : Def
{
/// <summary>Категория продукта (например "material" или "food") — для будущей экономики/инвентаря.</summary>
public string Kind { get; init; } = "";
}
/// <summary>
/// Базовый геном вида (вложенный объект <see cref="PlantDef.Genome"/>): оптимумы и толерантности по
/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами
@@ -114,6 +125,18 @@ public sealed class GenomeDef
/// <summary>Доля разброса аллелей вокруг базы при генерации особи.</summary>
public float Spread { get; init; } = 0.08f;
/// <summary>Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит).</summary>
public float FruitYield { get; init; }
/// <summary>Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима).</summary>
public float FruitSeason { get; init; } = 1f;
/// <summary>Сколько продукта даёт сбор растения (масштаб <see cref="PlantDef.HarvestProduct"/>).</summary>
public float HarvestAmount { get; init; } = 1f;
/// <summary>Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета.</summary>
public float LeafHue { get; init; } = 0.33f;
}
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
@@ -133,6 +156,12 @@ public sealed class PlantDef : Def
/// <summary>Базовый геном вида: оптимумы/толерантности по среде и бодрость роста.</summary>
public GenomeDef Genome { get; init; } = new();
/// <summary>Имя <see cref="ProductDef"/>, выдаваемого при сборе растения (дерево/трава); null — несборное.</summary>
public string? HarvestProduct { get; init; }
/// <summary>Имя <see cref="ProductDef"/>, выдаваемого плодами (ягоды/жёлудь); null — не плодоносит.</summary>
public string? FruitProduct { get; init; }
}
/// <summary>
+62 -1
View File
@@ -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
/// <summary>Стадии роста по порядку; последняя — терминальная.</summary>
public required Stage[] Stages { get; init; }
/// <summary>Набор генов вида: базовые значения для генерации особи (геном из общих <see cref="GeneDef"/>).</summary>
public required GenomeTemplate Template { get; init; }
/// <summary>День, с которого растение достигает последней (зрелой) стадии.</summary>
public float MaturityDays => Stages[^1].EnterDay;
@@ -42,15 +46,28 @@ public sealed class PlantSet
private readonly Species[] _species;
private readonly Dictionary<PlantDef, int> _index = new();
/// <summary>Общий реестр генов (по id) для скрещивания и вычисления признаков растений.</summary>
public IReadOnlyDictionary<string, GeneDef> GeneRegistry { get; }
/// <summary>Строит таблицу из всех (не-abstract) <see cref="PlantDef"/> мода.</summary>
public PlantSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
{
var genes = content
.Defs.All<GeneDef>()
.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
GeneRegistry = genes;
var defs = content.Defs.All<PlantDef>().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
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(PlantDef def) => _index[def];
// Переводит базовый геном вида (числа из plants.json) в набор генов: каждое число — центр
// аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида.
private static GenomeTemplate BuildTemplate(
GenomeDef g,
IReadOnlyDictionary<string, GeneDef> 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)
{
// Без явных стадий растение существует как одна терминальная стадия из базовой текстуры.
+2
View File
@@ -6,10 +6,12 @@
<ItemGroup>
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Host\MrGameEng.Host.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference
Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
+23
View File
@@ -0,0 +1,23 @@
using LittleSim.Sim;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Net;
/// <summary>
/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
/// Сервер (LittleSim.Server) и клиент (<see cref="Scenes.MultiplayerScene"/>) обязаны
/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
/// ВНИМАНИЕ: у веб-клиента бинарное зеркало этой схемы
/// (src/LittleSim.Web/NetContract.cs — он собран против KNI и не может ссылаться сюда);
/// меняешь состав или порядок — меняй и там.
/// </summary>
public static class NetSchema
{
/// <summary>Порт сервера по умолчанию.</summary>
public const int DefaultPort = 9050;
/// <summary>Схема репликации: позиция/масштаб жителя и его потребности.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
}
+65
View File
@@ -0,0 +1,65 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Net;
/// <summary>
/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду,
/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками.
/// Снапшот пишет в <see cref="Transform2D.Position"/>; система ловит это (позиция
/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает
/// видимую позицию к цели, записывая её обратно в трансформ для рендера.
/// </summary>
public struct NetLerp : IComponent
{
/// <summary>Нарисованная (сглаженная) позиция прошлого кадра.</summary>
public Vector2 Visual;
/// <summary>Последняя серверная позиция — цель сглаживания.</summary>
public Vector2 Target;
/// <summary>Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта.</summary>
public bool Initialized;
}
/// <summary>
/// Двигает <see cref="NetLerp.Visual"/> к <see cref="NetLerp.Target"/> и пишет результат в
/// <see cref="Transform2D.Position"/>. Ставится после прокачки сети и до рендера.
/// </summary>
public sealed class NetSmoothingSystem : QuerySystem<Transform2D, NetLerp>
{
// Скорость экспоненциального сглаживания: за ~0.25 с визуал почти догоняет цель.
private const float Rate = 12f;
protected override void OnUpdate()
{
var blend = 1f - MathF.Exp(-Rate * Tick.deltaTime);
foreach (var (transforms, lerps, _) in Query.Chunks)
{
var t = transforms.Span;
var l = lerps.Span;
for (var i = 0; i < t.Length; i++)
{
ref var lerp = ref l[i];
ref var position = ref t[i].Position;
if (!lerp.Initialized)
{
lerp.Visual = lerp.Target = position;
lerp.Initialized = true;
continue;
}
// Транформ трогает только снапшот: разошёлся с нарисованным — новая цель.
if (position != lerp.Visual)
{
lerp.Target = position;
}
lerp.Visual = Vector2.Lerp(lerp.Visual, lerp.Target, blend);
position = lerp.Visual;
}
}
}
}
+42 -2
View File
@@ -1,6 +1,46 @@
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Host;
// Контент-линт без окна/GPU: грузит моды/дефы (патчи и валидаторы тоже), компилирует формулы генов
// (включая группировку gsum) и выходит. Удобно для CI.
if (args.Contains("--check-content"))
{
try
{
var content = GameContent.Load();
var genes = content.Defs.All<GeneDef>();
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;
var connectIndex = Array.IndexOf(args, "--connect");
if (connectIndex >= 0)
{
connect =
connectIndex + 1 < args.Length && !args[connectIndex + 1].StartsWith("--")
? args[connectIndex + 1]
: $"ws://localhost:{NetSchema.DefaultPort}/";
}
// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost(
@@ -11,7 +51,7 @@ using var host = new GameHost(
Height = 720,
ClearColor = new Color(12, 16, 24),
},
new BootScene()
new BootScene(connect)
);
host.Run();
+15 -2
View File
@@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
@@ -5,6 +6,7 @@ using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
@@ -18,10 +20,18 @@ namespace LittleSim.Scenes;
public sealed class BootScene : Scene
{
private readonly string[] _steps = { "Загрузка", "Loading" };
private readonly string? _connectTo;
private Task<GameContent>? _load;
private GameSettings _settings = new();
private Label _label = null!;
/// <summary>Обычный запуск — в главное меню.</summary>
public BootScene()
: this(null) { }
/// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
public BootScene(string? connectTo) => _connectTo = connectTo;
protected override void OnLoad()
{
Context.UseAudio();
@@ -46,7 +56,7 @@ public sealed class BootScene : Scene
desktop.Root = Ui.Screen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(GameContent.Load);
_load = Task.Run(() => GameContent.Load());
UpdateSystems.Add(new CallbackSystem(Tick));
}
@@ -73,6 +83,9 @@ public sealed class BootScene : Scene
Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language);
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.6f));
Scene next = _connectTo is null
? new MainMenuScene()
: new MultiplayerScene(new Uri(_connectTo));
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ internal static class ScatterSpawner
index,
new Vector2(px, py),
age,
PlantGenome.FromDef(def.Genome, random),
plants[index].Template.Generate(random),
terrain.Fertility,
cellSize
);
+2 -1
View File
@@ -2,6 +2,7 @@ using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.UI;
@@ -40,7 +41,7 @@ public sealed class CreditsScene : Scene
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+3 -2
View File
@@ -3,6 +3,7 @@ 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;
@@ -73,7 +74,7 @@ public sealed class LoadGameScene : Scene
{
Context.Scenes.Switch(
new WorldScene(save.ToConfig(), save),
Transition.Fade(0.6f)
Transitions.Fade(0.6f)
);
}
},
@@ -104,7 +105,7 @@ public sealed class LoadGameScene : Scene
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
namespace LittleSim.Scenes;
@@ -37,7 +38,7 @@ public sealed class MainMenuScene : Scene
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(scene, Transition.Fade(0.4f));
Context.Scenes.Switch(scene, Transitions.Fade(0.4f));
}
}
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using System.Threading.Tasks;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Net;
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.Net;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Сетевой клиент: подключается к дедикейтед-серверу (LittleSim.Server --listen) и рендерит
/// реплицированных жителей. Симуляция целиком на сервере; сюда приезжают только компоненты
/// из <see cref="NetSchema"/>, а спрайты вешаются локально при спавне — граница
/// sim/presentation проходит теперь через сеть. Esc — назад в меню, команда консоли `net` —
/// состояние соединения.
/// </summary>
public sealed class MultiplayerScene : Scene
{
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly Uri _server;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
private ReplicationClient _replication = null!;
private InputManager _input = null!;
private GameContent _content = null!;
private Label _hud = null!;
private string _statusKey = "net.connecting";
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server;
protected override void OnLoad()
{
_content = Context.Services.Get<GameContent>();
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
_input = this.UseInput();
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
Store.CreateEntity(new Camera(Bounds.Center, zoom: 1f, bounds: Bounds));
// Реплика пишет в ECS сцены; презентацию (спрайт) вешаем сами при спавне.
_replication = new ReplicationClient(NetSchema.Create(), Store);
_replication.EntitySpawned += entity =>
{
var sprite = new Sprite(white, GameLayers.Beings);
sprite.CenterOrigin();
entity.AddComponent(sprite);
entity.AddComponent(new NetLerp());
};
var desktop = this.UseUI();
_hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud);
UpdateSystems.Add(new CallbackSystem(Pump));
// Снапшоты приходят ~10 раз в секунду — сглаживаем позиции до частоты кадра.
UpdateSystems.Add(new NetSmoothingSystem());
// Усталость жителей видна и по сети: PawnNeeds реплицируется, спрайт темнеет локально.
UpdateSystems.Add(new PawnAppearanceSystem());
var console = this.UseDevConsole();
console.Register(
"net",
"net — connection status and replicated entity count",
(c, _) =>
{
c.WriteLine($"server: {_server}");
c.WriteLine($"state: {(_connection?.IsOpen == true ? "connected" : "offline")}");
c.WriteLine($"entities: {_replication.EntityCount}");
}
);
_connecting = WebSocketClient.ConnectAsync(_server);
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
{
_statusKey = "net.failed";
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
}
else
{
_connection = finished.Result;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
}
if (_connection is not null)
{
_replication.Pump(_connection);
if (!_connection.IsOpen)
{
_statusKey = "net.lost";
}
}
_hud.Text = _content.Languages.Format(
"net.hud",
_content.Languages.Get(_statusKey),
_replication.EntityCount
);
if (_input.IsKeyPressed(Keys.Escape))
{
_connection?.Close();
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+3 -2
View File
@@ -6,6 +6,7 @@ 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;
@@ -153,14 +154,14 @@ public sealed class NewWorldScene : Scene
Seed = seed,
SmoothPasses = _smoothing,
};
Context.Scenes.Switch(new WorldScene(config), Transition.Fade(0.6f));
Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
}
private void Back()
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+1
View File
@@ -5,6 +5,7 @@ using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
+2 -1
View File
@@ -5,6 +5,7 @@ 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;
@@ -59,7 +60,7 @@ public sealed class SettingsScene : Scene
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+156 -8
View File
@@ -10,7 +10,10 @@ 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;
@@ -73,7 +76,7 @@ public sealed class WorldScene : Scene
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var content = Context.Services.Get<GameContent>();
var atlases = Context.Services.Get<ModAtlases>();
var device = Context.GraphicsDevice;
var device = Context.GetGraphicsDevice();
var input = this.UseInput();
_speed = Context.Services.Get<GameSpeed>();
_speed.SetStep(0); // новый мир/загрузка стартуют на x1
@@ -159,6 +162,7 @@ public sealed class WorldScene : Scene
);
UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, lighting, CellSize));
UpdateSystems.Add(new PlantFruitingSystem(_plants, calendar, climate));
UpdateSystems.Add(
new PlantLifecycleSystem(
Store,
@@ -350,9 +354,14 @@ public sealed class WorldScene : Scene
// Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном.
Store
.Query<PlantGrowth, PlantGenome, Transform2D>()
.Query<PlantGrowth, PlantOrganism, Transform2D>()
.ForEachEntity(
(ref PlantGrowth grow, ref PlantGenome gene, ref Transform2D transform, Entity _) =>
(
ref PlantGrowth grow,
ref PlantOrganism org,
ref Transform2D transform,
Entity _
) =>
{
save.Plants.Add(
new PlantSave
@@ -363,7 +372,7 @@ public sealed class WorldScene : Scene
AgeDays = grow.AgeDays,
Stage = grow.Stage,
CellFertility = grow.CellFertility,
Genome = gene,
Genome = org.Genome.ToDictionary(),
}
);
}
@@ -376,6 +385,7 @@ public sealed class WorldScene : Scene
private void RestorePlants(GameContent content)
{
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
foreach (var plant in _save!.Plants)
{
if (!content.Defs.TryGet<PlantDef>(plant.Species, out var def))
@@ -383,13 +393,17 @@ public sealed class WorldScene : Scene
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,
_plants.IndexOf(def),
index,
new Vector2(plant.X, plant.Y),
plant.AgeDays,
plant.Genome,
genome,
plant.CellFertility,
CellSize
);
@@ -435,7 +449,7 @@ public sealed class WorldScene : Scene
if (!Context.Scenes.IsTransitioning)
{
_speed.Resume();
Context.Scenes.Switch(scene, Transition.Fade(0.5f));
Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
}
}
@@ -456,14 +470,148 @@ public sealed class WorldScene : Scene
c.WriteLine($"regenerating world, seed {seed}");
Context.Scenes.Switch(
new WorldScene(_config with { Seed = seed }),
Transition.Fade(0.6f)
Transitions.Fade(0.6f)
);
}
);
console.Register(
"formula",
"formula <expr> — compile and evaluate an expression (gene-formula engine demo)",
(c, args) =>
{
if (args.Length == 0)
{
c.WriteLine(
"usage: formula <expr> 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 <species> [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<GeneDef>();
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 <species> [seed] e.g. plant TreeOakA");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<PlantDef>(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<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label)
: productDefName;
}
+22 -6
View File
@@ -2,20 +2,25 @@ using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Единая точка создания сущности-растения: спрайт стадии по возрасту, тинт по морфе генома и
/// компоненты роста/генома. Переиспользуется начальным скаттером, размножением и загрузкой сейва —
/// чтобы все растения собирались одинаково.
/// Единая точка создания сущности-растения: спрайт стадии по возрасту, признаки из генома формулами
/// генов, тинт по морфе и компоненты роста/организма. Переиспользуется начальным скаттером,
/// размножением и загрузкой сейва — чтобы все растения собирались одинаково.
/// </summary>
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);
/// <summary>Создаёт растение вида <paramref name="species"/> с заданным возрастом и геномом.</summary>
public static Entity Create(
EntityStore store,
@@ -23,7 +28,7 @@ public static class PlantFactory
int species,
Vector2 position,
float ageDays,
in PlantGenome genome,
Genome genome,
float cellFertility,
int cellSize
)
@@ -31,10 +36,11 @@ public static class PlantFactory
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 = genome.IsVariant ? VariantTint : Color.White;
sprite.Color = Tint(traits);
return store.CreateEntity(
new Transform2D(
@@ -49,7 +55,17 @@ public static class PlantFactory
AgeDays = ageDays,
CellFertility = cellFertility,
},
genome
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;
}
}
+62
View File
@@ -0,0 +1,62 @@
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using MrGameEng.AI;
using MrGameEng.Core;
namespace LittleSim.Sim;
/// <summary>
/// Плодоношение (фаза G4): у зрелого растения с признаком <see cref="PlantPhenotype.FruitYield"/> &gt; 0
/// в его генетический сезон (<see cref="PlantPhenotype.FruitSeason"/>) копятся зрелые плоды до потолка
/// <see cref="PlantPhenotype.FruitYield"/>; вне сезона плоды опадают. Полностью управляется генами через
/// признаки — система лишь применяет их к компоненту <see cref="Fruiting"/>. Незрелые/бесплодные
/// растения остаются с нулём.
/// </summary>
public sealed class PlantFruitingSystem(PlantSet plants, Calendar calendar, Climate climate)
: QuerySystem<PlantGrowth, PlantOrganism, Fruiting>
{
// За сколько игровых дней набирается/опадает полный урожай.
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));
}
}
}
}
}
-171
View File
@@ -1,171 +0,0 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
namespace LittleSim.Sim;
/// <summary>
/// Диплоидный геном растения: по паре аллелей на ген. Числовые гены экспрессируются гибридно —
/// фенотип есть среднее двух аллелей; дискретный ген морфы — по Менделю (рецессив виден только в
/// гомозиготе). Аллели наследуются (правятся в инспекторе); фенотип-свойства читают системы роста и
/// жизненного цикла. <see cref="Breed"/> моделирует мейоз и мутацию.
/// </summary>
public struct PlantGenome : IComponent
{
// --- Числовые гены (аллель A/B), фенотип = среднее ------------------------------------------
public float OptimalLightA;
public float OptimalLightB;
public float LightToleranceA;
public float LightToleranceB;
public float OptimalTemperatureA;
public float OptimalTemperatureB;
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 float LifespanA;
public float LifespanB;
public float DispersalRangeA;
public float DispersalRangeB;
public float ReproduceIntervalA;
public float ReproduceIntervalB;
public float SelfPollinationA;
public float SelfPollinationB;
public float MutationRateA;
public float MutationRateB;
// --- Дискретный ген морфы (0 — доминантный, 1 — рецессивный) --------------------------------
public int MorphA;
public int MorphB;
/// <summary>Фенотип: оптимальная освещённость.</summary>
public readonly float OptimalLight => (OptimalLightA + OptimalLightB) * 0.5f;
/// <summary>Фенотип: толерантность по свету.</summary>
public readonly float LightTolerance => (LightToleranceA + LightToleranceB) * 0.5f;
/// <summary>Фенотип: оптимальная температура.</summary>
public readonly float OptimalTemperature => (OptimalTemperatureA + OptimalTemperatureB) * 0.5f;
/// <summary>Фенотип: толерантность по температуре.</summary>
public readonly float TemperatureTolerance =>
(TemperatureToleranceA + TemperatureToleranceB) * 0.5f;
/// <summary>Фенотип: оптимальная плодородность.</summary>
public readonly float OptimalFertility => (OptimalFertilityA + OptimalFertilityB) * 0.5f;
/// <summary>Фенотип: толерантность по плодородности.</summary>
public readonly float FertilityTolerance => (FertilityToleranceA + FertilityToleranceB) * 0.5f;
/// <summary>Фенотип: бодрость роста.</summary>
public readonly float Vigor => (VigorA + VigorB) * 0.5f;
/// <summary>Фенотип: продолжительность жизни (дней).</summary>
public readonly float Lifespan => (LifespanA + LifespanB) * 0.5f;
/// <summary>Фенотип: дальность расселения (клеток).</summary>
public readonly float DispersalRange => (DispersalRangeA + DispersalRangeB) * 0.5f;
/// <summary>Фенотип: интервал размножения (дней).</summary>
public readonly float ReproduceInterval => (ReproduceIntervalA + ReproduceIntervalB) * 0.5f;
/// <summary>Фенотип: способность к самоопылению (0..1).</summary>
public readonly float SelfPollination => (SelfPollinationA + SelfPollinationB) * 0.5f;
/// <summary>Фенотип: темп мутаций (0..1).</summary>
public readonly float MutationRate => (MutationRateA + MutationRateB) * 0.5f;
/// <summary>Фенотип морфы: рецессивный вариант проявляется только в гомозиготе (1,1).</summary>
public readonly bool IsVariant => MorphA == 1 && MorphB == 1;
/// <summary>
/// Генерирует особь из базового генома вида: числовые аллели — база ± относительный разброс
/// (<see cref="GenomeDef.Spread"/>); аллель морфы = рецессивный (1) с вероятностью
/// <see cref="GenomeDef.VariantChance"/>. Сидируется <paramref name="random"/> — детерминизм.
/// </summary>
public static PlantGenome FromDef(GenomeDef def, Random random)
{
float Allele(float baseValue) =>
baseValue + (random.NextSingle() * 2f - 1f) * def.Spread * baseValue;
int MorphAllele() => random.NextSingle() < def.VariantChance ? 1 : 0;
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),
LifespanA = Allele(def.Lifespan),
LifespanB = Allele(def.Lifespan),
DispersalRangeA = Allele(def.DispersalRange),
DispersalRangeB = Allele(def.DispersalRange),
ReproduceIntervalA = Allele(def.ReproduceInterval),
ReproduceIntervalB = Allele(def.ReproduceInterval),
SelfPollinationA = Allele(def.SelfPollination),
SelfPollinationB = Allele(def.SelfPollination),
MutationRateA = Allele(def.MutationRate),
MutationRateB = Allele(def.MutationRate),
MorphA = MorphAllele(),
MorphB = MorphAllele(),
};
}
/// <summary>
/// Потомок двух родителей (мейоз): аллель A — случайный из родителя <paramref name="a"/>, аллель
/// B — из <paramref name="b"/>; затем мутация (с вероятностью среднего темпа мутаций родителей —
/// сдвиг числового аллеля на малую долю / переключение морфы). Самоопыление = <c>Breed(self, self)</c>.
/// </summary>
public static PlantGenome Breed(in PlantGenome a, in PlantGenome b, Random rng)
{
var rate = (a.MutationRate + b.MutationRate) * 0.5f;
float Mut(float v) =>
rng.NextSingle() < rate ? v + (rng.NextSingle() * 2f - 1f) * 0.12f * v : v;
float FromA(float x, float y) => Mut(rng.NextSingle() < 0.5f ? x : y);
int MutMorph(int m) => rng.NextSingle() < rate ? 1 - m : m;
return new PlantGenome
{
OptimalLightA = FromA(a.OptimalLightA, a.OptimalLightB),
OptimalLightB = FromA(b.OptimalLightA, b.OptimalLightB),
LightToleranceA = FromA(a.LightToleranceA, a.LightToleranceB),
LightToleranceB = FromA(b.LightToleranceA, b.LightToleranceB),
OptimalTemperatureA = FromA(a.OptimalTemperatureA, a.OptimalTemperatureB),
OptimalTemperatureB = FromA(b.OptimalTemperatureA, b.OptimalTemperatureB),
TemperatureToleranceA = FromA(a.TemperatureToleranceA, a.TemperatureToleranceB),
TemperatureToleranceB = FromA(b.TemperatureToleranceA, b.TemperatureToleranceB),
OptimalFertilityA = FromA(a.OptimalFertilityA, a.OptimalFertilityB),
OptimalFertilityB = FromA(b.OptimalFertilityA, b.OptimalFertilityB),
FertilityToleranceA = FromA(a.FertilityToleranceA, a.FertilityToleranceB),
FertilityToleranceB = FromA(b.FertilityToleranceA, b.FertilityToleranceB),
VigorA = FromA(a.VigorA, a.VigorB),
VigorB = FromA(b.VigorA, b.VigorB),
LifespanA = FromA(a.LifespanA, a.LifespanB),
LifespanB = FromA(b.LifespanA, b.LifespanB),
DispersalRangeA = FromA(a.DispersalRangeA, a.DispersalRangeB),
DispersalRangeB = FromA(b.DispersalRangeA, b.DispersalRangeB),
ReproduceIntervalA = FromA(a.ReproduceIntervalA, a.ReproduceIntervalB),
ReproduceIntervalB = FromA(b.ReproduceIntervalA, b.ReproduceIntervalB),
SelfPollinationA = FromA(a.SelfPollinationA, a.SelfPollinationB),
SelfPollinationB = FromA(b.SelfPollinationA, b.SelfPollinationB),
MutationRateA = FromA(a.MutationRateA, a.MutationRateB),
MutationRateB = FromA(b.MutationRateA, b.MutationRateB),
MorphA = MutMorph(rng.NextSingle() < 0.5f ? a.MorphA : a.MorphB),
MorphB = MutMorph(rng.NextSingle() < 0.5f ? b.MorphA : b.MorphB),
};
}
}
+14 -13
View File
@@ -11,9 +11,10 @@ namespace LittleSim.Sim;
/// <summary>
/// Двигает рост растений по игровому календарю: прирост возраста = пригодность среды для генов
/// растения. Скорость = <c>бодрость · пригодность(свет) · пригодность(температура) ·
/// пригодность(плодородность)</c>, каждая пригодность — гауссов «колокол» вокруг оптимума из генов
/// (см. <see cref="PlantGenome"/>). Свет/температура в фазе B глобальные (день/ночь и сезон); стадии
/// и спрайт переключаются по накопленному возрасту как прежде. Терминальную стадию пропускает.
/// пригодность(плодородность)</c>, каждая пригодность — гауссов «колокол» вокруг оптимума из
/// признаков (<see cref="PlantPhenotype"/>), вычисленных формулами генов. Свет локальный (лайтмап),
/// температура глобальная (сезон/день-ночь); стадии и спрайт переключаются по накопленному
/// возрасту как прежде. Терминальную стадию пропускает.
/// </summary>
public sealed class PlantGrowthSystem(
PlantSet plants,
@@ -21,7 +22,7 @@ public sealed class PlantGrowthSystem(
Climate climate,
Lighting lighting,
int cellSize
) : QuerySystem<PlantGrowth, PlantGenome, Sprite, Transform2D>
) : QuerySystem<PlantGrowth, PlantOrganism, Sprite, Transform2D>
{
protected override void OnUpdate()
{
@@ -34,10 +35,10 @@ public sealed class PlantGrowthSystem(
// Температура глобальная; свет — локальный (лайтмап: подлесок под кронами темнее).
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++)
@@ -50,20 +51,20 @@ public sealed class PlantGrowthSystem(
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;
+42 -18
View File
@@ -3,6 +3,7 @@ using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
@@ -31,7 +32,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
private readonly bool[] _cellLand;
private readonly int _densityCap;
private readonly Random _rng;
private readonly ArchetypeQuery<PlantGrowth, PlantGenome, Transform2D> _query;
private readonly ArchetypeQuery<PlantGrowth, PlantOrganism, Transform2D> _query;
private readonly int[] _count;
private readonly CellRep[] _rep;
@@ -65,7 +66,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_cellLand = cellLand;
_densityCap = densityCap;
_rng = new Random(seed);
_query = store.Query<PlantGrowth, PlantGenome, Transform2D>();
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D>();
_count = new int[width * height];
_rep = new CellRep[width * height];
}
@@ -99,16 +100,16 @@ public sealed class PlantLifecycleSystem : BaseSystem
_candidates.Clear();
_births.Clear();
foreach (var (growths, genomes, transforms, entities) in _query.Chunks)
foreach (var (growths, organisms, transforms, entities) in _query.Chunks)
{
var g = growths.Span;
var dna = genomes.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 gene = ref dna[i];
if (grow.AgeDays > gene.Lifespan)
ref var org = ref orgs[i];
if (grow.AgeDays > org.Traits.Lifespan)
{
_deaths.Add(entities.EntityAt(i)); // умер от старости
continue;
@@ -125,7 +126,8 @@ public sealed class PlantLifecycleSystem : BaseSystem
{
Species = grow.Species,
Mature = mature,
Genome = gene,
Genome = org.Genome,
MutationRate = org.Traits.MutationRate,
};
if (mature)
{
@@ -134,7 +136,8 @@ public sealed class PlantLifecycleSystem : BaseSystem
{
Cell = cell,
Species = grow.Species,
Genome = gene,
Genome = org.Genome,
Traits = org.Traits,
}
);
}
@@ -147,24 +150,29 @@ public sealed class PlantLifecycleSystem : BaseSystem
{
foreach (var cand in _candidates)
{
var interval = MathF.Max(0.5f, cand.Genome.ReproduceInterval);
var interval = MathF.Max(0.5f, cand.Traits.ReproduceInterval);
if (_rng.NextSingle() >= elapsed / interval)
{
continue; // в этот тик не сеет
}
var range = Math.Clamp((int)MathF.Round(cand.Genome.DispersalRange), 1, 6);
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.Genome.SelfPollination)
if (_rng.NextSingle() >= cand.Traits.SelfPollination)
{
continue; // нет партнёра и самоопыление не удалось
}
partner = cand.Genome; // самоопыление
// самоопыление — партнёр это сам кандидат
partner = new Partner
{
Genome = cand.Genome,
MutationRate = cand.Traits.MutationRate,
};
}
if (!TryPickTarget(cx, cy, range, out var tcell))
@@ -172,7 +180,15 @@ public sealed class PlantLifecycleSystem : BaseSystem
continue; // некуда сеять (нет суши/свободного места рядом)
}
var child = PlantGenome.Breed(cand.Genome, partner, _rng);
// Темп мутаций потомка — средний эволюционирующий признак родителей (ген управляет мутацией).
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(
@@ -192,7 +208,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
}
}
private bool TryFindPartner(int cx, int cy, int range, int species, out PlantGenome partner)
private bool TryFindPartner(int cx, int cy, int range, int species, out Partner partner)
{
for (var dy = -range; dy <= range; dy++)
{
@@ -213,7 +229,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
ref var rep = ref _rep[ny * _width + nx];
if (rep.Species == species && rep.Mature)
{
partner = rep.Genome;
partner = new Partner { Genome = rep.Genome, MutationRate = rep.MutationRate };
return true;
}
}
@@ -272,21 +288,29 @@ public sealed class PlantLifecycleSystem : BaseSystem
{
public int Species;
public bool Mature;
public PlantGenome Genome;
public Genome Genome;
public float MutationRate;
}
private struct Candidate
{
public int Cell;
public int Species;
public PlantGenome Genome;
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 PlantGenome Genome;
public Genome Genome;
public float Fertility;
}
}
+87
View File
@@ -0,0 +1,87 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>genes.json</c>):
/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую.
/// Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
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;
/// <summary>Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.</summary>
public bool IsVariant;
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static PlantPhenotype FromTraits(IReadOnlyDictionary<string, float> 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,
};
}
}
/// <summary>
/// Компонент-организм растения: его управляемый <see cref="Genome"/> (для размножения, сейва и
/// пересчёта) и выраженный фенотип <see cref="Traits"/>, по которому работают системы. Заменяет
/// прежний фиксированный <c>PlantGenome</c> — теперь геном переменного состава и признаки из формул
/// генов (фаза G3). Геном — ссылочный объект, поэтому компонент несёт ссылку, а не копию.
/// </summary>
public struct PlantOrganism : IComponent
{
/// <summary>Геном особи (аллели по генам); общий источник истины для признаков и размножения.</summary>
public Genome Genome;
/// <summary>Выраженные признаки — то, что читают системы роста/жизненного цикла.</summary>
public PlantPhenotype Traits;
}
/// <summary>
/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его
/// сезон плодоношения (признак <see cref="PlantPhenotype.FruitSeason"/>) до потолка
/// <see cref="PlantPhenotype.FruitYield"/>, вне сезона опадает. Управляется <c>PlantFruitingSystem</c>.
/// </summary>
public struct Fruiting : IComponent
{
/// <summary>Накоплено зрелых плодов (0..FruitYield).</summary>
public float RipeFruit;
}