Compare commits

..
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 92d0302138 UI: rework settings panel — card, aligned rows, themed segmented controls
The settings screen was ragged: bare gray TextButtons (off-theme), each row
center-aligned on its own so labels/controls never lined up, and a latent bug —
clicking a segment or toggle only changed the value, the highlight didn''t refresh
until Apply rebuilt the panel.

- Wrap in a Card with title + accent rule, matching the other menus.
- Every setting is a "label (fixed width) + control" row, left-aligned, so the
  controls form a clean column.
- Segments and toggles use the themed ChoiceButton with an active state
  (Ui.SetChoiceActive: accent fill/border) instead of text-color-only.
- Toggles read On/Off (new settings.on/off loc) instead of ●/○ glyphs.
- Any click now runs all refreshers immediately, so highlights track the value
  live, not just after Apply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:22:30 +03:00
Leonid PershinandClaude Opus 4.8 36b6d576a1 UI: themed dark menu — cards, accent rule, hover/press buttons, backdrop
The menus were default gray Myra buttons on the raw clear color. Give the shared
Ui helpers a cohesive dark theme so every menu screen benefits at once:
- Button: dark fill, accent border, hover (OverBackground) and press (PressedBackground)
  highlights, padded, light text — real feedback instead of flat gray boxes.
- Card: bordered, translucent-dark, padded container; AccentRule: thin accent divider
  under the title.
- MenuScreen: full-screen dark backdrop behind menu content (separate from Screen,
  which stays background-free so the in-game HUD over the world is unaffected).

Main menu, New World, Credits and Load are now framed cards with a title + accent
rule; Settings/Boot/Loading get the backdrop. WorldScene/MultiplayerScene keep the
background-free Screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:07:30 +03:00
Leonid PershinandClaude Opus 4.8 02dcd01c0f UI: redesign inspect panel — stat bars, sections, scroll, clearer tabs
Replace the single per-tab text blob with structured widgets:
- StatBar: RimWorld-style colored bars for needs, skills, capacities, blood/pain,
  mood. Good→bad gradient (red/amber/green); drives & skills use a neutral blue;
  pain fills by amount but colors inverted (more pain = red).
- Section headers (accent) group Health (vitals/capacities/conditions) and Mood;
  section keys lose the "— … —" dashes (the header styling separates them now).
- Body lives in a fixed-height ScrollViewer so long tabs (Health) scroll and the
  panel keeps a constant size instead of jumping between tabs.
- Active tab gets a background highlight, not just accent text; tab buttons padded.
- Narrative data (overview/genes/products) stays as styled lines; plant growth-to-
  next-stage shown as a bar.

New loc: inspect.health.vitals, inspect.vital.blood/pain (ru/en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:53:08 +03:00
Leonid PershinandClaude Opus 4.8 56f2c22932 UI: visible pause button + tighter, dimmer selection bracket
- speed.pause was "⏸" (U+23F8), a glyph the bitmap font lacks, so the pause button
  rendered blank/invisible (x1/x3/x6 are ASCII and showed). Use the word Pause/Пауза.
- Selection bracket was radius x2.2 of the sprite''s bounding CIRCLE (half-diagonal),
  so it sat much larger than the entity, in a bright opaque blue. Shrink to x1.4 and
  mute the tint so it hugs the target without shouting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:40:06 +03:00
Leonid PershinandClaude Opus 4.8 9f6b8a0554 Stop tracking runtime settings.json (gitignore it)
settings.json is written by the game at runtime in the working dir and was
accidentally added; untrack it and gitignore /settings.json and /Saves/ (the local
file is kept so the game still reads it). .vscode/settings.json is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:24:11 +03:00
Leonid PershinandClaude Opus 4.8 ccb7c960f9 Disable tree light occlusion (fixes strange isolated shadows on open ground)
A mature tree marked a single lightmap cell as an occluder (shaded to 35% + a
directional sun-shadow tail up to 7 cells). With mountains gone, trees were the
only occluders, so on open soil these showed up as strange dark blobs detached
from the (larger, offset) tree sprite. Gated tree occlusion behind TreesCastShade
(off) so the forest is evenly lit; the occluder infra stays for light-blocking
terrain and re-enabling once tree canopies have a real multi-cell shade footprint.
Trade-off: understory shade no longer dims growth under canopy (minor). Engine
lighting constants are untouched (would need the submodule workflow). Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:22:56 +03:00
Leonid PershinandClaude Opus 4.8 d196437462 Review pass: fix stale comments (drop mountain refs, update AnimalSet summary)
Code review of the session''s work found the simulation deterministic, save/load
complete, def/localization consistent, and no real correctness bugs (the rest of
the flagged items were defensive-paranoia false positives). Only fixes needed were
stale comments: AnimalSet still described the Phase-A1 single-sprite era (now
directional sprites + stages/sex), and two occluder comments referenced the removed
mountains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:15:05 +03:00
Leonid PershinandClaude Opus 4.8 65ecd2c7b2 Merge branch 'senses-memory' into main
Animal senses (sight/hearing/smell/touch) and memory: smell/touch capacities +
organs (reduced by hediffs/damage), acuity + detectability genes, multi-sense
threat/prey detection with a coarse smell zone, and a brain-bounded memory that
records threats and steers wandering animals away from danger zones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:08:52 +03:00
Leonid PershinandClaude Opus 4.8 f60c55399b Senses stage 3: bounded animal memory (remember & avoid threats)
Memory component holds a small ring of entries {kind, perceived pos, confidence,
age}, capacity = brainSize x 16 (tiny brains remember nothing). On detecting a
threat the animal records it (Memory.Remember, merge-by-proximity, evict weakest);
AnimalMemorySystem forgets entries over ~2 days. While wandering, an animal steers
away from the nearest remembered threat (danger zone lingers after the predator is
out of sight). Component is general (Food/Water/Mate kinds reserved for later) and
transient (not serialized, like AI targets). Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:08:32 +03:00
Leonid PershinandClaude Opus 4.8 3031bf2954 Senses stage 2: multi-sense detection (sight/hearing/smell) for threat & prey
Predator/prey detection now uses three senses instead of one radius. Per observer,
SenseRanges = sight (vision gene x Sight capacity), hearing (x1.4, Hearing gene/cap),
smell (x1.8, Smell gene/cap). Detect(): sight beats the target''s camouflage and
hearing scales with its noise (both give exact position); smell reaches furthest but
only a COARSE zone -- the position snaps to a smell cell, so a predator tracking by
scent heads roughly toward prey until sight takes over. Damaged eyes/ears/nose or
hediff capMods shrink the matching sense; detectability genes (camouflage/scent/noise)
ride in the animal grid entry. Deterministic (snap, not noise). Mate/forage stay on
sight for now. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:02:59 +03:00
Leonid PershinandClaude Opus 4.8 70e79a5c30 Senses stage 1: smell/touch capacities, organs, acuity + detectability genes
Foundation for the perception system. Adds Smell and Touch capacities (CapacityCalc
dependency chain x consciousness) with organs on the Quadruped body (nose->Smell,
skin->Touch; eyes/ears already give Sight/Hearing) so hediffs and organ damage
reduce them for free. New genes: acuity GeneHearing/GeneSmell/GeneTouch (radius
multipliers, default 1 if a species omits them) and detectability GeneCamouflage
(lower sight), GeneScent (raise smell), GeneNoise (raise hearing) on AnimalPhenotype.
Deer/wolf/boar/chicken get sensible values (wolf smells well, deer is alert +
dappled). cap.smell/cap.touch localized. Build + --check-content clean
(48 genes, 47 traits). Multi-sense detection + memory come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:55:56 +03:00
Leonid PershinandClaude Opus 4.8 dc617521c2 Merge branch 'animal-bob' into main
Walking sway for animals (AnimalBobSystem) to make movement feel livelier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:29:15 +03:00
Leonid PershinandClaude Opus 4.8 0c07eba021 Liven up animal movement with a walking sway (presentation only)
AnimalBobSystem tilts a moving animal side-to-side via Transform2D.Rotation (a
gentle waddle) and eases it upright when standing. Phase runs on game time (frozen
on pause, faster when sped up) and is offset per entity id so animals sway out of
sync. Purely presentational -- the simulation ignores rotation, and the facing
system writes scale (not rotation), so no conflict. Corpses/eggs have no brain so
they stay still. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:29:14 +03:00
Leonid PershinandClaude Opus 4.8 405ed192a8 Merge branch 'remove-mountains' into main
Remove mountain terrain (forest covers the high elevation band for now).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:24:19 +03:00
Leonid PershinandClaude Opus 4.8 cad4b5f38f Remove mountains from the map (forest covers the high band for now)
Dropped the Mountain terrain def and extended Forest to maxHeight 1.01, so the
former mountain elevations generate forest instead. No terrain blocks light now;
the occluder mechanism stays generic (mature trees still cast shadows) -- updated
the stale "mountain" comments. Removed the unused terrain.mountain localization.
Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:24:19 +03:00
Leonid PershinandClaude Opus 4.8 0d7ea55824 Merge branch 'animal-facing' into main
Animals turn to face their direction of travel (east/north/south sprites + west
flip), via AnimalFacingSystem and directional sprite sets in AnimalSet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:12:32 +03:00
Leonid PershinandClaude Opus 4.8 edfbb15251 Animals face their movement direction (4-way: east/west-flip/north/south)
Animals had a single east-facing sprite and never turned. AnimalSet now loads a
directional set per look (female/male/baby): east from the def texture, north/south
by the _east->_north/_south naming convention (falls back to east if absent), with
west rendered as a horizontal flip of east. New AnimalFacingSystem orients each
animal toward its brain Target (dominant axis of travel), updating sprite region +
flip and recomputing origin/scale when the region changes; a standing animal keeps
its facing. ModAtlases gained TryGetRegion for the optional directional lookups.
Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:12:32 +03:00
Leonid PershinandClaude Opus 4.8 d9beac9331 Merge branch 'world-loading-scene' into main
Add a loading screen (WorldLoadingScene) between world setup/load and WorldScene so
the blocking world generation shows a "Generating world" indicator instead of a
blank fade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:02:19 +03:00
Leonid PershinandClaude Opus 4.8 6ec70cab91 Loading screen between world setup and the game scene
World generation (WorldScene.OnLoad: terrain, atlases, spawn) runs synchronously
and blocks the frame; previously only a blank fade covered it, with no feedback.
New WorldLoadingScene shows a "Generating world" label, lets it draw, then switches
to WorldScene WITHOUT a transition so its last drawn frame (the label) stays on
screen during the blocking generation. Both New World and Load Game route through
it. Localized loading.world ru/en. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:02:19 +03:00
Leonid PershinandClaude Opus 4.8 282ec018f7 Merge branch 'animal-grid' into main
Spatial grid for animal neighbour queries: threat/prey/mate/herd scans now use a
per-frame SpatialGrid<NeighborEntry> instead of iterating every animal per decision,
removing the O(animals^2) cost. Deterministic (id tie-breaks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:55:41 +03:00
Leonid PershinandClaude Opus 4.8 b61c6acff3 Perf: spatial grid for animal neighbour queries (drops O(animals^2) AI scans)
Generalized the plant grid into SpatialGrid<T> (PlantGrid.cs -> SpatialGrid.cs)
and added an animal grid. AnimalDecisionSystem rebuilds both once per frame; the
animal grid entry precomputes the fields the AI needs (species, body size, eats-meat,
sex, adult) so queries do not refetch components. Threat / prey / mate / herd scans
now gather only neighbours from cells overlapping the vision radius instead of
iterating every animal per decision. IsThreatTo/IsPreyFor take precomputed values;
dead MateNeedIndex removed. Mate stays species-agnostic (unchanged behaviour).
Added id tie-breaks to threat/mate selection so results are order-invariant ->
fully deterministic regardless of grid iteration order. Build + check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:55:27 +03:00
Leonid PershinandClaude Opus 4.8 347fac2c1b Merge branch 'perf-hotpaths' into main
Hot-path performance: removed per-frame allocations and string genome lookups
(Kleiber via precomputed RefBodySize, single Skills fetch, senescence scale inside
CapacityCalc, alloc-free SkillSeed), and a plant spatial grid that replaces the
dominant per-decision scan of all plants with a local cell query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:46:00 +03:00
Leonid PershinandClaude Opus 4.8 15cad39240 Perf: spatial grid for plant foraging (drops the dominant O(plants) scan)
Plants vastly outnumber animals, and every herbivore forage decision scanned ALL
plants — the dominant per-decision cost. New PlantGrid (uniform cell grid, cell
lists pooled so rebuild is alloc-free) is rebuilt once per frame from plant
positions; TryFindPlant/TryFindBestPlant now gather only candidates from cells
overlapping the vision radius instead of iterating every plant. The entry carries
position/id/toxicity/palatability so no component refetch is needed. Selection is
unchanged (nearest / max-attractiveness, id tie-break), and since the result is
order-invariant the behaviour is identical and deterministic. Build + check clean.

Note: the animal-side neighbour scans (threat/prey/mate/herd) are still O(animals)
per decision — animals are far fewer than plants, and an animal grid is a riskier
change best done with a GUI run; deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:44:35 +03:00
Leonid PershinandClaude Opus 4.8 2b9d1fd105 Perf: kill per-frame allocations and string lookups in hot sim paths
- AnimalNeedsSystem (per-frame, every animal): drop the string-keyed genome lookup
  and Mass.OfAnimal call; Kleiber factor is now (size/refBody)^2.25 using a
  precomputed AnimalSet.Species.RefBodySize. No dict probe per animal per frame.
- AnimalActionSystem: fetch the Skills component ONCE per acting animal instead of
  twice per action (SkillFactor + GrantXp took in Skills now).
- RecomputeCapacities: apply the senescence scale inside CapacityCalc.Compute
  instead of a second pass that allocated a List<string> of keys every recompute
  (per animal per health tick, and per injury in combat).
- AnimalFactory.SkillSeed: iterate genome.Alleles (IReadOnlyDictionary, no copy)
  instead of ToDictionary() which allocated a dict per spawn.

Behaviour-preserving (same numbers). Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:39:36 +03:00
Leonid PershinandClaude Opus 4.8 3dfa8c658f Merge branch 'skills-and-devtools' into main
Developer-mode setting gating the dev console, a practice-grown skills system
(with passion) for animals, and a RimWorld-style moddable dev spawner/damage tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:25:04 +03:00
Leonid PershinandClaude Opus 4.8 cdb8439edf Dev spawner: RimWorld-style moddable spawn/damage tool (dev mode only)
A side panel (F9, only when developer mode is on) lists entries by category:
animals and plants are auto-populated from their defs (so modded content shows up
automatically), plus action tools. Spawn entries arm a "tool in hand" — left-click
the world to spawn at the cursor (right-click clears); action tools (injure/kill/
infect) apply to the currently selected creature, so you can line up entities and
damage them to compare. Moddable via a new DevToolDef def type: mods add buttons
referencing a code handler kind (spawn-animal/plant/infect/damage/kill) + target,
without touching code. Localized dev.* ru/en. Build + --check-content clean
(13 def types).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:20:59 +03:00
Leonid PershinandClaude Opus 4.8 58380adc31 Skills system: practice-grown abilities with passion (animals now, humans later)
Generic data-driven skills (SkillDef + skills.json: Foraging, Hunting, Evasion),
mirroring the needs registry. Skills component (per-skill level [0..1] + passion)
on every animal, built by AnimalFactory; passion is rolled deterministically from
the genome (no RNG param threaded). Levels grow from PRACTICE (XP on the matching
action, scaled by passion''s learn-rate) and slowly decay toward a floor when
unused (AnimalSkillSystem), so animals specialize.

Effects wired now: Foraging -> satiety from grazing, Hunting -> attack damage,
Evasion -> flee speed. A missing skill def -> factor 1 (no effect), so it is safe
to mod the set. Inspector gains a Skills tab (level% + passion stars); `skills`
console command shows live mean levels; skills are saved/loaded (levels+passions).
Same model will carry to future humans. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:15:11 +03:00
Leonid PershinandClaude Opus 4.8 cf2bc77882 Developer-mode setting gates the dev console
GameSettings.DeveloperMode (default ON) + a toggle in the settings panel. WorldScene
only stands up the dev console (and, later, the dev spawner) when it is enabled, so
without dev mode the backtick key does nothing. GodCameraSystem''s console arg is now
nullable (no console when dev mode off). Localized settings.devmode ru/en. Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:57:21 +03:00
Leonid PershinandClaude Opus 4.8 d44e17198b Merge branch 'mass-system' into main
Weight system: cubic body mass for plants/animals (BaseMassKg), carry capacity,
Kleiber metabolism, mass-based corpse meat, and a dormant Carrier foundation for
future item hauling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:36:18 +03:00
Leonid PershinandClaude Opus 4.8 1a3f205f86 Weight system: body mass, carry capacity, Kleiber metabolism
Mass is derived by a cubic law: mass = BaseMassKg * (size / reference)^3, so a
juvenile/seedling weighs far less than an adult/mature and the size gene moves
mass too. New Mass helper + BaseMassKg on AnimalDef/PlantDef (deer 70, wolf 45,
boar 80, chicken 2.5; trees ~450, grass 0.4, etc.) and MassKg per unit on
ProductDef (foundation for hauling/inventory).

Wired into the sim (as chosen):
- Corpse meat is now proportional to body mass (Mass.MeatFraction), not raw
  bodySize; Corpse gained a BodySize field so sprite scale stays independent of
  meat (save/load updated).
- Kleiber metabolism: need decay x= (mass/refMass)^0.75, normalized per species,
  so juveniles are cheaper to feed and heavier individuals eat a bit more,
  without cross-species balance blowups (adults of typical size are unchanged).
- Carry capacity = mass x fraction x carry capability (Moving for quadrupeds;
  Manipulation for future humans), shown in inspector/console. A dormant Carrier
  component + Mass.LoadSpeedFactor hook slow an overloaded animal — the
  foundation for item hauling.

Inspector shows body/plant weight (+carry); `animal`/`plant` console print mass.
Build + --check-content clean (42 genes, 41 traits). Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:30:36 +03:00
Leonid PershinandClaude Opus 4.8 0e2cf55a54 Merge branch 'ecology-thermal-social' into main
Thermoregulation (revives GeneInsulation) + wound infection, seed dispersal by
herbivores (endozoochory), and herding/flocking via GeneSociability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:29:11 +03:00
Leonid PershinandClaude Opus 4.8 c0822de650 Herding / flocking via GeneSociability (boids)
Social species (deer 0.8, wolf 0.6, chicken 0.55, boar 0.3) now school together:
AnimalDecisionSystem scans nearby same-species neighbours (HerdScan) and, when
wandering, a lone social animal steers toward the herd centroid (cohesion) while
close members just mill about (separation). "Safety in numbers" shrinks the
threat-detection radius in a group, so herded prey are calmer and scatter less.
Asocial animals (sociability < 0.3) keep the old random wander. GeneSociability
+ trait; `animal` console prints insulation/sociability. Build + --check-content
clean (42 genes, 41 traits). Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:02:28 +03:00
Leonid PershinandClaude Opus 4.8 5ac34f4dc7 Seed dispersal by herbivores (endozoochory)
Eating a mature plant has a chance to lodge a seed in the herbivore's gut
(GutSeed component, cloned plant genome). SeedDispersalSystem ticks the timer and
plants a seedling of that species wherever the animal has wandered to (if the
cell is land), then drops the component. Herbivores become agents of plant
spread — flora follows grazing routes — closing a plant<->animal loop on top of
the existing Fruiting/PlantFactory machinery. GutSeed is transient (not
serialized), like AI targets. Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:58:28 +03:00
Leonid PershinandClaude Opus 4.8 562e365be1 Animal thermoregulation (insulation) + wound infection
Two health threads finished, both via the existing hediff engine in
AnimalHealthSystem (now also takes Climate + the new hediff defs):

- Thermoregulation revives the dead GeneInsulation. A comfort band is derived
  per animal: fur (insulation) widens cold tolerance but worsens heat tolerance,
  larger body resists cold. Outside it, Hypothermia/Heatstroke severity builds
  (Poisoned-pattern: Intensify zeroes immunity so it can't recover while still
  exposed), capacities drop, severity 1 = death; back in comfort it heals.
  Seasonal/climate mortality and a real cold↔heat adaptation tradeoff.
- Wound infection marries the injury and disease systems: while bleeding, a
  chance (∝ wound severity) to contract Infection, a progressing disease fought
  by immunity × blood filtration — weak kidneys/liver or many wounds = worse.

New hediffs Hypothermia/Heatstroke/Infection (+ru/en). Build + --check-content
clean. Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:53:27 +03:00
Leonid PershinandClaude Opus 4.8 5b0cf1dd4a Merge branch 'coevolution-health' into main
Plant–herbivore coevolution (toxin/thorns ↔ tolerance, smart avoidance, costs),
RimWorld-style health capacities wired to behaviour, senescence, and birth-type
genes (live-bearing vs egg-laying with eggs) + hybrid-creation foundation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:44:17 +03:00
Leonid PershinandClaude Opus 4.8 b2e3eb490e Birth type gene (live-bearing vs egg-laying) + hybrid-creation foundation
GeneEggLaying (species-fixed) selects the conception cycle: viviparous animals
carry to a live birth (existing path); oviparous animals, after the same gravid
period, LAY eggs instead. New Egg entity (carries the already-bred offspring
genome) + EggSystem incubates and hatches it into a baby. AnimalPregnancySystem
branches on the mother's birth type. Adds an oviparous showcase species, Chicken.
Eggs are serialized (WorldSave.Eggs) like corpses, so clutches survive save/load.

Also lays the foundation for creating any species or hybrid (full command/UI
deferred): AnimalSet.HybridGenome(base, other) produces a cross anchored to a
base species' body/gene-set with shared genes blended Mendelian-style — so
Create(species, GenerateGenome) makes any species and Create(species,
HybridGenome) makes any hybrid.

EggTexture on AnimalDef (defaults to the seed dot placeholder); reproduction line
added to the `animal` console command. Build + --check-content clean (41 genes,
40 traits). Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:24:04 +03:00
Leonid PershinandClaude Opus 4.8 e6b1367a3a Senescence: aging degrades capacities and fertility
GeneLifespan already set the moment of death from old age; now aging actually
manifests before it. RecomputeCapacities takes a capacityScale; AnimalHealthSystem
applies SenescenceFactor(age, lifespan) — capacities fade from 60% of lifespan to
~0.55× near the end, so old animals move/see/filter blood worse and are frailer.
Fertility falls with the same factor (smaller litters), and past 90% of lifespan
females no longer conceive (post-reproductive). Makes GeneLifespan and the Senior
stage meaningful. Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:13:26 +03:00
Leonid PershinandClaude Opus 4.8 249b603497 Wire Eating + Digestion capacities to feeding (option 1)
Eating (jaw) scales graze bite and Digestion×Eating scales satiety gained from
food (plant grazing and corpse scavenging via Refill efficiency). A damaged jaw
or sick gut means less nutrition per feeding, so the animal must eat more often.
Completes wiring the new health capacities to behaviour; Talking remains an
indicator (no social system yet). Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:58:10 +03:00
Leonid PershinandClaude Opus 4.8 e105938a22 Wire health capacities to behaviour: filtration→immunity, sight/hearing→perception
The expanded capacities were mostly display-only; make two of them affect the
simulation (Moving→speed and Consciousness→intelligence gating already existed):

- Blood filtration → toxin/disease clearance: AdvanceHediffs takes an immunity
  scale (default 1); AnimalHealthSystem passes the BloodFiltration capacity, so
  damaged kidneys/liver clear Poisoned and diseases slower (RimWorld model).
  Ties the new capacities to the coevolution work — weak filtration makes toxic
  plants deadlier.
- Sight/hearing → perception radius: foraging, hunting and mate search scale by
  Sight; threat detection (flee) scales by max(Sight, Hearing) — you see or hear
  a predator. Injured eyes/ears shrink awareness.

Eating/Talking/Digestion remain indicators for now. Build + --check-content
clean. Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:18:35 +03:00
Leonid PershinandClaude Opus 4.8 8807045303 Plant–herbivore coevolution + RimWorld-style health capacities
Adds the first plant↔animal coupling beyond "herbivore eats plant": a toxin/
thorns ↔ tolerance arms race built on the existing gene + hediff engines, plus
the fuller RimWorld capacity set on the health scaffold.

Coevolution C1 — plant defense → poisons/injures the eater:
- Plant genes GeneToxicity/GeneThorns/GenePalatability (GenomeDef + PlantSet
  template + PlantPhenotype); animal gene GeneToxinTolerance.
- Hediff Poisoned (non-progressing; grows from eating via HealthState.Intensify,
  immunity heals it, overdose is lethal).
- AnimalActionSystem.ApplyPlantDefense on each bite: poison dose =
  toxicity × (1 − tolerance); thorns → ApplyInjury (reuses predator-cluster).
- Cost of defense: PlantGrowthSystem growth ×= DefenseGrowthFactor()
  (1 − 0.4·toxicity − 0.3·thorns) — without a cost defense would max out and
  coevolution would stall.
- Showcase: cactus thorns, poisonous mushroom; grass starts clean so toxicity
  can evolve on the staple. Deer/boar get small starting tolerance.

Coevolution C2 — discrimination + the other half of the arms race:
- Smart foraging (reuses A7 intelligence gating): herbivores with effective
  intelligence ≥ tier-4 weigh plants by palatability − perceived-toxin −
  distance; reflex grazers (or sick animals with dropped consciousness) eat the
  nearest plant and risk poison.
- Cost of tolerance: detox raises metabolism (need decay ×= 1 + 0.5·tolerance),
  closing the arms race so tolerance doesn't fix at 1.
- popstats reports mean toxinTolerance and a plants line (mean toxicity/thorns/
  palatability) to watch the drift.

Health capacities (RimWorld parity, user-requested):
- New capacities BloodFiltration/Hearing/Talking/Eating; CapacityCalc rewritten
  to emit only capacities the body declares (a destroyed organ still shows 0%)
  with the full dependency chain (blood → pumping/breathing/filtration →
  consciousness → moving/sight/hearing/talking/eating, digestion).
- Quadruped body gains kidneys + liver (filtration), ears (hearing), jaw/tongue
  (eating/talking). Inspector health tab lists all of a body's capacities.

Localization (ru/en) for new genes' defense line, Poisoned, and the new
capacity labels. Build + --check-content clean (11 def types, 40 genes,
39 traits). Not GUI-verified (headless can't render the world scene).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:13:51 +03:00
Leonid Pershin 97d83606ce Implement diet genes for animals, introducing GeneHerbivory, GeneCarnivory, and GeneOmnivory to define dietary behaviors. Add new animal types: Wolf (carnivore) and WildBoar (omnivore), with associated attributes and hunting mechanics. Enhance animal behavior systems to include hunting and fleeing dynamics based on genetic traits. Update documentation to reflect these changes and ensure localization for new terms. Clean build with no content errors. 2026-06-14 13:14:36 +03:00
Leonid PershinandClaude Opus 4.8 aae76e9083 Merge branch 'animals' into main: animal/zoology system (A1-A8 + serialization)
Brings the full animal foundation onto main and folds its new defs into the
Defs/ subfolder layout introduced on main:
- animal genes -> Defs/Genes/Animal.json (split out of genes.json)
- animals/bodies/hediffs/needs/thoughts -> own Defs/<Type>/ subfolders
- ProductMeat/Bone/Leather + bigger world presets auto-merged via rename detection
- PawnDef un-sealed (AnimalDef : PawnDef); def-location doc comments updated to subfolders
- engine pointer kept at main's newer 96e19c7 (only adds directional sun shadows)

Build + --check-content clean: 11 def types, 33 genes, 35 plants, 32 traits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:48:49 +03:00
Leonid Pershin c47f99bc29 Update engine subproject commit to 96e19c7. Remove obsolete gene, patch, pawn, plant, product, terrain, and world preset definitions from the Core mod. Enhance documentation to reflect new directory structure for Defs, improving clarity on file organization and localization paths. 2026-06-14 12:42:09 +03:00
Leonid Pershin 77c6e84683 Обновление данных: добавлены новые продукты (мясо, кости, кожа) в products.json и изменены размеры и население в worldpresets.json. Также обновлено описание метода регистрации слоев в GameLayers.cs. Обновлен статус подпроекта в engine. 2026-06-14 07:01:38 +03:00
Leonid PershinandClaude Opus 4.8 4b53eb8984 Животные: сериализация — сейв/лоад популяции и трупов
Закрывает сквозное требование плана (сейв/лоад данных каждой фазы). WorldSave получил Animals (AnimalSave: геном+пол, поколение, стадия/возраст, нужды, здоровье — кровь/HP частей/хедифы, настроение+мысли, беременность+геном отца) и Corpses (CorpseSave). SaveAnimals снимает популяцию (Health/Mood/Pregnant через entity); RestoreAnimals/RestoreAnimalState собирают особь фабрикой из генома + накладывают состояние (ref-правки до AddComponent(Pregnant); труп пере-тинтится через Stage=-1). Corpse получил Species; CreateCorpse принимает индекс вида + HealthState.AddHediff(def,severity,immunity). На загрузке стадо не пересыпается при наличии животных в сейве; старый/пустой сейв регенерируется из сида. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:59:26 +03:00
Leonid PershinandClaude Opus 4.8 003f235440 Животные A8 часть 2: инспектор животных (вкладки нужды/здоровье/настроение)
InspectPanel осматривает и животных: вкладки Обзор (стадия/возраст/пол/поколение/беременность), Нужды, Здоровье (кровь/боль, способности, хедифы), Настроение (значение + мысли). Набор вкладок по типу выбранного. SelectionSystem пикает и животных (запрос расширен PlantGrowth → +AnimalOrganism). Локализация inspect.*/need.*/hediff.*/cap.*/animalstage.* ru+en. Базовый план зоологии A1-A8 закрыт. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:19:23 +03:00
Leonid PershinandClaude Opus 4.8 1b12831817 Животные A8 часть 1: настроение — ядро (ThoughtDef + Mood + MoodSystem)
Тип дефа ThoughtDef + thoughts.json (роды/спаривание/жажда — сила и длительность данными). Компонент Mood (значение 0..1, активность по тиру мозга, список затухающих мыслей) на всех животных; AnimalMoodSystem (тир 4+: настроение из непрерывного фона — средняя удовлетворённость убывающих нужд минус боль — плюс сумма мыслей). Реестр ThoughtSet + id-константы AnimalThoughts; триггеры: роды (PregnancySystem), спаривание и утоление жажды (ActionSystem). Низкое настроение подавляет спаривание (DecisionSystem). Команда mood [species]. Локализация thought.* ru/en. Сборка + --check-content чистые (11 типов дефов). Осталось A8.2 — вкладки инспектора.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:13:46 +03:00
Leonid PershinandClaude Opus 4.8 26bf83db64 Животные A7: мозг → интеллект → гейт нужд/действий
Эффективный интеллект = brainSize (ген) × Consciousness (capacity из здоровья), AnimalFactory.Intelligence. AnimalContext гейтит выбор ИИ: нужда с minBrain выше интеллекта особи в выбор не входит (AnimalDecisionSystem читает Health поэлементно). Пороги в needs.json — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг ~0.45). Обратная связь от повреждения мозга: болезнь/боль/кровопотеря роняют сознание → интеллект падает → первыми отключаются сон/спаривание. Команда intel <species> [consciousness]. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:00:11 +03:00
Leonid PershinandClaude Opus 4.8 9f8ff93591 Животные A6: болезни и иммунитет
HediffDef расширен (initialSeverity/severityPerDay/immunityPerDay/lethalSeverity/pain/ambientPerDay/capMods); хедифы стали инстансами (Hediff: тяжесть+иммунитет). AnimalHealthSystem (медленный тик ~час): фоновое заражение, гонка тяжесть<->иммунитет (выздоровление при иммунитете 1 / смерть при летальной тяжести), пересчёт способностей с болью и capMods; смерть по здоровью оставляет труп. Первый реальный источник урона: Moving подключён к скорости (больные медленнее). Болезнь Fever; команда infect для теста.

Сборка чистая, --check-content (10 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:48:05 +03:00
Leonid PershinandClaude Opus 4.8 9db12273c9 Животные A5 (часть 2): части тела, кровь, capacities
BodyDef/BodyPartDef + bodies.json (Quadruped — данные: иерархия органов, вклады в способности). AnimalDef.Body. HealthState расширен Parts/BloodLevel/Pain/Capacities; CapacityCalc обобщённо считает способности по зависимостям (кровь->сердце/лёгкие->сознание->движение/зрение), модовые проходят сырыми. Фабрика строит части (HP x размер тела) и начальные способности. Источников урона нет -> всё на полном HP, способности=1 (каркас под болезни/хищников). Команда body <species>.

Сборка чистая, --check-content (10 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:36:34 +03:00
Leonid PershinandClaude Opus 4.8 c90bf32460 Животные A5 (часть 1): смерть → труп → разложение
Компонент Corpse + CorpseSystem: после смерти животное оставляет труп (AnimalFactory.CreateCorpse, мясо ∝ размер тела) вместо деспавна; труп разлагается Fresh→Rotting→Skeleton→исчезает со скоростью от температуры, тинтуясь по стадии. AnimalDef.CorpseTexture + спрайт трупа в AnimalSet; смертность спавнит труп. Продукты мясо/кости/шкура заданы (разделка/падальщики — позже).

Сборка чистая, --check-content (9 типов дефов). Части тела/кровь/capacities — часть 2 A5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:22:11 +03:00
Leonid PershinandClaude Opus 4.8 cbc74c0794 Рефактор: зрение/скорость/выедание животного — из дефа вида
Завершает вынос баланса в данные: AnimalDecisionSystem.VisionRadius берёт дальность из AnimalDef.VisionCells (× ген), TryFindPlant/TryFindMate принимают готовый радиус; AnimalActionSystem берёт BaseSpeed и ForageBiteDays из дефа вида (через AnimalSet). Убраны хардкод-константы скорости/зрения/выедания.

Сборка чистая, --check-content (9 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:16:44 +03:00
Leonid PershinandClaude Opus 4.8 5d0cbd2115 Рефактор: стадии роста и спавн — данными
Стадии стали данными: AnimalStageDef + AnimalDef.Stages (возраст входа = EnterAt x якорь maturity/lifespan, масштаб, набор текстур baby/adult, флаг adult для размножения); пусто -> встроенный дефолт Baby/Juvenile/Adult/Senior. AnimalFactory.StageAt/RegionFor/Scale/IsAdult работают по данным стадий; системы роста/решения/гона используют флаг adult вместо индекса. Спавн стал данными: AnimalDef.SpawnPer1000Cells — WorldScene рассыпает ВСЕ виды по плотности, не только захардкоженного оленя. Устраняет блокеры расширяемости #5 и #7.

Сборка чистая, --check-content (9 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:57:44 +03:00
Leonid PershinandClaude Opus 4.8 f12b0f170c Рефактор: нужды и диета — данными, а не кодом
Нужды стали данными: NeedDef + needs.json (Hunger/Thirst/Rest/Mating), NeedSet (реестр: индексы, тип deplete/drive, поведение-исполнитель по id), динамический AnimalNeeds.Values (managed-массив — новая нужда не меняет структуру). AnimalNeedsSystem универсально считает убывание/рост по NeedDef; UtilityAi строится из NeedSet; действия исполняются по строковому id (реестр поведений) с восполнением нужды через NeedSet. Диета — AnimalDef.Diet (поиск корма гейтится). Добавить нужду/рацион виду = JSON без кода. Устраняет блокеры расширяемости #2 и #4.

Сборка чистая, --check-content (9 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:46:47 +03:00
Leonid PershinandClaude Opus 4.8 d4a370e02d Рефактор: геном животного — данными, а не кодом
AnimalGenomeDef (фикс. поля) убран; AnimalDef.Genome теперь словарь geneId->base. AnimalSet.BuildTemplate строит шаблон generically по реестру генов (разброс из самого GeneDef, дискретные поддержаны). Модер добавляет ген особи одной строкой JSON в animals.json — без правки кода. Устраняет блокер расширяемости #1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:39:16 +03:00
Leonid PershinandClaude Opus 4.8 97521f81b8 Животные A4: размножение (гон-хедиф, беременность, роды)
Нужда Mating + лёгкий hediff-каркас (HediffDef, hediffs.json с Rut, Health/HealthState). Гены GeneBreedingSeason/GeneGestationDays/GeneLitterSize. AnimalRutSystem навешивает сезонный гон-хедиф взрослым и поднимает влечение (вне сезона зачатия нет). Действие Mate ищет партнёра противоположного пола и сближается; при контакте самка получает Pregnant (геном отца). AnimalPregnancySystem тикает срок и рожает помёт через Genome.Breed (пол наследуется без YY, поколение+1), с потолком численности.

Сборка чистая, --check-content (8 типов дефов, 33 гена, 32 признака).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:32:18 +03:00
Leonid PershinandClaude Opus 4.8 355ced4937 Животные A3: жизненный цикл, стадии и пол
Компонент AnimalGrowth (стадия + возраст). Ген пола GeneSex (локус XX/XY; генерация основателя задаёт пол явно через AnimalSet.GenerateGenome, исключая невозможный YY) и GeneMaturityAge. Стадии Baby->Juvenile->Adult->Senior с порогами из генов. AnimalGrowthSystem меняет спрайт (детёныш/самец/самка) и размер по стадии; AnimalMortalitySystem — смерть от старости и истощения (деспавн), замыкает контур ёмкости среды. Стадо спавнится разновозрастным.

Сборка чистая, --check-content (30 генов, 29 признаков).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:20:44 +03:00
Leonid PershinandClaude Opus 4.8 e35e620655 Животные A2: нужды и ИИ (выбор + исполнение)
AnimalNeeds (голод/жажда/отдых) падают по гену метаболизма. Два слоя ИИ: AnimalDecisionSystem (движковый UtilityAi выбирает действие и ищет ближайшую еду/воду) и AnimalActionSystem (движение к цели со скоростью по гену + утоление: выедание растений с гибелью выеденной травы, питьё у кромки воды, сон). AnimalAppearanceSystem тускнеет с острой нуждой. Кромка воды считается из террейна; команда popstats для наблюдаемости дрейфа генов.

Сборка чистая. Контур ёмкости среды частичный — смерть от голода придёт в A3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:13:50 +03:00
Leonid PershinandClaude Opus 4.8 5e61491960 Животные A1: фундамент — AnimalDef, гены зверя, спавн оленя
Новый тип дефа Animal (AnimalDef : PawnDef) + animals.json (Deer). 8 организм-агностичных животных генов в genes.json. AnimalPhenotype/AnimalOrganism, AnimalSet (шаблон генома вида) и AnimalFactory — зеркала растительных PlantSet/PlantFactory. Детерминированный спавн стада оленей по суше в WorldScene с рендером и тинтом меха по гену; консольная команда 'animal <species>'.

Проверено: сборка чистая, --check-content (7 типов дефов, 28 генов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:03:51 +03:00
Leonid PershinandClaude Opus 4.8 516e263c77 docs: дизайн системы животных (зоология)
Полный план зоологии на генетическом фундаменте: 7 столпов (гены, жизненный цикл, размножение, здоровье по модели RimWorld, мозг/интеллект, нужды, настроение), моддинг данными, фазы A1-A8 и экологический горизонт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:03:39 +03:00
Leonid Pershin 9c9502414b Update plant definitions and localization. Adjusted GrassA sizeCells from 1.2 to 1.5 and added new plant types including BaseWarmTree, BaseColdTree, and various cacti. Enhanced terrain definitions with scatter probabilities for new plants. Updated product definitions to include fiber and mushrooms, and localized new plant and product names in English and Russian. Updated engine subproject commit to 08381703f79377a5bc48fc0745620f8815695b05-dirty. 2026-06-13 06:55:43 +03:00
Leonid Pershin b9bb7006be Enhance UI localization and settings functionality. Added new localization keys for inspection tabs and UI scale settings in both English and Russian. Updated GameSettings to include a UiScale property. Refactored scenes to utilize UseScaledUI for consistent UI scaling across various game scenes. Improved InspectPanel to support tabbed navigation for overview, genes, and products. Adjusted SettingsPanel to allow users to modify UI scale dynamically. 2026-06-13 06:24:53 +03:00
Leonid Pershin c60ae072e9 Update engine subproject commit to 08381703f79377a5bc48fc0745620f8815695b05 2026-06-13 05:21:30 +03:00
Leonid Pershin f46ae15dc6 Update engine subproject commit to f382fc98eadc4a489689e51e88748b616661a560-dirty; adjust plant genetics for optimal temperature and hardiness traits, enhance terrain scatter probabilities, and improve UI localization for connection status. Refactor multiplayer scene to implement exponential backoff for reconnections and streamline game initialization in web client. 2026-06-13 05:21:17 +03:00
Leonid PershinandClaude Fable 5 6b29a40b7f Bump engine: MrGameEng.Net robustness
Points the engine submodule at f382fc9 (pushed): heartbeat liveness +
idle-timeout drop, replication protocol-version byte, reassembled-message
size cap, and ReplicationClient.Clear() for reconnect. Enables the desktop
and web clients to auto-reconnect and the server to shed dead connections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:45:13 +03:00
Leonid Pershin 6f38d949ab Update engine subproject commit to d360093be1a9352fd2f531dde620a400c83a5d71 2026-06-13 04:34:39 +03:00
Leonid Pershin cc1841c2e2 Enhance plant genetics system with cold and heat hardiness traits. Updated genes.json to include GeneColdHardiness and GeneHeatHardiness, and modified plant definitions to incorporate these traits. Adjusted temperature tolerance calculations in PlantPhenotype and PlantLifecycleSystem to account for temperature stress. Improved documentation and added temperature stress management in the lifecycle system. 2026-06-13 04:34:03 +03:00
Leonid Pershin 4fda693994 Update engine subproject commit to d044cafad93177e0fbffb79872bde12afcf03f9c-dirty; remove outdated documentation files: растения.md and roadmap.md. 2026-06-13 04:27:35 +03:00
mrleo1nid d712afa518 Merge pull request 'Multiplayer: headless dedicated server, MrGameEng.Net, desktop and browser clients' (#1) from claude/fervent-shannon-8d884e into main
Reviewed-on: #1
2026-06-13 01:25:07 +00:00
85 changed files with 9833 additions and 608 deletions
+4 -1
View File
@@ -417,4 +417,7 @@ FodyWeavers.xsd
# LittleSim
Cache/
# Рантайм-файлы (пишутся игрой в рабочем каталоге) — не версионируем
/settings.json
/Saves/
+168
View File
@@ -0,0 +1,168 @@
{
"type": "Animal",
// Виды-животные: подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов (см. Defs/Genes/).
// Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов. Диета задаётся ГЕНАМИ
// (herbivory/carnivory/omnivory): олень — травоядный, волк — хищник, кабан — всеядный. Хищники несут
// боевые параметры (attack*) и охотятся на добычу не крупнее себя; жертвы убегают (см. AnimalSystems).
"defs": [
{
"defName": "Deer",
"label": "pawn.deer",
"kind": "animal",
"texture": "things/pawn/animal/deer/DeerFemale_east",
"maleTexture": "things/pawn/animal/deer/DeerMale_east",
"babyTexture": "things/pawn/animal/deer/DeerBaby_east",
"corpseTexture": "things/pawn/animal/deer/Dessicated_DeerFemale_east",
"body": "Quadruped",
"diet": ["plant"],
"spawnPer1000Cells": 2.5,
"baseMassKg": 70,
"genome": {
"GeneMaxBodySize": 1.5,
"GeneMetabolism": 1.0,
"GeneMoveSpeed": 1.2,
"GeneBloodVolume": 1.4,
"GeneVision": 1.3,
"GeneHearing": 1.4,
"GeneSmell": 1.2,
"GeneTouch": 1.0,
"GeneCamouflage": 0.35,
"GeneScent": 0.5,
"GeneNoise": 0.4,
"GeneBrainSize": 0.45,
"GeneInsulation": 0.55,
"GeneFurColor": 0.45,
"GeneMaturityAge": 90,
"GeneLifespan": 360,
"GeneBreedingSeason": 2,
"GeneGestationDays": 30,
"GeneLitterSize": 1,
"GeneHerbivory": 1.0,
"GeneToxinTolerance": 0.1,
"GeneSociability": 0.8
}
},
{
"defName": "Wolf",
"label": "pawn.wolf",
"kind": "animal",
"texture": "things/pawn/animal/wolf_timber/Wolf_Timber_east",
"maleTexture": "things/pawn/animal/wolf_timber/Wolf_Timber3_east",
"body": "Quadruped",
"diet": ["meat"],
"spawnPer1000Cells": 0.8,
"baseSpeed": 32,
"visionCells": 18,
"attackRangeCells": 1.3,
"attackDamage": 80,
"attackBleed": 3.0,
"attackBloodLoss": 5.0,
"baseMassKg": 45,
"genome": {
"GeneMaxBodySize": 1.6,
"GeneMetabolism": 1.1,
"GeneMoveSpeed": 1.4,
"GeneBloodVolume": 1.3,
"GeneVision": 1.5,
"GeneHearing": 1.5,
"GeneSmell": 1.8,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.6,
"GeneNoise": 0.5,
"GeneBrainSize": 0.5,
"GeneInsulation": 0.6,
"GeneFurColor": 0.7,
"GeneMaturityAge": 70,
"GeneLifespan": 240,
"GeneBreedingSeason": 3,
"GeneGestationDays": 35,
"GeneLitterSize": 4,
"GeneCarnivory": 1.0,
"GeneSociability": 0.6
}
},
{
"defName": "WildBoar",
"label": "pawn.boar",
"kind": "animal",
"texture": "things/pawn/animal/wildboar/WildBoar_east",
"body": "Quadruped",
"diet": ["plant", "meat"],
"spawnPer1000Cells": 1.0,
"baseSpeed": 26,
"visionCells": 13,
"attackRangeCells": 1.2,
"attackDamage": 45,
"attackBleed": 1.8,
"attackBloodLoss": 3.0,
"baseMassKg": 80,
"genome": {
"GeneMaxBodySize": 1.3,
"GeneMetabolism": 1.2,
"GeneMoveSpeed": 1.0,
"GeneBloodVolume": 1.2,
"GeneVision": 1.0,
"GeneHearing": 1.2,
"GeneSmell": 1.6,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.7,
"GeneNoise": 0.7,
"GeneBrainSize": 0.42,
"GeneInsulation": 0.5,
"GeneFurColor": 0.85,
"GeneMaturityAge": 80,
"GeneLifespan": 280,
"GeneBreedingSeason": 0,
"GeneGestationDays": 28,
"GeneLitterSize": 5,
"GeneHerbivory": 0.4,
"GeneCarnivory": 0.2,
"GeneOmnivory": 0.85,
"GeneToxinTolerance": 0.25,
"GeneSociability": 0.3
}
},
{
"defName": "Chicken",
"label": "pawn.chicken",
"kind": "animal",
"texture": "things/pawn/animal/chicken/Chicken_east",
"body": "Quadruped",
"diet": ["plant"],
"spawnPer1000Cells": 1.5,
"baseSpeed": 22,
"visionCells": 10,
"baseMassKg": 2.5,
"genome": {
"GeneMaxBodySize": 0.5,
"GeneMetabolism": 1.3,
"GeneMoveSpeed": 0.9,
"GeneBloodVolume": 0.7,
"GeneVision": 1.1,
"GeneHearing": 1.0,
"GeneSmell": 0.6,
"GeneTouch": 1.0,
"GeneCamouflage": 0.2,
"GeneScent": 0.4,
"GeneNoise": 0.6,
"GeneBrainSize": 0.3,
"GeneInsulation": 0.45,
"GeneFurColor": 0.6,
"GeneMaturityAge": 50,
"GeneLifespan": 180,
"GeneBreedingSeason": 1,
"GeneGestationDays": 12,
"GeneLitterSize": 6,
"GeneHerbivory": 0.8,
"GeneToxinTolerance": 0.15,
"GeneEggLaying": 1.0,
"GeneSociability": 0.55
}
}
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"type": "Body",
// Анатомия видов (фаза A5): дерево частей/органов. Вклады в способности (capacities) по телу
// суммируются ≈1 у здоровой особи. maxHp умножается на размер тела особи при создании.
// Источников урона пока нет — части на полном HP, способности = 1; каркас под болезни/хищников.
"defs": [
{
"defName": "Quadruped",
"parts": [
{ "name": "torso", "coverage": 0.3, "maxHp": 40, "vital": true },
{ "name": "heart", "parent": "torso", "coverage": 0.02, "maxHp": 12, "vital": true,
"capacities": { "BloodPumping": 1.0 } },
{ "name": "lungLeft", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Breathing": 0.5 } },
{ "name": "lungRight", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Breathing": 0.5 } },
{ "name": "liver", "parent": "torso", "coverage": 0.03, "maxHp": 14, "vital": true,
"capacities": { "BloodFiltration": 0.4 } },
{ "name": "kidneyLeft", "parent": "torso", "coverage": 0.02, "maxHp": 10,
"capacities": { "BloodFiltration": 0.3 } },
{ "name": "kidneyRight", "parent": "torso", "coverage": 0.02, "maxHp": 10,
"capacities": { "BloodFiltration": 0.3 } },
{ "name": "stomach", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Digestion": 1.0 } },
{ "name": "head", "coverage": 0.08, "maxHp": 25 },
{ "name": "brain", "parent": "head", "coverage": 0.02, "maxHp": 12, "vital": true,
"capacities": { "Consciousness": 1.0 } },
{ "name": "eyeLeft", "parent": "head", "coverage": 0.015, "maxHp": 8,
"capacities": { "Sight": 0.5 } },
{ "name": "eyeRight", "parent": "head", "coverage": 0.015, "maxHp": 8,
"capacities": { "Sight": 0.5 } },
{ "name": "earLeft", "parent": "head", "coverage": 0.01, "maxHp": 8,
"capacities": { "Hearing": 0.5 } },
{ "name": "earRight", "parent": "head", "coverage": 0.01, "maxHp": 8,
"capacities": { "Hearing": 0.5 } },
{ "name": "nose", "parent": "head", "coverage": 0.01, "maxHp": 8,
"capacities": { "Smell": 1.0 } },
{ "name": "skin", "coverage": 0.05, "maxHp": 20,
"capacities": { "Touch": 1.0 } },
{ "name": "jaw", "parent": "head", "coverage": 0.02, "maxHp": 10,
"capacities": { "Eating": 1.0, "Talking": 0.5 } },
{ "name": "tongue", "parent": "head", "coverage": 0.01, "maxHp": 8,
"capacities": { "Talking": 0.5 } },
{ "name": "legFrontLeft", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legFrontRight", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legBackLeft", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legBackRight", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } }
]
}
]
}
+109
View File
@@ -0,0 +1,109 @@
{
"type": "Gene",
// Гены животных: организм-агностичные гены зверя (их несёт геном животного). Тело/обмен/движение/
// чувства/мозг + пол (локус) и размножение. Признаки читают системы животных (рост, ИИ, здоровье).
"defs": [
{ "defName": "GeneMaxBodySize", "parent": "BaseNumericGene", "label": "gene.bodySize",
"default": 1.0, "min": 0.1, "max": 4.0, "tags": ["animal", "body"],
"effects": { "bodySize": "value" } },
{ "defName": "GeneMetabolism", "parent": "BaseNumericGene", "label": "gene.metabolism",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "metabolism"],
"effects": { "metabolism": "value" } },
{ "defName": "GeneMoveSpeed", "parent": "BaseNumericGene", "label": "gene.moveSpeed",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "movement"],
"effects": { "moveSpeed": "value" } },
{ "defName": "GeneBloodVolume", "parent": "BaseNumericGene", "label": "gene.bloodVolume",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "health"],
"effects": { "bloodVolume": "value" } },
{ "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "vision": "value" } },
// --- Чувства (острота = множитель радиуса восприятия) и заметность (как видят/слышат/чуют ЭТУ особь) ---
// Острота работает вместе со способностью органа (глаза/уши/нос/кожа): ген × capacity × радиус чувства.
{ "defName": "GeneHearing", "parent": "BaseNumericGene", "label": "gene.hearing",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "hearing": "value" } },
{ "defName": "GeneSmell", "parent": "BaseNumericGene", "label": "gene.smell",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "smell": "value" } },
{ "defName": "GeneTouch", "parent": "BaseNumericGene", "label": "gene.touch",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "touch": "value" } },
// Заметность цели: камуфляж снижает обнаружение зрением (1−camouflage), запах повышает обонянием,
// шум при движении повышает обнаружение слухом. См. формулу радиуса в системе восприятия.
{ "defName": "GeneCamouflage", "parent": "BaseNumericGene", "label": "gene.camouflage",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "stealth"],
"effects": { "camouflage": "value" } },
{ "defName": "GeneScent", "parent": "BaseNumericGene", "label": "gene.scent",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "stealth"],
"effects": { "scent": "value" } },
{ "defName": "GeneNoise", "parent": "BaseNumericGene", "label": "gene.noise",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "stealth"],
"effects": { "noise": "value" } },
{ "defName": "GeneBrainSize", "parent": "BaseNumericGene", "label": "gene.brainSize",
"default": 0.45, "min": 0.0, "max": 1.0, "spread": 0.03, "tags": ["animal", "brain"],
"effects": { "brainSize": "value" } },
{ "defName": "GeneInsulation", "parent": "BaseNumericGene", "label": "gene.insulation",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "temperature"],
"effects": { "insulation": "value" } },
{ "defName": "GeneFurColor", "parent": "BaseNumericGene", "label": "gene.furColor",
"default": 0.5, "min": 0.0, "max": 1.0, "spread": 0.06, "tags": ["animal", "morphology", "color"],
"effects": { "furHue": "value" } },
{ "defName": "GeneMaturityAge", "parent": "BaseNumericGene", "label": "gene.maturityAge",
"default": 90, "min": 1, "max": 400, "tags": ["animal", "lifecycle"],
"effects": { "maturityAge": "value" } },
// --- Диета как ГЕНЫ (хищничество/травоядство/всеядство) ---
// Каждый ген выражает свой признак [0..1]; рацион выводится порогами: ест растения, если
// max(herbivory, omnivory) ≥ порога; ест мясо (охота/падаль), если max(carnivory, omnivory) ≥ порога.
// Поэтому всеядность — отдельный ген, который включает ОБА источника пищи (генералист), а
// специалисты задаются высоким herbivory ЛИБО carnivory. См. AnimalFactory.Diet.
{ "defName": "GeneHerbivory", "parent": "BaseNumericGene", "label": "gene.herbivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "herbivory": "value" } },
{ "defName": "GeneCarnivory", "parent": "BaseNumericGene", "label": "gene.carnivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "carnivory": "value" } },
{ "defName": "GeneOmnivory", "parent": "BaseNumericGene", "label": "gene.omnivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "omnivory": "value" } },
// Устойчивость к растительным ядам (коэволюция): снижает дозу отравления при поедании токсичных
// растений (effectivePoison = toxicity × (1 toxinTolerance), см. AnimalActionSystem). Растёт под
// давлением ядовитого корма — другая сторона гонки вооружений с GeneToxicity растений.
{ "defName": "GeneToxinTolerance", "parent": "BaseNumericGene", "label": "gene.toxinTolerance",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "toxinTolerance": "value" } },
// Пол как локус (фаза A3): аллели {X=0, Y=1}; самка XX, самец XY. Эффекта нет — пол читается из
// самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y).
// Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY.
{ "defName": "GeneSex", "kind": "Discrete", "label": "gene.sex",
"variants": 2, "variantWeights": [0.5, 0.5], "mutationChance": 0, "tags": ["animal", "sex"] },
// Социальность [0..1]: стадные/стайные виды держатся вместе (когезия при блуждании) и спокойнее в
// группе («безопасность в числе» снижает дальность реакции на хищника). См. AnimalDecisionSystem.
{ "defName": "GeneSociability", "parent": "BaseNumericGene", "label": "gene.sociability",
"default": 0.3, "min": 0.0, "max": 1.0, "spread": 0.05, "tags": ["animal", "behaviour"],
"effects": { "sociability": "value" } },
// Тип рождения (цикл зачатия): живородящие (eggLaying < 0.5) вынашивают и рожают живых детёнышей;
// яйцекладущие (≥ 0.5) после вынашивания ОТКЛАДЫВАЮТ яйца-сущности, которые инкубируются и
// вылупляются (см. AnimalPregnancySystem/EggSystem). Признак фиксирован по виду (spread/mutation 0).
{ "defName": "GeneEggLaying", "parent": "BaseNumericGene", "label": "gene.eggLaying",
"default": 0.0, "min": 0.0, "max": 1.0, "spread": 0, "mutationChance": 0, "tags": ["animal", "reproduction"],
"effects": { "eggLaying": "value" } },
// Размножение (фаза A4): сезон гона фиксирован по виду (0 весна … 3 зима), срок вынашивания и помёт.
{ "defName": "GeneBreedingSeason", "parent": "BaseNumericGene", "label": "gene.breedingSeason",
"default": 2, "min": 0, "max": 3, "spread": 0, "mutationChance": 0, "tags": ["animal", "reproduction"],
"effects": { "breedingSeason": "value" } },
{ "defName": "GeneGestationDays", "parent": "BaseNumericGene", "label": "gene.gestationDays",
"default": 30, "min": 1, "max": 200, "tags": ["animal", "reproduction"],
"effects": { "gestationDays": "value" } },
{ "defName": "GeneLitterSize", "parent": "BaseNumericGene", "label": "gene.litterSize",
"default": 1, "min": 1, "max": 12, "tags": ["animal", "reproduction"],
"effects": { "litterSize": "value" } }
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"type": "Gene",
// Контент-гены (фаза G4+): плодоношение, добыча, цвет, производные и дискретные морфы.
"defs": [
{ "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" } },
// --- Защита растения (коэволюция с травоядными) ---
// Токсичность отравляет поедателя (hediff Poisoned, тем сильнее, чем ниже его устойчивость к яду);
// шипы наносят поедателю лёгкую травму; вкусность (palatability) — обратная привлекательность для
// умных травоядных (избегание появится в C2). У защиты ЕСТЬ ЦЕНА: токсичность/шипы тормозят рост
// (см. PlantGrowthSystem), иначе все растения дошли бы до максимума и коэволюция бы встала.
{ "defName": "GeneToxicity", "parent": "BaseNumericGene", "label": "gene.toxicity",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["defense"],
"effects": { "toxicity": "value" } },
{ "defName": "GeneThorns", "parent": "BaseNumericGene", "label": "gene.thorns",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["defense"],
"effects": { "thorns": "value" } },
{ "defName": "GenePalatability", "parent": "BaseNumericGene", "label": "gene.palatability",
"default": 1.0, "min": 0.0, "max": 1.0, "tags": ["defense"],
"effects": { "palatability": "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" } }
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"type": "Gene",
// Гены среды: свет, температура, почва. Покрывают экологические измерения генома растения,
// которые читают системы роста/жизненного цикла.
"defs": [
{ "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" } },
// Жёсткие края диапазона роста (модель RimWorld): на сколько °C ниже/выше плато оптимума рост
// спадает до нуля. Ниже (optimal-tolerance-coldHardiness) или выше (optimal+tolerance+heatHardiness)
// растение дормантно и копит температурный стресс. Асимметрично: вид может терпеть жару лучше холода.
{ "defName": "GeneColdHardiness", "parent": "BaseNumericGene", "label": "gene.coldHardiness",
"default": 8, "min": 0, "max": 40, "tags": ["environment", "temperature"],
"effects": { "coldHardiness": "value" } },
{ "defName": "GeneHeatHardiness", "parent": "BaseNumericGene", "label": "gene.heatHardiness",
"default": 10, "min": 0, "max": 40, "tags": ["environment", "temperature"],
"effects": { "heatHardiness": "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" } }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"type": "Gene",
// Гены роста и размножения: бодрость, продолжительность жизни, расселение, репродукция.
"defs": [
{ "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" } }
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"type": "Gene",
// Общие (абстрактные) гены: база наследования для конкретных генов. Каждый ген задаёт, как
// генерируются/мутируют аллели и как ген вкладывается в признаки (effects: имя признака →
// формула; value — выраженное значение гена). Конкретные гены лежат рядом по подтемам.
"defs": [
{ "defName": "BaseNumericGene", "abstract": true, "kind": "Numeric",
"spread": 0.08, "mutationChance": 0.05, "mutationMagnitude": 0.12 }
]
}
+60
View File
@@ -0,0 +1,60 @@
{
"type": "Hediff",
// Хедифы — состояния организма (фаза A4: лёгкий каркас). Гон (Rut) навешивается сезонно на взрослых
// и поднимает половое влечение. Раны/болезни/возрастные эффекты со стадиями придут в A5/A6.
"defs": [
{ "defName": "Rut", "label": "hediff.rut" },
// Болезнь (фаза A6): прогрессирует, параллельно растёт иммунитет (гонка). Обычно иммунитет
// успевает победить (~3 дня), но больной зверь медленнее и слабее; слабые/невезучие гибнут.
{
"defName": "Fever", "label": "hediff.fever",
"initialSeverity": 0.05, "severityPerDay": 0.25, "immunityPerDay": 0.32,
"lethalSeverity": 1.0, "pain": 0.3, "ambientPerDay": 0.03,
"capMods": { "Moving": 0.6, "Consciousness": 0.85, "Digestion": 0.8 }
},
// Рана/кровотечение (предатор-кластер): первый травматический урон. Не прогрессирует сама
// (severityPerDay 0), но immunityPerDay её «сворачивает» (рана заживает ~за 2 дня). Пока активна —
// теряется кровь (bloodLossPerDay × тяжесть); кровь на нуле = смерть. Боль и капмоды от тяжёлых ран.
// Каждый укус усиливает тяжесть и сбрасывает заживление (см. HealthState.Intensify).
{
"defName": "Bleeding", "label": "hediff.bleeding",
"initialSeverity": 0.3, "immunityPerDay": 0.5, "bloodLossPerDay": 0.8,
"pain": 0.25, "capMods": { "Moving": 0.92, "Consciousness": 0.95 }
},
// Отравление растительным ядом (коэволюция): сама не прогрессирует (severityPerDay 0) — тяжесть
// копится при поедании токсичных растений (HealthState.Intensify в AnimalActionSystem). Иммунитет
// её рассасывает (~1.5 дня), но если зверь ест яд быстрее, чем выводит, тяжесть доходит до 1 → смерть.
// Это селективное давление: ядовитый корм опасен для травоядных с низкой устойчивостью.
{
"defName": "Poisoned", "label": "hediff.poisoned",
"initialSeverity": 0.0, "immunityPerDay": 0.65, "lethalSeverity": 1.0, "pain": 0.2,
"capMods": { "Moving": 0.85, "Consciousness": 0.9, "Digestion": 0.7 }
},
// Терморегуляция (как Poisoned-паттерн): сами не прогрессируют — тяжесть копится, пока зверю
// холодно/жарко (см. AnimalHealthSystem, зависит от GeneInsulation + размера тела), а в комфорте
// immunityPerDay их рассасывает. Тяжесть 1 = смерть от переохлаждения/перегрева.
{
"defName": "Hypothermia", "label": "hediff.hypothermia",
"initialSeverity": 0.0, "immunityPerDay": 0.5, "lethalSeverity": 1.0, "pain": 0.15,
"capMods": { "Moving": 0.7, "Consciousness": 0.8 }
},
{
"defName": "Heatstroke", "label": "hediff.heatstroke",
"initialSeverity": 0.0, "immunityPerDay": 0.5, "lethalSeverity": 1.0, "pain": 0.15,
"capMods": { "Moving": 0.75, "Consciousness": 0.8 }
},
// Заражение раны (предатор/шипы → кровотечение → инфекция): прогрессирует, иммунитет (× фильтрация
// крови) душит. Слабая фильтрация/много ран → выше риск и хуже исход.
{
"defName": "Infection", "label": "hediff.infection",
"initialSeverity": 0.08, "severityPerDay": 0.2, "immunityPerDay": 0.28,
"lethalSeverity": 1.0, "pain": 0.25,
"capMods": { "Moving": 0.8, "Consciousness": 0.85, "BloodFiltration": 0.8 }
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"type": "Need",
// Нужды как данные (фаза A2, рефактор расширяемости): добавить нужду виду = добавить запись здесь.
// kind=deplete убывает и утоляется действием; kind=drive растёт под хедифом (гон) и сбрасывается им.
// action — id поведения-исполнителя (реестр в коде). minBrain — гейт интеллектом (активен с A7):
// нужда, чей minBrain выше эффективного интеллекта особи (brainSize × Consciousness), в выбор ИИ не
// входит. Пороги — тиры мозга: 0.0 голод-рефлекс, 0.25 жажда (низший позвоночный), 0.40 сон/секс
// (зверь). У оленя мозг ~0.45: здоров — весь набор; повреждение мозга/болезнь роняют сознание →
// эффективный интеллект падает → первыми отключаются сон/спаривание (обратная связь A7).
"defs": [
{ "defName": "Hunger", "label": "need.hunger", "kind": "deplete", "action": "Eat",
"decayPerDay": 0.55, "feedPerDay": 4, "lethal": true, "minBrain": 0.0 },
{ "defName": "Thirst", "label": "need.thirst", "kind": "deplete", "action": "Drink",
"decayPerDay": 0.9, "feedPerDay": 8, "lethal": true, "minBrain": 0.25 },
{ "defName": "Rest", "label": "need.rest", "kind": "deplete", "action": "Sleep",
"decayPerDay": 0.7, "feedPerDay": 3, "lethal": false, "minBrain": 0.40 },
{ "defName": "Mating", "label": "need.mating", "kind": "drive", "action": "Mate",
"decayPerDay": 1.0, "risePerDay": 1.2, "risesUnder": "Rut", "minBrain": 0.40 }
]
}
@@ -1,14 +1,7 @@
{
"type": "Pawn",
// Животные.
"defs": [
{ "defName": "BaseBeing", "abstract": true, "kind": "being", "sizeCells": 1.1 },
{ "defName": "BeingMale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Male_south" },
{ "defName": "BeingFemale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Female_south" },
{ "defName": "BeingThin", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Thin_south" },
{ "defName": "BeingFat", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Fat_south" },
{ "defName": "BeingHulk", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Hulk_south" },
{ "defName": "BaseAnimal", "abstract": true, "kind": "animal" },
{ "defName": "Bear", "parent": "BaseAnimal", "label": "pawn.bear", "texture": "things/pawn/animal/bear/Bear_east", "sizeCells": 1.7 },
{ "defName": "DeerDoe", "parent": "BaseAnimal", "label": "pawn.deer", "texture": "things/pawn/animal/deer/DeerFemale_east", "sizeCells": 1.4 },
{ "defName": "DeerBuck", "parent": "BaseAnimal", "label": "pawn.deer", "texture": "things/pawn/animal/deer/DeerMale_east", "sizeCells": 1.6 },
+11
View File
@@ -0,0 +1,11 @@
{
"type": "Pawn",
// Жители: варианты тел.
"defs": [
{ "defName": "BeingMale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Male_south" },
{ "defName": "BeingFemale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Female_south" },
{ "defName": "BeingThin", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Thin_south" },
{ "defName": "BeingFat", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Fat_south" },
{ "defName": "BeingHulk", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Hulk_south" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"type": "Pawn",
// Общие (абстрактные) существа: база для жителей и животных.
"defs": [
{ "defName": "BaseBeing", "abstract": true, "kind": "being", "sizeCells": 1.1 },
{ "defName": "BaseAnimal", "abstract": true, "kind": "animal" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"type": "Plant",
// Ягодный почвопокров луга (еда).
"defs": [
{ "defName": "Strawberry", "parent": "BaseBerryBush", "label": "plant.strawberry", "sizeCells": 1.0, "texture": "things/plant/strawberryplant/StrawberryPlant" },
{ "defName": "Raspberry", "parent": "BaseBerryBush", "label": "plant.raspberry", "texture": "things/plant/raspberryplant/raspberrybusha" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"type": "Plant",
// Кусты: ягодный подлесок. Патч даёт им древесину при сборе (веточки) — см. Patches/Patches.json.
"defs": [
{ "defName": "BushA", "parent": "BaseBush", "texture": "things/plant/bush/BushA" },
{ "defName": "BushB", "parent": "BaseBush", "texture": "things/plant/bush/BushB" },
{ "defName": "BushC", "parent": "BaseBush", "texture": "things/plant/bush/BushC" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"type": "Plant",
// Пустыня: кактусы и агава на песке. Большинство без плодов; сагуаро/агава плодоносят.
"defs": [
{ "defName": "SaguaroCactusA", "parent": "BaseCactus", "label": "plant.saguaro", "sizeCells": 2.2, "trunkRadiusCells": 0.18,
"fruitProduct": "ProductBerry", "texture": "things/plant/saguarocactus/SaguaroCactusA" },
{ "defName": "SaguaroCactusB", "parent": "BaseCactus", "label": "plant.saguaro", "sizeCells": 2.2, "trunkRadiusCells": 0.18,
"fruitProduct": "ProductBerry", "texture": "things/plant/saguarocactus/SaguaroCactusB" },
{ "defName": "PebbleCactusA", "parent": "BaseCactus", "sizeCells": 1.0, "texture": "things/plant/pebblecactus/PebbleCactusA" },
{ "defName": "PebbleCactusB", "parent": "BaseCactus", "sizeCells": 1.0, "texture": "things/plant/pebblecactus/PebbleCactusB" },
{ "defName": "PincushionCactusA", "parent": "BaseCactus", "sizeCells": 0.9, "texture": "things/plant/pincushioncactus/PincushionCactusA" },
{ "defName": "AgaveA", "parent": "BaseCactus", "label": "plant.agave", "sizeCells": 1.3, "fruitProduct": "ProductBerry", "texture": "things/plant/agave/AgaveA" },
{ "defName": "AgaveB", "parent": "BaseCactus", "label": "plant.agave", "sizeCells": 1.3, "fruitProduct": "ProductBerry", "texture": "things/plant/agave/AgaveB" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"type": "Plant",
// Луг: декоративные цветы с быстрым расселением.
"defs": [
{ "defName": "DandelionA", "parent": "BaseFlower", "label": "plant.dandelion", "texture": "things/plant/dandelion/Dandelion" },
{ "defName": "DandelionB", "parent": "BaseFlower", "label": "plant.dandelion", "texture": "things/plant/dandelion/DandelionB" },
{ "defName": "DandelionC", "parent": "BaseFlower", "label": "plant.dandelion", "texture": "things/plant/dandelion/DandelionC" },
{ "defName": "DaylilyA", "parent": "BaseFlower", "label": "plant.daylily", "texture": "things/plant/daylily/DaylilyA" },
{ "defName": "DaylilyB", "parent": "BaseFlower", "label": "plant.daylily", "texture": "things/plant/daylily/DaylilyB" },
{ "defName": "RoseA", "parent": "BaseFlower", "label": "plant.rose", "sizeCells": 1.1, "texture": "things/plant/rose/RoseA" }
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"type": "Plant",
// Трава: быстрый почвопокров, основной скаттер лугов и леса.
"defs": [
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.5,
"baseMassKg": 0.4,
"harvestProduct": "ProductGrass",
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 19, "temperatureTolerance": 8,
"coldHardiness": 9, "heatHardiness": 6,
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
"harvestAmount": 2, "leafHue": 0.36 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" },
{ "sizeCells": 1.5, "label": "plant.stage.mature" }
] }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"type": "Plant",
// Грибы: подлесок леса, растут в тени крон.
"defs": [
{ "defName": "GlowstoolA", "parent": "BaseMushroom", "sizeCells": 0.9, "texture": "things/plant/glowstool/GlowstoolA" },
{ "defName": "GlowstoolB", "parent": "BaseMushroom", "sizeCells": 0.9, "texture": "things/plant/glowstool/GlowstoolB" },
{ "defName": "TimbershroomA", "parent": "BaseMushroom", "sizeCells": 1.1, "texture": "things/plant/timbershroom/TimbershroomA" },
{ "defName": "TimbershroomB", "parent": "BaseMushroom", "sizeCells": 1.1, "texture": "things/plant/timbershroom/TimbershroomB" },
{ "defName": "NutrifungusA", "parent": "BaseMushroom", "sizeCells": 0.9, "texture": "things/plant/nutrifungus/NutrifungusA" }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"type": "Plant",
// Деревья: умеренные (BaseTree), тёплые (BaseWarmTree) и холодные (BaseColdTree) ниши.
"defs": [
{ "defName": "TreeOakA", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakA" },
{ "defName": "TreeOakB", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakB" },
{ "defName": "TreeBirchA", "parent": "BaseTree", "label": "plant.birch", "texture": "things/plant/treebirch/TreeBirchA" },
{ "defName": "TreeGrayPineA", "parent": "BaseTree", "label": "plant.pine", "texture": "things/plant/treegraypine/TreeGrayPineA" },
{ "defName": "TreeMapleA", "parent": "BaseTree", "label": "plant.maple", "texture": "things/plant/treemaple/TreeMapleA" },
{ "defName": "TreeMapleB", "parent": "BaseTree", "label": "plant.maple", "texture": "things/plant/treemaple/TreeMapleB" },
{ "defName": "TreePoplarA", "parent": "BaseTree", "label": "plant.poplar", "texture": "things/plant/treepoplar/TreePoplarA" },
{ "defName": "TreeTeakA", "parent": "BaseWarmTree", "label": "plant.teak", "texture": "things/plant/treeteak/TreeTeakA" },
{ "defName": "TreeTeakB", "parent": "BaseWarmTree", "label": "plant.teak", "texture": "things/plant/treeteak/TreeTeakB" },
{ "defName": "TreeCypressA", "parent": "BaseColdTree", "label": "plant.cypress", "texture": "things/plant/treecypress/TreeCypressA" },
{ "defName": "TreeCypressB", "parent": "BaseColdTree", "label": "plant.cypress", "texture": "things/plant/treecypress/TreeCypressB" }
]
}
+126
View File
@@ -0,0 +1,126 @@
{
"type": "Plant",
// Общие (абстрактные) растения: базовые геномы по типам ниш. Конкретные виды наследуют их
// через "parent" и лежат рядом в файлах по семействам (Trees, Bushes, Cacti, …).
"defs": [
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
"baseMassKg": 450,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 10,
"coldHardiness": 14, "heatHardiness": 12,
"optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0,
"lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 5, "fruitSeason": 2, "harvestAmount": 10, "leafHue": 0.30 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
// Деревья тёплой и холодной температурных ниш (showcase температурных генов).
{ "defName": "BaseWarmTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
"baseMassKg": 420,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.65, "lightTolerance": 0.5, "optimalTemperature": 23, "temperatureTolerance": 9,
"coldHardiness": 8, "heatHardiness": 16,
"optimalFertility": 1.5, "fertilityTolerance": 0.9, "vigor": 1.1,
"lifespan": 150, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 5, "fruitSeason": 2, "harvestAmount": 10, "leafHue": 0.22 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
{ "defName": "BaseColdTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.26,
"baseMassKg": 500,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.55, "lightTolerance": 0.5, "optimalTemperature": 8, "temperatureTolerance": 12,
"coldHardiness": 18, "heatHardiness": 8,
"optimalFertility": 1.2, "fertilityTolerance": 1.0, "vigor": 0.9,
"lifespan": 220, "dispersalRange": 2, "reproduceInterval": 16, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.12, "spread": 0.08,
"fruitYield": 4, "fruitSeason": 2, "harvestAmount": 12, "leafHue": 0.45 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
"baseMassKg": 6,
"fruitProduct": "ProductBerry",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 9,
"coldHardiness": 10, "heatHardiness": 8,
"optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0,
"lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
"fruitYield": 3, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.32 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" },
{ "sizeCells": 1.4, "label": "plant.stage.mature" }
] },
// Кактусы: жаролюбивы, засухоустойчивы (низкий оптимум почвы), морозо-нестойки → гибнут в морозы.
{ "defName": "BaseCactus", "abstract": true, "label": "plant.cactus", "sizeCells": 1.2, "trunkRadiusCells": 0,
"baseMassKg": 35,
"harvestProduct": "ProductFiber",
"genome": { "optimalLight": 0.95, "lightTolerance": 0.35, "optimalTemperature": 26, "temperatureTolerance": 8,
"coldHardiness": 20, "heatHardiness": 18,
"optimalFertility": 0.4, "fertilityTolerance": 0.5, "vigor": 0.5,
"lifespan": 300, "dispersalRange": 2, "reproduceInterval": 25, "selfPollination": 0.6,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 3, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.5,
"thorns": 0.6, "palatability": 0.5 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 5, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] },
// Грибы: подлесок леса. Низкий оптимум света (0.15) → растут в тени крон, где другим темно
// (showcase световой пригодности). Богатая почва, короткая жизнь, быстрое спороношение.
{ "defName": "BaseMushroom", "abstract": true, "label": "plant.mushroom", "sizeCells": 0.9, "trunkRadiusCells": 0,
"baseMassKg": 0.3,
"harvestProduct": "ProductMushroom",
"genome": { "optimalLight": 0.15, "lightTolerance": 0.22, "optimalTemperature": 14, "temperatureTolerance": 11,
"coldHardiness": 12, "heatHardiness": 8,
"optimalFertility": 1.6, "fertilityTolerance": 1.1, "vigor": 1.2,
"lifespan": 25, "dispersalRange": 3, "reproduceInterval": 4, "selfPollination": 0.9,
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
"fruitYield": 0, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.5,
"toxicity": 0.5, "palatability": 0.4 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.4, "growDays": 2, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] },
// Луг: цветы (декоративные, быстрое расселение) и ягодный почвопокров (еда).
{ "defName": "BaseFlower", "abstract": true, "label": "plant.flower", "sizeCells": 1.0, "trunkRadiusCells": 0,
"baseMassKg": 0.15,
"genome": { "optimalLight": 0.9, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 9,
"coldHardiness": 8, "heatHardiness": 8,
"optimalFertility": 1.0, "fertilityTolerance": 1.1, "vigor": 1.4,
"lifespan": 18, "dispersalRange": 4, "reproduceInterval": 3, "selfPollination": 0.85,
"mutationRate": 0.08, "variantChance": 0.3, "spread": 0.1,
"fruitYield": 0, "fruitSeason": 0, "harvestAmount": 0, "leafHue": 0.2 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.4, "growDays": 2, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] },
{ "defName": "BaseBerryBush", "abstract": true, "label": "plant.berrybush", "sizeCells": 1.2, "trunkRadiusCells": 0,
"baseMassKg": 5,
"fruitProduct": "ProductBerry",
"genome": { "optimalLight": 0.8, "lightTolerance": 0.4, "optimalTemperature": 18, "temperatureTolerance": 9,
"coldHardiness": 10, "heatHardiness": 7,
"optimalFertility": 1.2, "fertilityTolerance": 1.0, "vigor": 1.1,
"lifespan": 40, "dispersalRange": 2, "reproduceInterval": 6, "selfPollination": 0.6,
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
"fruitYield": 4, "fruitSeason": 2, "harvestAmount": 0, "leafHue": 0.33 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 3, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"type": "Product",
// Продукты сбора/плодоношения растений. На них ссылаются PlantDef.harvestProduct / fruitProduct;
// количество задаётся генами (harvestAmount / fruitYield).
"defs": [
{ "defName": "ProductWood", "label": "product.wood", "kind": "material", "massKg": 1.0 },
{ "defName": "ProductGrass", "label": "product.grass", "kind": "material", "massKg": 0.1 },
{ "defName": "ProductFiber", "label": "product.fiber", "kind": "material", "massKg": 0.2 },
{ "defName": "ProductBerry", "label": "product.berry", "kind": "food", "massKg": 0.05 },
{ "defName": "ProductAcorn", "label": "product.acorn", "kind": "food", "massKg": 0.05 },
{ "defName": "ProductMushroom", "label": "product.mushroom", "kind": "food", "massKg": 0.1 },
// Животные продукты (фаза A5): добываются из трупа (разделка/гниение). Инвентарь/добыча — позже.
{ "defName": "ProductMeat", "label": "product.meat", "kind": "food", "massKg": 0.5 },
{ "defName": "ProductBone", "label": "product.bone", "kind": "material", "massKg": 0.3 },
{ "defName": "ProductLeather", "label": "product.leather", "kind": "material", "massKg": 0.4 }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"type": "Skill",
// Навыки как данные: уровень особи [0..1] растёт от практики (XP при соответствующем действии,
// ускоряется страстью) и медленно угасает к floorLevel. Эффект навыка — код по его id (добыча→выпас,
// охота→урон, уклонение→бегство). Сейчас навыки у животных; те же дефы пригодятся будущему человеку.
"defs": [
{ "defName": "Foraging", "label": "skill.foraging", "category": "survival",
"learnRate": 0.6, "decayPerDay": 0.015, "floorLevel": 0.0 },
{ "defName": "Hunting", "label": "skill.hunting", "category": "survival",
"learnRate": 0.5, "decayPerDay": 0.02, "floorLevel": 0.0 },
{ "defName": "Evasion", "label": "skill.evasion", "category": "survival",
"learnRate": 0.7, "decayPerDay": 0.02, "floorLevel": 0.0 }
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"type": "Terrain",
"defs": [
{ "defName": "DeepWater", "label": "terrain.deepwater", "maxHeight": 0.30, "color": [23, 63, 95] },
{ "defName": "Water", "label": "terrain.water", "maxHeight": 0.42, "color": [32, 99, 155] },
{ "defName": "Sand", "label": "terrain.sand", "maxHeight": 0.46, "color": [237, 201, 120],
"isLand": true, "surface": "terrain/surfaces/sand", "fertility": 0.4,
"scatter": [
{ "chance": 0.05, "options": ["SaguaroCactusA", "SaguaroCactusB"] },
{ "chance": 0.10, "options": ["PebbleCactusA", "PebbleCactusB", "PincushionCactusA"] },
{ "chance": 0.04, "options": ["AgaveA", "AgaveB"] }
] },
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.72, "color": [90, 160, 70],
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
"scatter": [
{ "chance": 1.0, "options": ["GrassA"] },
{ "chance": 0.5, "options": ["GrassA"] },
{ "chance": 0.22, "options": ["BushA", "BushB", "BushC"] },
{ "chance": 0.20, "options": ["Strawberry", "Raspberry"] },
{ "chance": 0.14, "options": ["DandelionA", "DandelionB", "DandelionC", "DaylilyA", "DaylilyB", "RoseA"] }
] },
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 1.01, "color": [44, 110, 50],
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
"scatter": [
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA",
"TreeMapleA", "TreeMapleB", "TreePoplarA", "TreeCypressA", "TreeCypressB", "TreeTeakA", "TreeTeakB"] },
{ "chance": 0.6, "options": ["GrassA"] },
{ "chance": 0.16, "options": ["BushA", "BushB", "BushC"] },
{ "chance": 0.14, "options": ["Strawberry", "Raspberry"] },
{ "chance": 0.16, "options": ["GlowstoolA", "GlowstoolB", "TimbershroomA", "TimbershroomB", "NutrifungusA"] }
] }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"type": "Thought",
// Мысли настроения (фаза A8): событие вешает на зверя временный сдвиг настроения, затухающий за
// durationDays. Триггер события — код (реестр AnimalThoughts), сила/длительность/метка — данные.
// Настроение есть только у тира 4+ (мозг ≥ 0.40); непрерывный фон (голод/жажда/усталость/боль)
// считается отдельно, мысли — поверх него. Негативные событийные мысли (потеря детёныша) придут с
// родительской опекой (горизонт) — пока горе идёт через непрерывный фон.
"defs": [
{ "defName": "GaveBirth", "label": "thought.gaveBirth", "moodOffset": 0.25, "durationDays": 3 },
{ "defName": "Mated", "label": "thought.mated", "moodOffset": 0.15, "durationDays": 1.5 },
{ "defName": "QuenchedThirst", "label": "thought.quenchedThirst", "moodOffset": 0.1, "durationDays": 0.5 }
]
}
@@ -1,8 +1,8 @@
{
"type": "WorldPreset",
"defs": [
{ "defName": "Small", "label": "preset.small", "order": 0, "width": 80, "height": 50, "population": 40 },
{ "defName": "Medium", "label": "preset.medium", "order": 1, "width": 120, "height": 70, "population": 80 },
{ "defName": "Large", "label": "preset.large", "order": 2, "width": 180, "height": 110, "population": 140 }
{ "defName": "Small", "label": "preset.small", "order": 0, "width": 128, "height": 80, "population": 60 },
{ "defName": "Medium", "label": "preset.medium", "order": 1, "width": 200, "height": 120, "population": 110 },
{ "defName": "Large", "label": "preset.large", "order": 2, "width": 300, "height": 180, "population": 180 }
]
}
-73
View File
@@ -1,73 +0,0 @@
{
"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" } }
]
}
-48
View File
@@ -1,48 +0,0 @@
{
"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,
"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" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
{ "defName": "TreeOakA", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakA" },
{ "defName": "TreeOakB", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakB" },
{ "defName": "TreeBirchA", "parent": "BaseTree", "label": "plant.birch", "texture": "things/plant/treebirch/TreeBirchA" },
{ "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,
"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,
"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" }
] },
{ "defName": "BushA", "parent": "BaseBush", "texture": "things/plant/bush/BushA" },
{ "defName": "BushB", "parent": "BaseBush", "texture": "things/plant/bush/BushB" },
{ "defName": "BushC", "parent": "BaseBush", "texture": "things/plant/bush/BushC" }
]
}
-11
View File
@@ -1,11 +0,0 @@
{
"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" }
]
}
-22
View File
@@ -1,22 +0,0 @@
{
"type": "Terrain",
"defs": [
{ "defName": "DeepWater", "label": "terrain.deepwater", "maxHeight": 0.30, "color": [23, 63, 95] },
{ "defName": "Water", "label": "terrain.water", "maxHeight": 0.42, "color": [32, 99, 155] },
{ "defName": "Sand", "label": "terrain.sand", "maxHeight": 0.48, "color": [237, 201, 120],
"isLand": true, "surface": "terrain/surfaces/sand", "fertility": 0.4 },
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70],
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
"scatter": [
{ "chance": 0.35, "options": ["GrassA"] },
{ "chance": 0.07, "options": ["BushA", "BushB", "BushC"] }
] },
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
"scatter": [
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] }
] },
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
]
}
+121 -4
View File
@@ -6,7 +6,89 @@
"season.summer": "summer",
"season.autumn": "autumn",
"season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
"hud.controls": "WASD — camera, wheel — zoom, LMB — select, ` — console, F1 — inspector",
"inspect.tab.overview": "Overview",
"inspect.tab.genes": "Genes",
"inspect.tab.products": "Products",
"inspect.tab.needs": "Needs",
"inspect.tab.skills": "Skills",
"inspect.tab.health": "Health",
"inspect.tab.mood": "Mood",
"inspect.stage": "Stage: {0} ({1}/{2})",
"inspect.growing": "Growth: {0:0}% to next stage",
"inspect.mature": "Fully grown",
"inspect.age": "Age: {0:0.0} d · lives to {1:0}",
"inspect.mass": "Weight: {0:0.#} kg · carries up to {1:0.#} kg",
"inspect.plantmass": "Weight: {0:0.##} kg",
"inspect.state": "State: {0}",
"inspect.state.growing": "growing",
"inspect.state.mature": "mature",
"inspect.state.dormantcold": "dormant — too cold",
"inspect.state.dormanthot": "dormant — too hot",
"inspect.stress": "Stress: {0:0.0} d to death",
"inspect.temp": "Temperature: grows {0:0}…{1:0}°C, optimal {2:0}…{3:0}°C",
"inspect.genes": "Genes",
"inspect.gene.vigor": "Vigor {0:0.00} · lifespan {1:0} d",
"inspect.gene.env": "Opt. light {0:0.00} · opt. soil {1:0.00}",
"inspect.gene.repro": "Dispersal {0:0.0} · interval {1:0.0} d",
"inspect.gene.repro2": "Self-pollin. {0:0.00} · mutation {1:0.00}",
"inspect.gene.hardy": "Cold/heat {0:0}/{1:0} · leaf hue {2:0.00}",
"inspect.gene.defense": "Defense: toxin {0:0.00} · thorns {1:0.00}",
"inspect.gene.variant": "Morph: recessive variant",
"inspect.harvest": "Harvest: {0} ×{1:0.#}",
"inspect.fruit": "Fruit: {0} ×{1:0.#} ({2}) · ripe {3:0.#}",
"inspect.barren": "Bears no fruit",
"inspect.sex.male": "male",
"inspect.sex.female": "female",
"inspect.animal.sex": "Sex: {0} · generation {1}",
"inspect.animal.pregnant": "Pregnant: {0:0.0} d to birth",
"inspect.needline": "{0}: {1:0}%",
"inspect.skillline": "{0}: {1:0}% {2}",
"skill.foraging": "foraging",
"skill.hunting": "hunting",
"skill.evasion": "evasion",
"inspect.health.blood": "Blood: {0:0}% · pain: {1:0}%",
"inspect.health.vitals": "Vitals",
"inspect.vital.blood": "blood",
"inspect.vital.pain": "pain",
"inspect.health.caps": "Capacities",
"inspect.health.cap": "{0}: {1:0}%",
"inspect.health.hediffs": "Conditions",
"inspect.health.hediffline": "{0}: severity {1:0}%",
"inspect.health.none": "Healthy, no wounds or illness",
"inspect.mood.value": "Mood: {0:0}%",
"inspect.mood.none": "No mood (primitive brain)",
"inspect.mood.thoughts": "Thoughts",
"inspect.mood.thoughtline": "{0}: {1}",
"inspect.mood.calm": "Calm, nothing on its mind",
"animalstage.baby": "baby",
"animalstage.juvenile": "juvenile",
"animalstage.adult": "adult",
"animalstage.senior": "senior",
"need.hunger": "hunger",
"need.thirst": "thirst",
"need.rest": "rest",
"need.mating": "mating",
"cap.consciousness": "consciousness",
"cap.moving": "moving",
"cap.sight": "sight",
"cap.hearing": "hearing",
"cap.smell": "smell",
"cap.touch": "touch",
"cap.talking": "talking",
"cap.eating": "eating",
"cap.breathing": "breathing",
"cap.bloodpumping": "blood pumping",
"cap.bloodfiltration": "blood filtration",
"cap.digestion": "digestion",
"hediff.rut": "rut",
"hediff.fever": "fever",
"hediff.bleeding": "bleeding",
"hediff.poisoned": "poisoned",
"hediff.hypothermia": "hypothermia",
"hediff.heatstroke": "heatstroke",
"hediff.infection": "infection",
"inspect.hint": "LMB — select · RMB/Esc — clear",
"hud.paused": "PAUSED",
"menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
@@ -15,6 +97,7 @@
"menu.settings": "Settings",
"menu.credits": "Credits",
"menu.quit": "Quit",
"loading.world": "Generating world",
"newworld.title": "New World",
"newworld.name": "Name",
"newworld.defaultname": "New World",
@@ -35,6 +118,19 @@
"settings.vsync": "Vertical sync",
"settings.resolution": "Resolution",
"settings.volume": "Volume",
"settings.uiscale": "UI scale",
"settings.devmode": "Developer mode",
"settings.on": "On",
"settings.off": "Off",
"dev.title": "Dev spawner",
"dev.hint": "Pick an entry · F9 to hide",
"dev.armed": "In hand: {0} · LMB to spawn, RMB to clear",
"dev.tools": "Tools",
"dev.damage": "Injure selected",
"dev.kill": "Kill selected",
"dev.infect": "Infect selected",
"dev.animals": "Animals",
"dev.plants": "Plants",
"settings.apply": "Apply",
"settings.back": "Back",
"pause.title": "Paused",
@@ -44,7 +140,7 @@
"pause.mainmenu": "Main menu",
"pause.quit": "Quit",
"pause.saved": "Saved: {0}",
"speed.pause": "",
"speed.pause": "Pause",
"credits.title": "Credits",
"credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine",
@@ -57,20 +153,36 @@
"terrain.sand": "sand",
"terrain.grass": "grass",
"terrain.forest": "forest",
"terrain.mountain": "mountains",
"plant.oak": "oak",
"plant.birch": "birch",
"plant.pine": "pine",
"plant.grass": "grass tuft",
"plant.bush": "bush",
"plant.maple": "maple",
"plant.poplar": "poplar",
"plant.teak": "teak",
"plant.cypress": "cypress",
"plant.cactus": "cactus",
"plant.saguaro": "saguaro",
"plant.agave": "agave",
"plant.mushroom": "mushroom",
"plant.flower": "flower",
"plant.dandelion": "dandelion",
"plant.daylily": "daylily",
"plant.rose": "rose",
"plant.berrybush": "berry bush",
"plant.strawberry": "strawberry",
"plant.raspberry": "raspberry",
"plant.stage.seedling": "seedling",
"plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling",
"plant.stage.mature": "mature",
"product.wood": "wood",
"product.grass": "grass",
"product.fiber": "fiber",
"product.berry": "berries",
"product.acorn": "acorn",
"product.mushroom": "mushrooms",
"pawn.being": "being",
"pawn.bear": "bear",
"pawn.deer": "deer",
@@ -78,11 +190,16 @@
"pawn.hare": "hare",
"pawn.boar": "boar",
"pawn.wolf": "wolf",
"pawn.chicken": "chicken",
"pawn.muffalo": "muffalo",
"pawn.squirrel": "squirrel",
"thought.gaveBirth": "gave birth",
"thought.mated": "mated",
"thought.quenchedThirst": "drank its fill",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…",
"net.connected": "connected",
"net.failed": "connection failed",
"net.lost": "connection lost"
"net.lost": "connection lost",
"net.reconnecting": "reconnecting…"
}
+121 -4
View File
@@ -6,7 +6,89 @@
"season.summer": "лето",
"season.autumn": "осень",
"season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор",
"inspect.tab.overview": "Обзор",
"inspect.tab.genes": "Гены",
"inspect.tab.products": "Продукты",
"inspect.tab.needs": "Нужды",
"inspect.tab.skills": "Навыки",
"inspect.tab.health": "Здоровье",
"inspect.tab.mood": "Настроение",
"inspect.stage": "Стадия: {0} ({1}/{2})",
"inspect.growing": "Рост: {0:0}% до следующей стадии",
"inspect.mature": "Полностью выросло",
"inspect.age": "Возраст: {0:0.0} дн · живёт до {1:0}",
"inspect.mass": "Вес: {0:0.#} кг · несёт до {1:0.#} кг",
"inspect.plantmass": "Вес: {0:0.##} кг",
"inspect.state": "Состояние: {0}",
"inspect.state.growing": "растёт",
"inspect.state.mature": "созрело",
"inspect.state.dormantcold": "покой — слишком холодно",
"inspect.state.dormanthot": "покой — слишком жарко",
"inspect.stress": "Стресс: {0:0.0} дн до гибели",
"inspect.temp": "Температура: рост {0:0}…{1:0}°C, оптимум {2:0}…{3:0}°C",
"inspect.genes": "Гены",
"inspect.gene.vigor": "Бодрость {0:0.00} · жизнь {1:0} дн",
"inspect.gene.env": "Опт. свет {0:0.00} · опт. почва {1:0.00}",
"inspect.gene.repro": "Расселение {0:0.0} кл · период {1:0.0} дн",
"inspect.gene.repro2": "Самоопыление {0:0.00} · мутации {1:0.00}",
"inspect.gene.hardy": "Морозо/жаро {0:0}/{1:0} · оттенок {2:0.00}",
"inspect.gene.defense": "Защита: яд {0:0.00} · шипы {1:0.00}",
"inspect.gene.variant": "Морфа: рецессивный вариант",
"inspect.harvest": "Сбор: {0} ×{1:0.#}",
"inspect.fruit": "Плоды: {0} ×{1:0.#} ({2}) · зрелых {3:0.#}",
"inspect.barren": "Не плодоносит",
"inspect.sex.male": "самец",
"inspect.sex.female": "самка",
"inspect.animal.sex": "Пол: {0} · поколение {1}",
"inspect.animal.pregnant": "Беременна: {0:0.0} дн до родов",
"inspect.needline": "{0}: {1:0}%",
"inspect.skillline": "{0}: {1:0}% {2}",
"skill.foraging": "добыча корма",
"skill.hunting": "охота",
"skill.evasion": "уклонение",
"inspect.health.blood": "Кровь: {0:0}% · боль: {1:0}%",
"inspect.health.vitals": "Жизненные показатели",
"inspect.vital.blood": "кровь",
"inspect.vital.pain": "боль",
"inspect.health.caps": "Способности",
"inspect.health.cap": "{0}: {1:0}%",
"inspect.health.hediffs": "Состояния",
"inspect.health.hediffline": "{0}: тяжесть {1:0}%",
"inspect.health.none": "Здоров, ран и болезней нет",
"inspect.mood.value": "Настроение: {0:0}%",
"inspect.mood.none": "Настроения нет (примитивный мозг)",
"inspect.mood.thoughts": "Мысли",
"inspect.mood.thoughtline": "{0}: {1}",
"inspect.mood.calm": "Спокоен, особых мыслей нет",
"animalstage.baby": "детёныш",
"animalstage.juvenile": "подросток",
"animalstage.adult": "взрослый",
"animalstage.senior": "старый",
"need.hunger": "голод",
"need.thirst": "жажда",
"need.rest": "отдых",
"need.mating": "влечение",
"cap.consciousness": "сознание",
"cap.moving": "движение",
"cap.sight": "зрение",
"cap.hearing": "слух",
"cap.smell": "обоняние",
"cap.touch": "осязание",
"cap.talking": "речь",
"cap.eating": "питание",
"cap.breathing": "дыхание",
"cap.bloodpumping": "кровоснабжение",
"cap.bloodfiltration": "фильтрация крови",
"cap.digestion": "пищеварение",
"hediff.rut": "гон",
"hediff.fever": "лихорадка",
"hediff.bleeding": "кровотечение",
"hediff.poisoned": "отравление",
"hediff.hypothermia": "переохлаждение",
"hediff.heatstroke": "тепловой удар",
"hediff.infection": "заражение",
"inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять",
"hud.paused": "ПАУЗА",
"menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
@@ -15,6 +97,7 @@
"menu.settings": "Настройки",
"menu.credits": "Авторы",
"menu.quit": "Выход",
"loading.world": "Создание мира",
"newworld.title": "Новый мир",
"newworld.name": "Название",
"newworld.defaultname": "Новый мир",
@@ -35,6 +118,19 @@
"settings.vsync": "Вертикальная синхронизация",
"settings.resolution": "Разрешение",
"settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI",
"settings.devmode": "Режим разработчика",
"settings.on": "Вкл",
"settings.off": "Откл",
"dev.title": "Дев-спавнер",
"dev.hint": "Выбери пункт · F9 — скрыть",
"dev.armed": "В руке: {0} · ЛКМ — спавн, ПКМ — снять",
"dev.tools": "Инструменты",
"dev.damage": "Ранить выбранного",
"dev.kill": "Убить выбранного",
"dev.infect": "Заразить выбранного",
"dev.animals": "Животные",
"dev.plants": "Растения",
"settings.apply": "Применить",
"settings.back": "Назад",
"pause.title": "Пауза",
@@ -44,7 +140,7 @@
"pause.mainmenu": "Главное меню",
"pause.quit": "Выход",
"pause.saved": "Сохранено: {0}",
"speed.pause": "",
"speed.pause": "Пауза",
"credits.title": "Авторы",
"credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng",
@@ -57,20 +153,36 @@
"terrain.sand": "песок",
"terrain.grass": "трава",
"terrain.forest": "лес",
"terrain.mountain": "горы",
"plant.oak": "дуб",
"plant.birch": "берёза",
"plant.pine": "сосна",
"plant.grass": "пучок травы",
"plant.bush": "куст",
"plant.maple": "клён",
"plant.poplar": "тополь",
"plant.teak": "тик",
"plant.cypress": "кипарис",
"plant.cactus": "кактус",
"plant.saguaro": "сагуаро",
"plant.agave": "агава",
"plant.mushroom": "гриб",
"plant.flower": "цветок",
"plant.dandelion": "одуванчик",
"plant.daylily": "лилейник",
"plant.rose": "роза",
"plant.berrybush": "ягодный куст",
"plant.strawberry": "земляника",
"plant.raspberry": "малина",
"plant.stage.seedling": "росток",
"plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое",
"product.wood": "древесина",
"product.grass": "трава",
"product.fiber": "волокно",
"product.berry": "ягоды",
"product.acorn": "жёлудь",
"product.mushroom": "грибы",
"pawn.being": "житель",
"pawn.bear": "медведь",
"pawn.deer": "олень",
@@ -78,11 +190,16 @@
"pawn.hare": "заяц",
"pawn.boar": "кабан",
"pawn.wolf": "волк",
"pawn.chicken": "курица",
"pawn.muffalo": "муффало",
"pawn.squirrel": "белка",
"thought.gaveBirth": "родила потомство",
"thought.mated": "спарилась",
"thought.quenchedThirst": "напилась вволю",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…",
"net.connected": "подключено",
"net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно"
"net.lost": "соединение потеряно",
"net.reconnecting": "переподключение…"
}
+20
View File
@@ -60,6 +60,26 @@ Mods/<Имя>/
вид — `being`/`animal`). CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип
дефа = новый класс + `RegisterType` в `GameContent.Load`.
`Defs/` сканируется рекурсивно (`Defs/**/*.json`), и имя файла ни на что не влияет —
важны только `type` и `defName`. Поэтому контент Core разложен по подпапкам для удобства
поиска: одна папка на тип дефа, внутри — общие (абстрактные) дефы в `_Bases.json` и
конкретные, разбитые по семействам:
```
Defs/
Genes/ _Bases.json, Environment.json, Growth.json, Content.json
Plants/ _Bases.json, Trees.json, Bushes.json, Grass.json, Cacti.json,
Mushrooms.json, Flowers.json, Berries.json
Pawns/ _Bases.json, Beings.json, Animals.json
Terrain/ Terrain.json
Products/ Products.json
Patches/ Patches.json
WorldPresets/ WorldPresets.json
```
`parent` разрешается после слияния всех файлов, так что ссылка на родителя из другого
файла/папки (`TreeOakA``BaseTree`) работает независимо от расположения.
## Локализация
`Languages/<код>/*.json` — плоские словари «ключ → строка»:
-36
View File
@@ -1,36 +0,0 @@
# Roadmap LittleSim
Этап закрыт, когда механика работает, наблюдаема (HUD/консоль/визуал)
и управляется командой консоли.
## Этап 0 — Каркас ✅
- [x] Репозиторий с движком-сабмодулем, общий solution
- [x] Генерация рельефа по сиду (вода/песок/трава/лес/горы)
- [x] Жители-заглушки (блуждание), камера бога, HUD, консоль (`regen`)
## Этап 1 — Жители как агенты
- [~] Потребности: голод, усталость (тикают со временем) — усталость/энергия готова (`PawnNeeds`), голод впереди
- [~] Utility-выбор действия: искать еду / отдыхать / бродить — каркас на движке (`MrGameEng.AI`), выбор «отдыхать/бродить» работает; «искать еду» впереди
- [ ] Еда на траве/в лесу: растёт, собирается, истощается
- [ ] Смерть от голода; визуальное состояние жителя (цвет/прозрачность)
- [ ] Консоль: `spawn <n>`, `feed`, `starve` для тестов
## Этап 2 — Общество
- [ ] Дома и поселения (житель несёт еду домой, спит дома)
- [ ] Размножение при сытости; рост населения
- [ ] Простые профессии: собиратель, строитель
## Этап 3 — Бог
- [ ] Вера как ресурс (генерируется жителями)
- [ ] Первые силы: дождь (ускоряет рост еды), молния (поджигает лес)
- [ ] Терраформинг клеток
## Этап 4 — Экология и время
- [ ] День/ночь, сезоны
- [ ] Восстановление/деградация ресурсов, пожары
- [ ] Миграции при нехватке ресурсов
## Бэклог
- Сохранение/загрузка мира (сериализация Friflo)
- Фиксированный тик симуляции, независимый от рендера
- Исторические графики (население, еда) в дев-оверлее
+572
View File
@@ -0,0 +1,572 @@
# Животные (зоология)
> Статус: **A1–A8 реализованы целиком + сериализация животных** — базовый план зоологии и его сквозное
> требование сейв/лоада закрыты; дальше экологический горизонт (хищники, опека, эпидемии, миграция) и
> бэклог реализма. Это живой план — стиль и правила как в [концепте](concept.md) и [модах](mods.md).
>
> **Сериализация животных (готово): сейв/лоад популяции и трупов.** `WorldSave` получил `Animals`
> (`AnimalSave`: геном→признаки+пол, поколение, стадия/возраст, нужды+истощение, здоровье — кровь/HP
> частей/хедифы тяжесть+иммунитет, настроение+мысли, беременность+геном отца) и `Corpses` (`CorpseSave`:
> вид/позиция/таймер-стадия разложения/мясо). `SaveAnimals` снимает популяцию (Health/Mood/Pregnant — через
> entity, т.к. запрос ≤5 типов); `RestoreAnimals`/`RestoreAnimalState` собирают особь фабрикой из генома и
> накладывают динамическое состояние (ref-правки до структурного `AddComponent(Pregnant)`; труп
> пере-тинтится через `Stage=-1`). `Corpse` получил поле `Species` (для спрайта/разделки); `CreateCorpse`
> теперь принимает индекс вида. На загрузке стадо НЕ пересыпается, если в сейве есть животные; старый/пустой
> сейв — регенерируется из сида (не крашит). Эволюция/болезни/беременности переживают сейв/лоад. Сборка +
> `--check-content` чистые.
>
> **A8 часть 2 (готово): инспектор животных.** `InspectPanel` теперь осматривает и животных (не только
> растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %),
> **Здоровье** (кровь/боль, способности Consciousness/Moving/Sight, активные хедифы с тяжестью),
> **Настроение** (значение % + список мыслей с их сдвигом). Набор вкладок зависит от типа выбранного
> (растение vs животное). `SelectionSystem` теперь пикает и животных (запрос расширен с `PlantGrowth` на
> `AnimalOrganism`). Локализация `inspect.*`/`need.*`/`hediff.*`/`cap.*`/`animalstage.*` (ru/en). Сборка +
> `--check-content` чистые.
>
> **A8 часть 1 (готово): настроение — ядро.** Тип дефа `ThoughtDef` + `thoughts.json` (мысли: роды/
> спаривание/утоление жажды — сила и длительность данными); компонент `Mood` (значение 0..1, активность по
> тиру мозга, список затухающих мыслей) на всех животных; `AnimalMoodSystem` (тир 4+: настроение из
> непрерывного фона — средняя удовлетворённость убывающих нужд минус боль из здоровья — плюс сумма мыслей;
> мысли угасают). Реестр `ThoughtSet` + id-константы `AnimalThoughts`; событийные триггеры: роды
> (`AnimalPregnancySystem`), спаривание и утоление сильной жажды (`AnimalActionSystem`). **Эффект на
> поведение**: низкое настроение (стресс/истощение) подавляет спаривание в `AnimalDecisionSystem`. Команда
> `mood [species]`. Сборка + `--check-content` чистые (11 типов дефов). Осталось A8.2: вкладки инспектора
> (needs/health/mood) — сейчас животные в инспекторе не осматриваются (он только растительный).
>
> **A7 (готово): мозг → интеллект → гейт нужд/действий.** Эффективный интеллект = `brainSize` (ген) ×
> `Consciousness` (capacity из здоровья) — `AnimalFactory.Intelligence`. `AnimalContext` гейтит выбор ИИ:
> нужда с `minBrain` выше интеллекта особи в выбор не входит (`AnimalDecisionSystem` читает `Health`
> поэлементно). Пороги в `needs.json` — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг
> ~0.45 → здоров весь набор). **Обратная связь от повреждения мозга**: болезнь/боль/кровопотеря роняют
> сознание → эффективный интеллект падает → первыми отключаются сон/спаривание (лихорадящий олень
> сводится к еде/питью). Команда `intel <species> [consciousness]`. Сборка + `--check-content` чистые.
>
> **A5 часть 1 (готово): смерть → труп → разложение.** `Corpse`-компонент + `CorpseSystem`
> (Fresh→Rotting→Skeleton→исчезает, скорость от температуры, тинт по стадии); смерть теперь оставляет
> труп (`AnimalFactory.CreateCorpse`, мясо ∝ размер тела) вместо деспавна; `AnimalDef.CorpseTexture` +
> `corpseTexture` оленя (`Dessicated_*`, скелет — бледный тинт, своего спрайта пока нет). Продукты
> `ProductMeat/Bone/Leather` в `products.json` (разделка/падальщики — позже). Сборка/линт чистые.
> **A5 часть 2 (готово): части тела, кровь, capacities.** `BodyDef`/`BodyPartDef` + `bodies.json`
> (`Quadruped` — данные: иерархия органов, вклады в способности); `AnimalDef.Body`; `HealthState`
> расширен `Parts`/`BloodLevel`/`Pain`/`Capacities`; `CapacityCalc` (обобщённый расчёт по зависимостям
> кровь→сердце/лёгкие→сознание→движение/зрение, модовые способности — сырыми); фабрика строит части
> (HP × размер тела) и считает способности. Источников урона ещё нет → всё на полном HP, способности =
> 1 (каркас под болезни A6/хищников). Команда `body <species>`. Сборка/линт чистые (10 типов дефов).
> Осталось в A5: смерть через здоровье (vital-часть/кровь) + истощение как hediff — когда появятся раны.
>
> **A6 (готово): болезни и иммунитет.** `HediffDef` расширён (severity/immunity/lethal/pain/capMods/
> ambient); хедифы теперь инстансы (`Hediff` с тяжестью/иммунитетом). `AnimalHealthSystem` (медленный
> тик ~час): фоновое заражение, гонка тяжесть↔иммунитет (выздоровление или смерть), пересчёт способностей
> с учётом боли и capMods; смерть по здоровью (летальная болезнь/кровь/потеря сознания) → труп. **Первый
> реальный урон** — `Moving` подключён к скорости (больные звери медленнее). Болезнь `Fever` в
> `hediffs.json`; команда `infect [hediff]`. Сборка/линт чистые.
>
> **A4 (готово):** нужда `Mating`; лёгкий hediff-каркас (`HediffDef`, `hediffs.json` с `Rut`,
> компонент `Health`/`HealthState`); гены `GeneBreedingSeason`/`GeneGestationDays`/`GeneLitterSize`;
> `AnimalRutSystem` (сезонный гон-хедиф поднимает влечение — вне сезона зачатия нет); действие
> `Mate` (поиск партнёра, сближение); `Pregnant` + `AnimalPregnancySystem` (вынашивание → роды
> через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка
> чистая, `--check-content` (33 гена).
>
> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — данными стали: **геном вида**
> (`AnimalDef.Genome` = словарь `geneId→base`, шаблон строится generically); **нужды**
> (`NeedDef`/`needs.json` + `NeedSet` + динамический `AnimalNeeds.Values` + реестр поведений по id);
> **диета** (`AnimalDef.Diet`); **стадии роста** (`AnimalStageDef`/`AnimalDef.Stages` — возраст входа,
> масштаб, текстуры, флаг «взрослая»; пусто → встроенный дефолт); **спавн** (`AnimalDef.SpawnPer1000Cells`
> — все виды рассыпаются по суше из данных, не только олень). Добавить ген/нужду/рацион/стадию/спавн
> виду = правка JSON без кода. Остаются как известные: реестр действий-кода (новое *поведение* = код,
> сознательная граница), часть глобальных баланс-констант (скорость/выпас/потолок).
>
> **A3 (готово):** компонент `AnimalGrowth` (стадия + возраст); ген пола `GeneSex` (XX/XY,
> генерация основателя без YY через `AnimalSet.GenerateGenome`) и ген `GeneMaturityAge`;
> стадии Baby→Juvenile→Adult→Senior с порогами из генов; `AnimalGrowthSystem` (смена
> спрайта детёныш/самец/самка и размера по стадии) + `AnimalMortalitySystem` (смерть от
> старости и от истощения → деспавн, замыкает контур ёмкости среды). Спавн стада —
> разновозрастный; команда `animal` показывает пол/созревание. Сборка чистая, `--check-content`
> (30 генов).
>
> **A1 (готово):** тип дефа `Animal` (`AnimalDef : PawnDef`) + `animals.json` (олень);
> 8 животных генов в `genes.json`; `AnimalPhenotype`/`AnimalOrganism`; `AnimalSet`
> (шаблон генома вида) + `AnimalFactory`; детерминированный спавн стада оленей по суше в
> `WorldScene` с рендером и тинтом меха по гену; консольная команда `animal <species>`.
>
> **A2 (готово):** нужды `AnimalNeeds` (голод/жажда/отдых) с падением по гену метаболизма;
> два слоя ИИ — `AnimalDecisionSystem` (движковый `UtilityAi`, выбор + поиск ближайшей
> еды/воды) и `AnimalActionSystem` (движение к цели + утоление: выедание растений, питьё у
> кромки воды, сон); `AnimalAppearanceSystem` (яркость по острой нужде); кромка воды из
> террейна; команда `popstats [species]` (средние признаков + размах поколений). Контур
> ёмкости среды частичный (трава выедается, но смерть от голода — в A3). Сборка чистая;
> визуальная проверка (олени ищут корм/воду, пасутся) — вручную: «Новый мир».
## Видение
Животные — следующая большая система после генов. Главный принцип: животное живёт по
**тем же законам, что и растение** — гены → признаки (фенотип) → поведение систем, —
но разворачивает их в полноценную зоологию: двуполое размножение с вынашиванием,
здоровье по модели RimWorld (части тела, органы, кровь, болезни), мозг и интеллект,
нужды и настроение. Цель прежняя: **эмерджентность** (динамика популяций, отбор,
пищевые цепи складываются из правил, а не скриптуются) и **наблюдаемость** (всё видно
в мире, HUD, инспекторе, консоли).
Первое показательное животное — **олень** (богатый набор текстур: ♂/♀/детёныш/труп).
## Опора на существующий фундамент
Ничего из перечисленного не строим заново — переиспользуем:
- организм-агностичный `GeneDef`, управляемый `Genome` (диплоидные аллели),
`Genome.Breed(мать, отец, mutationChance)`, `Phenotype.Compute`, `GenomeTemplate`
(шаблон вида, ср. `PlantSet.BuildTemplate`);
- формульный движок и regex-группы (`gsum('Gene.*')`) для производных признаков;
- движковый `UtilityAi<TContext>`, `ResponseCurve`, `Suitability.Trapezoid`;
- день/ночь, сезоны, климат, террейн `water`, `ProductDef`;
- паттерн компонентов растений: `…Organism { Genome; Phenotype Traits }`,
`…Growth { species, stage, ageDays }`, `…Needs`, `…Brain`, `AppearanceSystem` (sim→`Sprite`).
- Friflo допускает managed-ссылку в `IComponent` (доказано `Genome`/`Tilemap`) —
поэтому `Health`/`Pregnant` несут managed-объекты.
## Принцип моддинга: всё расширяется данными
Главное требование — **система должна целиком модифицироваться через моды без правки
кода**. Для этого:
1. **Никаких C#-`enum` для видового контента.** Виды, части тела, органы, способности
(capacities), болезни, нужды, мысли — это **дефы со строковыми id**, интернируемые
в int при загрузке (ровно как уже сделано с `geneId`). Enum нельзя расширить модом —
строковый id можно.
2. **Формулы вместо констант** везде, где можно (как `effects` у генов): модер меняет
баланс формулой в дефе, а не пересборкой.
3. **Реестр поведенческих действий** (`actionId → исполнитель-код`). Деф нужды/ИИ
ссылается на `actionId`. Граница честная и в духе проекта (движок без сэмпла,
фичи демонстрируются игрой): **комбинировать готовые кирпичи — мод; принципиально
новое поведение — код + деф.**
4. **Patch-система (фаза G5)** уже позволяет модеру менять чужие дефы по regex
(например, «всем хищникам +агрессия»).
5. Любой деф переопределяется одноимённым из позднего мода; локализация — в `Languages`.
Итог: модер добавляет новый вид зверя, орган, болезнь, нужду или мысль одними JSON-файлами.
## Геном животного
Животные гены живут в `genes.json` рядом с растительными (один организм-агностичный
тип `Gene`). Предлагаемый набор (числовые, если не сказано иное):
| Группа | Гены |
|---|---|
| Тело/метаболизм | `GeneMaxBodySize`, `GeneMetabolism`, `GeneMoveSpeed`, `GeneBloodVolume`, `GeneVision`, `GeneInsulation` (мех/терморегуляция) |
| Жизненный цикл | `GeneMaturityAge`, `GeneLifespan` (есть), `GeneGestationDays`, `GeneLitterSize` |
| Органы | `GeneHeartEfficiency`, `GeneLungCapacity`, `GeneImmunityGain`, `GeneHealRate`, `GenePainTolerance` |
| Мозг | `GeneBrainSize` → признак `brainSize` (см. «Мозг и интеллект») |
| Поведение | `GeneAggression`, `GeneSociability`, `GeneActivityPattern` (дневной/ночной/сумеречный) |
| Среда | переиспользуем `GeneOptimalTemperature/Tolerance/Cold/HeatHardiness` — звери тоже мёрзнут/перегреваются через ту же трапецию |
| Морфология | `GeneFurColor` (тинт спрайта, как `leafHue`), `GeneSex` (дискретный, см. ниже) |
**Пол выводится из диплоидности бесплатно.** Локус с аллелями {X=0, Y=1}: самка = XX,
самец = XY. При `Genome.Breed` мать может отдать только X (у неё нет Y), отец — X/Y
поровну → потомок XX/XY ровно 50/50, **без спец-кода в скрещивании**. У гена пола
`mutationChance: 0` (как у `GeneFruitSeason`), чтобы X не мутировал в Y.
> Тонкость генерации (не скрещивания): обычный `Generate` дискретного гена выбрал бы
> два аллеля независимо и мог бы выдать невозможный **YY**. Поэтому пол особи-основателя
> назначается явно — XX или XY (по 50/50) — а дальнейшее наследование корректно само.
Признаки собираются в `AnimalPhenotype.FromTraits(traits)` — зеркало `PlantPhenotype`.
Организм: `AnimalOrganism { Genome; AnimalPhenotype Traits; int Generation }` (поколение =
max(род.) + 1, у основателей 0 — для `popstats` и видимого дрейфа).
## Жизненный цикл и стадии роста
Зеркало `PlantGrowth`: `AnimalGrowth { species, stage, ageDays }`. Стадии задаются
данными в дефе вида: `Embryo` (в утробе) → `Baby``Juvenile``Adult``Senior`,
пороги из генов (`MaturityAge`, `Lifespan`). `AnimalGrowthSystem` двигает стадию,
масштабирует `sizeCells`, меняет спрайт/набор текстур (Baby ↔ ♀/♂). Стадия `Senior`
повышает шанс возрастных hediff и смерти — стык со здоровьем.
> **Путь смерти по фазам.** Чтобы на A4 (размножение) популяция не взрывалась без
> убыли, простая **смерть по старости и от голода/жажды (деспавн)** появляется уже в A3.
> На A5 она «апгрейдится»: смерть идёт через здоровье (возрастные hediff, истощение, отказ
> органов) и оставляет **труп** вместо деспавна.
## Нужды и ИИ
Обобщаем `PawnNeeds` (одна энергия) → `AnimalNeeds`: голод, жажда, сон, секс (0..1).
- скорости падения из `GeneMetabolism` × размер (масштаб по Клайберу: ∝ size^0.75);
- **голод** — ест растения/продукты. **Поедание реально воздействует на сущность
растения** (важно для контура ёмкости среды): выедает накопленные плоды
(`Fruiting.RipeFruit`) и/или убавляет биомассу/стадию растения; перевыпас может убить
растение. **Диета** (`AnimalDef.diet`) — список съедобного: теги растений/`ProductDef`
(для будущих хищников — живая добыча/мясо/трупы), плюс способ (выедание плодов vs выпас);
- **жажда** — пьёт на клетках террейна `water`;
- **сон** — спит в неактивную фазу суток (`GeneActivityPattern`): дневные ночью и т.д.;
- **секс** — удовлетворяется размножением.
ИИ разбит на **два слоя — выбор и исполнение**:
1. **Выбор**`AnimalDecisionSystem` на движковом `UtilityAi`: из активных нужд выбирает
действие `FindFood / FindWater / Sleep / SeekMate / Wander(default)` (готовые
`ResponseCurve`) и пишет его + цель в `AnimalBrain { Action; Target; DecideIn }`.
Действия и нужды описаны дефами `NeedDef` (decay-формула, `minBrain`).
2. **Исполнение** — отдельный слой систем (реестр `actionId → исполнитель`, см. «Принцип
моддинга») доводит выбранное действие до результата по схеме **найти цель → дойти →
выполнить → удовлетворить нужду**:
- *Eat*: ближайшее съедобное (плод/биомасса) → подойти → съесть (убавить растение) → ↑ сытость;
- *Drink*: ближайшая клетка `water` → подойти → попить → ↓ жажда;
- *Sleep*: остановиться → спать → ↓ усталость;
- *Mate*: подходящий партнёр → сблизиться → акт → `Pregnant` + сброс `Mating`;
- *Wander*: движение по умолчанию.
Без этого слоя нужда только падает, но не исполняется.
**Передвижение и поиск цели.** Движение к цели — рулевое смещение к точке (реюз
`Transform2D` + `WanderSystem`/`SeparationSystem` для обхода препятствий: стволы деревьев,
другие звери), без полноценного A* на старте. Вода непроходима для сухопутных → `Drink`
целит клетку суши у кромки воды. «Ближайшее съедобное/вода/партнёр» — **пространственный
запрос по сетке/тайлмапу** с радиусом из `Vision`/`Sight` (не O(n²)); ничьи в «ближайшем»
разрешаются детерминированно (по id сущности).
> Порядок фаз: формальный **гейтинг нужд интеллектом — фаза A7**. В A2 у оленя просто
> полный набор нужд зверя (тир 4); поле `minBrain` уже пишем в дефы, но активируется оно
> на A7 (см. «Мозг и интеллект»).
## Размножение (млекопитающие)
Главное отличие от растений — двуполость и отложенное во времени вынашивание:
1. **Влечение и гон (через хедиф).** Нужда `Mating` есть **круглый год** на низком фоне.
В брачный сезон вида на взрослых вешается **хедиф `Rut` (гон)**, который поднимает
влечение (множитель на рост `Mating` / снижение порога действия). **Только под `Rut`
влечение переходит порог `SeekMate` → вне сезона зачатия фактически нет**, хотя нужда
формально существует. Хедиф снимается с концом сезона и **виден в инспекторе** (как
любой hediff). Это унифицирует сезонность с hediff-механизмом, а не городит спец-флаг.
2. **Спаривание → беременность.** Два взрослых разного пола сближаются (действие `Mate`) →
акт → у самки появляется `Pregnant : IComponent { Genome FatherGenome; float
ConceivedDay; float GestationDays; int LitterSize }` — managed-ссылка на геном отца
(оплодотворение = «слепок» отца). Беременность повышает метаболизм (голод) и снижает
`Moving`. (Можно реализовать и как hediff, но держим компонентом ради managed-ссылки
на геном отца.)
3. **Роды.** По достижении срока спавним `LitterSize` детёнышей, каждому
`Genome.Breed(мать, отец, mutationRate)`, стадия `Baby`, рядом с матерью. Лактация/
опека — опционально после.
Стратегии r/K выводятся из генов: мелкие (заяц) — большой помёт, короткая жизнь;
крупные (олень/медведь) — мало потомства, долгая жизнь.
**Решённые параметры (A4):**
- **Гон = хедиф `Rut`** (а не «нужда растёт в сезон»): навешивается в брачный сезон
`GeneBreedingSeason` (как `fruitSeason`, 0..3) — у **оленя осень** (→ роды весной к
обилию корма); у r-стратегов (заяц) — круглогодично/частый гон. Под `Rut` влечение
переходит порог действия; вне `Rut` зачатия нет.
- **Hediff-каркас рождается здесь (A4).** Гон — первый хедиф, поэтому в A4 появляется
лёгкий каркас: контейнер `Health`/`HealthState` со списком хедифов, `HediffDef` со
стадиями и модификаторами, навешивание/снятие по триггеру. A5/A6 расширяют его
частями тела, кровью, болезнями.
- **Триггер акта** — взрослые, разный пол, у обоих `Mating > 0.6`, рядом (1–2 клетки),
самка не беременна, оба не спят/не убегают.
- **Послеродовый кулдаун** — самка не зачинает снова до родов + восстановления;
самец восстанавливается быстро → **полигиния** (один самец кроет нескольких самок).
- **Выбор партнёра (половой отбор)** — НЕ в A4: на старте берётся ближайший подходящий;
предпочтение по размеру/здоровью/настроению добавим позже отдельной итерацией.
## Здоровье — полная модель RimWorld
### Данные (дефы)
- **`BodyDef`** — дерево частей. У части: `coverage` (доля тела → вес попадания),
`parent`, `maxHp` (масштаб от размера тела), флаги (`vital`, `solid`, `skinCovered`),
**вклады в capacities** `{ breathing: 0.5, consciousness: 1.0, moving: 0.25, … }`.
Пример (олень): `body → torso → (heart, lungs×2, liver, stomach, kidneys×2, spine),
head → (brain, eyes×2, ears×2, jaw), legs×4 → hooves`.
- **`HediffDef`** — *всё* есть hediff (рана, потеря части, болезнь, имплант, можно и
беременность): `stages` с порогами по severity; стадия задаёт `capMods`
(offset/factor/setMax по capacity), `painOffset/painFactor`, `bleedRate`, для болезней —
параметры иммунитета.
- **`CapacityDef`** — общий каталог способностей строковыми id: `Consciousness, Moving,
Manipulation, Sight, Hearing, Breathing, BloodPumping, BloodFiltration, Digestion,
Metabolism` (расширяемо модом). Это **каталог**, а не обязательный набор: вид имеет
лишь те способности, в которые вкладываются части его `BodyDef` (у копытного оленя нет
`Manipulation` — некому вкладываться).
### Компонент
`Health : IComponent { HealthState State }` — managed-класс (как `Genome`):
`List<PartInstance>` (деф + текущий hp), `List<Hediff>` (severity, привязка к части,
иммунитет), кэш `Capacities`, `Pain`, `BloodLevel`, `Dead`.
> Контейнер растёт по фазам: **рождается в A4** с одним лишь `List<Hediff>` (под гон-хедиф
> `Rut`); части тела, кровь, capacities и `Dead` добавляются в A5, болезни/иммунитет — A6.
### Расчёт capacities (обобщённая дата-модель)
Способности зависят друг от друга, считаем в порядке зависимостей:
1. `BloodLevel` (0..1) ← запас минус кровопотеря;
2. `BloodPumping` ← сердце × BloodLevel; `Breathing` ← лёгкие × BloodLevel;
`BloodFiltration` ← печень/почки;
3. `Consciousness` ← мозг × BloodPumping × Breathing × (1 болевой штраф);
4. `Moving` ← ноги/позвоночник × Consciousness × BloodPumping; `Sight` ← глаза ×
Consciousness; `Digestion` и пр. — домножаются на вышестоящие.
Формула одна и общая: `capacity = Σ(вклады работающих частей × partHp/maxHp) ×
вышестоящие capacity`. **Без хардкода на каждую способность** — новую способность
вводит деф. Каждая capacity — множитель на действия: `Moving`→скорость, `Sight`→радиус
поиска, `Digestion`→скорость насыщения, `Consciousness`→интеллект и сам факт сознания.
### Кровь — как hediff (реалистичная модель RW)
Кровопотеря = hediff `BloodLoss` (severity 0..1). Кровоточащие раны поднимают её через
`bleedRate`; без кровотечения медленно рассасывается; severity = 1 → смерть; влияет на
`BloodPumping`/`Consciousness` через `capMods`. `GeneBloodVolume` масштабирует скорость
роста severity (больше крови → медленнее теряет).
### Болезни и иммунитет (гонка)
severity болезни растёт за день; параллельно растёт иммунитет (0..1) со скоростью из
стата (зависит от `BloodFiltration`, отдыха, сытости, `GeneImmunityGain`). Иммунитет→1
раньше = выздоровел; severity→летальный порог раньше = смерть. Голодный/раненый зверь
борется с болезнью медленнее.
### Смерть
Триггеры: разрушена `vital`-часть (мозг/сердце), `Consciousness ≤ порога`,
`BloodLevel = 0`, летальная severity болезни, старость (возрастные hediff: катаракта,
больная спина, деменция, закупорка артерий + шанс смерти от старости). На смерти:
стоп-ИИ, переход в труп.
### Тик
Медленный «health tick» раз в игровой час + событийный пересчёт при уроне.
Детерминизм: привязка к игровому времени, не к FPS.
### Источники урона в v1
**Почти никаких.** Строим весь каркас (части, кровь, capacities, hediff-framework, условия
смерти), но не вводим *травмирующих* источников. На старте (A1–A2) смерть только **от
старости**.
Порядок источников урона по фазам: **истощение/обезвоживание** — уже в **A3** (простое
правило «нужда=0 дольше N дней → смерть», замыкает контур ёмкости среды; в A5
переоформляется в hediff истощения) → **температура** (гипотермия/тепловой удар, hediff на
той же трапеции, что у растений) → **болезни** (A6) → **хищничество** (горизонт).
## Труп и разложение
Труп — отдельная сущность с `Corpse { RotDays; Stage }`. Конвейер (скорость зависит от
температуры/сезона — реюз климата):
```
Fresh ──→ Rotting ──→ Skeleton ──→ (исчезает)
│ │ │
│ разделка │ разделка └─ остаётся ProductBone
└──────────┴─→ ProductMeat (кол-во ∝ GeneBodySize) + опц. ProductLeather
```
- свежий/гниющий можно **разделать** → мясо (объём от размера тела) + шкура;
спрайт `Dessicated_*` (для оленя уже есть) на стадии Rotting;
- **скелет** — финальная стадия разложения (как просили, скелет — часть процесса):
мясо уже не получить, остаются кости (`ProductBone`);
- после скелета труп **исчезает в ничто**. На будущее — возврат плодородия в почву
(ниточка к растениям, опционально).
- мясо/кости/падаль → кормовая база будущих хищников и падальщиков.
Новые продукты: `ProductMeat`, `ProductBone`, `ProductLeather`.
**Спрайт скелета — нужен настоящий (решено).** В проекте костяных/скелетных текстур
нет (проверено) → это **ассет-задача**: создать/найти спрайт скелета (желательно
универсальный + по-видовой при наличии), путь вида `things/pawn/animal/deer/Skeleton_*`.
До появления ассета стадию Skeleton можно временно рисовать обработкой `Dessicated_*`,
но финальная версия требует своего спрайта.
## Мозг и интеллект
`GeneBrainSize` → признак `brainSize`. **Эффективный интеллект** = `brainSize ×
Consciousness` — т.е. повреждение/болезнь мозга, боль, кровопотеря динамически снижают
интеллект (сильная связка со здоровьем).
Интеллект **гейтит набор нужд и действий**. У `NeedDef`/действия поле `minBrain`:
активный набор особи = те, у кого `minBrain ≤ brainSize`. **Без enum** — шкала
непрерывная, тиры ниже лишь ярлыки для авторов контента (числа порогов — контент,
правятся модом). `brainSize` нормирован 0..1.
| Тир | `minBrain` | Кто | Что открывается |
|---|---|---|---|
| 1 — рефлекс | 0.00 | губки, простейшие | пассивный голод, реактивность |
| 2 — примитив | ~0.10 | насекомые, черви | голод; реактивное поведение |
| 3 — низший позвоночный | ~0.25 | рыбы, рептилии, амфибии | +жажда; простое избегание |
| 4 — зверь | ~0.45 | млекопитающие, птицы (**олень**, заяц, волк) | +сон, секс, Flee, SeekMate; **настроение**; факультативно стадность/память |
| 5 — умный (на будущее) | ~0.75 | приматы, врановые, люди | +социальные нужды, игра, любопытство, память о ресурсах, планирование, обучение, сложное настроение/отношения |
Олень = **тир 4**. Настроение, сон и брачное поведение включаются с тира 4.
## Настроение
`Mood` (0..1) собирается **из двух источников** (решено):
1. **Непрерывные состояния → в настроение напрямую** по кривой `ResponseCurve`:
уровни голода, жажды, усталости, боли (из здоровья), температуры вне комфорта.
Это не дискретные мысли, а текущий вклад нужд/состояния.
2. **События → `ThoughtDef { moodOffset, durationDays, trigger }`** (модель thoughts
RimWorld, данными): «дала потомство», «спарилась», «напилась после долгой жажды»,
«потеряла детёныша» — временный сдвиг настроения с затуханием.
Настроение есть только с тира 4 (зверь) — гейт интеллектом; у примитивных его нет.
Влияет на поведение (низкое → паника/бегство, не спаривается) и видно в инспекторе.
## Ориентировочные числа для оленя (старт)
Шкала времени мира: **год = 60 игровых дней**, сезон = 15 дней (для справки растения
живут ~160 дней). Числа — стартовая точка, балансируются при реализации; это значения
по умолчанию генома вида (`GenomeTemplate`), вокруг которых идёт разброс и мутации.
| Параметр | Ген/поле | Значение | Заметка |
|---|---|---|---|
| Размер тела | `GeneMaxBodySize` | ♀ 1.4 / ♂ 1.6 sizeCells | как в текущих `DeerDoe`/`DeerBuck` |
| Продолжительность жизни | `GeneLifespan` | ~360 дн (~6 лет) | дольше растений, видно несколько поколений |
| Взросление (→Adult) | `GeneMaturityAge` | ~90 дн (~1.5 года) | Baby 020, Juvenile 2090, Adult 90~290, Senior 290+ |
| Срок беременности | `GeneGestationDays` | ~30 дн (2 сезона) | гон осенью → роды весной |
| Помёт | `GeneLitterSize` | 1 (изредка 2) | K-стратег |
| Сезон гона | `GeneBreedingSeason` | осень | у зайца — круглый год |
| Размер мозга | `GeneBrainSize` | ~0.45 (тир 4) | открывает сон/секс/настроение |
| Объём крови | `GeneBloodVolume` | ~1.0 (×размер) | масштабирует скорость кровопотери |
| Скорость | `GeneMoveSpeed` | ~1.1–1.3 | олень быстрый |
| Голод | `NeedDef` decay | полный голод за ~1.5 дн | по Клайберу ∝ size^0.75 |
| Жажда | `NeedDef` decay | полная жажда за ~0.75 дн | быстрее голода |
| Сон | `NeedDef` decay | суточный цикл | спит в неактивную фазу |
## Идеи для реализма (бэклог)
Опционально, по мере фаз — каждая ложится на дата-модель:
- **Гон/брачный сезон** — хедиф `Rut`, навешиваемый в сезон, поднимает влечение (реализовано в A4).
- **Терморегуляция мехом** — `GeneInsulation` сдвигает температурную трапецию;
новорождённые мёрзнут сильнее.
- **Метаболизм по Клайберу** — расход ∝ size^0.75 (крупные едят больше абсолютно,
меньше на кг).
- **Циркадные ритмы** — дневные/ночные/сумеречные виды (`GeneActivityPattern`).
- **Стадность** — `GeneSociability`: стадные держатся группой (реюз `SeparationSystem`
+ когезия) → эмерджентные стада.
- **Сезонный окрас** — арктические лиса/заяц/волк белеют зимой (текстуры `*_arctic`
есть) — сезонный свитч спрайта/тинта.
- **Половой диморфизм** — рога/размер у самцов (ген, проявляется при XY).
- **r/K-стратегии** — выводятся из `litterSize/gestation/lifespan`.
- **Инбридинг-депрессия** (продвинутое) — родственное скрещивание снижает fitness
через накопление рецессивных аллелей.
- **Память о ресурсах/территория** (для умных) — водопои, кормовые места.
- **Возврат нутриентов** — полное разложение трупа повышает плодородие клетки.
## Новые типы дефов (для модеров)
| Тип | Назначение |
|---|---|
| `AnimalDef : PawnDef` (подтип) | вид: геном-шаблон (ссылки на гены), `BodyDef`, диета, текстуры по стадиям/полу, размеры. Наследник `PawnDef` — существующие 9 животных-`PawnDef` остаются валидными; поведение на компонентах, не на типе дефа |
| `BodyDef` | дерево частей тела с coverage/parent/maxHp/флагами/вкладами в capacities |
| `HediffDef` | раны/болезни/состояния: стадии, capMods, боль, кровотечение, иммунитет |
| `CapacityDef` | именованная способность (строковый id) |
| `NeedDef` | нужда: формула расхода, `minBrain`, привязка к действию |
| `ThoughtDef` | мысль настроения: offset, длительность, условие-триггер |
| `ProductDef` (есть) | + `ProductMeat`, `ProductBone`, `ProductLeather` |
CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип = класс + `RegisterType` в
`GameContent.Load` (см. [mods.md](mods.md)).
## Отбор, экология и наблюдаемость
Смысл генетики — чтобы за поколениями популяция **реально адаптировалась к климату
сида** (дрейф признаков под отбором). Чтобы это было видно (столп «наблюдаемость»):
- **Команда `popstats <species>`** — среднее и разброс ключевых признаков популяции +
счётчик поколений. Без неё отбор «есть, но невидим». Появляется, как только в мире
есть популяция (≈A1–A2), расширяется по мере добавления признаков.
- **Контур ёмкости среды (Лотка–Вольтерра через корм).** Даже без хищников травоядные
могут **выесть растения → голод → крах популяции → восстановление флоры → новый рост**.
Это бесплатная эмерджентность уже на A2, **если поедание реально уменьшает растения**.
Явно проверяем этот контур на A2 (поедание убавляет плод/растение, голод растёт при
нехватке корма). **Важно:** полный «крах» популяции требует **смерти от голода** — она
появляется в **A3** (простое правило). На A2 контур наблюдается частично (звери голодают
и тормозят, но не вымирают), полностью замыкается с A3.
## Сквозные требования (каждая фаза)
- **Детерминизм.** Любая случайность (спавн, выбор партнёра, мутации, выбор части под
урон, шанс инфекции) — из seeded `Random`, выведенного из сида мира (+ id сущности),
никогда `Random.Shared`. Один сид = воспроизводимый мир. Ничьи в пространственных
запросах («ближайший корм/вода/партнёр») разрешаются по id сущности, не по порядку обхода.
- **Сериализация — чек-лист по каждой фазе.** Каждая фаза сразу делает сейв/лоад своих
данных: A1 геном (уже умеет, geneId→Allele) → A3 стадия/возраст/пол → A4 беременность
(срок + **геном отца**) + список хедифов (гон `Rut`) → A5 здоровье (части тела, кровь,
capacities) + труп (стадия/таймер разложения) → A8 настроение/мысли. Пустой/старый
сейв — регенерируется, не крашит.
- **Sim/presentation split.** Системы симуляции пишут только компоненты; спрайт/тинт
(пол, стадия, мех, гниль) ставит `AppearanceSystem`. Никаких draw/UI из симуляции.
## План реализации (фазы A1–A8)
По аналогии с фазами генов G1–G5. Каждая фаза — отдельный коммит, дев-команда для
проверки, проверка через `--check-content` (где можно), и пункты сквозных требований выше.
**Вкладки инспектора добавляются по мере появления данных:** needs@A2, health@A5, mood@A8.
1. **A1 — фундамент.** `AnimalDef` + животные гены + `AnimalPhenotype`/`AnimalOrganism`
+ per-species `GenomeTemplate` + спавн оленя в `WorldScene` + рендер + команда
`animal <species>`.
2. **A2 — нужды + ИИ (выбор + исполнение).** `AnimalNeeds` + `AnimalDecisionSystem`
(выбор) + **слой исполнения** (Eat/Drink/Sleep: найти→дойти→выполнить→удовлетворить) +
еда-растения / вода-террейн / сон по фазе суток + `AppearanceSystem`. **Проверить
контур ёмкости среды** (поедание убавляет растения → колебания). Команда `popstats`.
3. **A3 — жизненный цикл.** Стадии Baby→Adult→Senior, ген пола (XX/XY, генерация без
YY), спрайт по стадии/полу, **простая смерть по старости и от голода/жажды (деспавн)**
— последняя замыкает контур ёмкости среды.
4. **A4 — размножение.** Нужда `Mating` + **лёгкий hediff-каркас** + гон-хедиф `Rut`
(сезон) + исполнение `Mate` → `Pregnant` → роды через `Genome.Breed`.
5. **A5 — здоровье I.** `BodyDef`, части тела, кровь (hediff), capacities, **апгрейд
смерти**: через здоровье + **труп** с разложением до скелета (вместо деспавна A3).
6. **A6 — здоровье II.** Болезни/`HediffDef` с иммунитетом, боль.
7. **A7 — мозг/интеллект.** Ген→интеллект→гейт нужд/действий + обратная связь от
повреждения мозга.
8. **A8 — настроение.** `Mood` + `ThoughtDef` + вкладки инспектора (needs/health/mood —
`InspectPanel` уже умеет вкладки overview/genes/products).
## После A8 — экологический горизонт
То, ради чего вся глубина: превращает «зоопарк» в живую экосистему. Каждая фича опирается
на уже построенные системы.
> **ГОТОВО — хищный кластер (часть 1): диета-гены + охота + урон + бегство.** Диета теперь
> ДАННЫЕ-через-ГЕНЫ: `GeneHerbivory`/`GeneCarnivory`/`GeneOmnivory` → признаки
> `herbivory/carnivory/omnivory`; рацион выводит `AnimalFactory.Diet` (ест растения, если
> max(herb,omni) ≥ 0.5; ест мясо, если max(carn,omni) ≥ 0.5 — всеядность включает оба; откат
> на `AnimalDef.Diet` у видов без генов диеты). Новые виды: **Wolf** (хищник, охотится) и
> **WildBoar** (всеядный); у оленя добавлен `GeneHerbivory`. Действие `Hunt`
> (`AnimalDecisionSystem` ищет ближайший труп-падаль ИЛИ живую добычу не крупнее себя;
> `AnimalActionSystem.Hunt` ведёт к цели и при контакте кусает или ест труп). **Первый
> травматический урон**: `HealthState.ApplyInjury` (урон части тела по весу попадания + острая
> кровопотеря + хедиф `Bleeding`); `Bleeding` (`bloodLossPerDay`) точит кровь в
> `AnimalHealthSystem` (+восстановление крови в покое), кровь на нуле → смерть → труп → падаль.
> Боевые параметры — данные на `AnimalDef` (`attackDamage/attackBleed/attackBloodLoss/
> attackRangeCells`). **Бегство**: `AnimalActions.Flee` — жертва, заметившая хищника не мельче
> себя в радиусе восприятия, убегает (приоритет над нуждами); сам хищник бежит лишь от заметно
> более крупного мясоеда. Общий путь смерти — `AnimalFactory.Die`. Консоль: `diet [species]`;
> `--check-content` печатает роль каждого вида. **Не покрыто (горизонт):** падальщики как
> отдельная роль, удар по настроению от страха, снижение страха стадом, спрайт скелета.
- **Хищный кластер.** Хищничество (волк→олень): диета = живая добыча; действие `Hunt`
(выследить → преследовать → атака → **урон через здоровье A5** → добить → съесть труп).
Это **первый «активный» (травмирующий) источник урона** для системы здоровья и
полноценная Лотка–Вольтерра с хищником. Рядом: **падальщики** (едят трупы Fresh/Rotting)
и **угроза/бегство** (хищник в радиусе `Sight` → страх → действие `Flee`, удар по
настроению; стадо снижает страх).
- **Родительская опека / импринтинг.** Детёныш ходит за матерью, голод гасится близостью
к лактирующей матери до ювенильной стадии; мать ведёт/защищает. Реализм + детёныши не
мрут от голода в одиночку.
- **Эпидемии.** Болезни (A6) распространяются по контакту/плотности → плотностно-зависимый
регулятор популяции (вспышки-крахи). Исход решает иммунитет (`GeneImmunityGain`).
- **Миграция.** При локальном истощении корма / некомфортном климате — уход к лучшим
клеткам (концепт: «голод+неурожай→миграция»). Эмерджентно из нужд + пространственного
поиска с большим радиусом.
Сюда же тяготеют пункты бэклога: половой отбор (выбор партнёра), сезонный окрас,
инбридинг-депрессия, возврат нутриентов в почву.
## Открытые вопросы
- Конкретный список `ThoughtDef`-событий для оленя (набор и величины offset/длительности)
— детализируется при реализации A8.
- Ассет: создать/найти спрайт скелета (`things/pawn/animal/deer/Skeleton_*`) к фазе A5.
-84
View File
@@ -1,84 +0,0 @@
# Растения как экосистема
Дизайн системы растений: рост зависит от среды, среда описана факторами, реакция растения на
факторы задаётся **генами**, а размножение передаёт гены потомкам с мутацией — появляется
естественный отбор. Документ — единый ориентир для фаз A–D; детали каждой фазы уточняются её
отдельным планом.
## Модель роста
Скорость роста растения в данный момент:
```
скорость = база × пригодность(свет) × пригодность(температура) × пригодность(плодородность)
```
Каждая «пригодность» — число в [0..1] из кривой отклика (движковый `ResponseCurve`, модуль
`MrGameEng.AI`) с **оптимумом** и **шириной толерантности**, которые берутся из генов растения.
Растение вне своих оптимумов растёт медленно или стоит; в своих — быстро. Так все факторы среды
сводятся к одному механизму, а различие видов/особей — целиком в генах.
Фактор «возраст/стадия» остаётся как сейчас (`PlantGrowthStage` в дефах): пригодности умножают
прирост `AgeDays`, стадии переключают спрайт/размер по накопленному возрасту.
## Факторы среды
- **Свет** — амбиент день/ночь (`MrGameEng.Lighting.DayNight`), сэмплируемый в точке
(`SampleAt(world)`); глобальный в фазе A, локальный (с тенями) в фазе D.
- **Температура** — непрерывная, из климата (`MrGameEng.Core.Climate`): сезонный синус по дню года
+ суточные колебания (холоднее ночью).
- **Плодородность** — пока константа типа террейна (`TerrainDef.Fertility`); позже возможна
по-клеточная почва с истощением/восстановлением.
## Гены (диплоидный геном, гибридная экспрессия)
Каждое растение несёт **геном**: по паре аллелей на ген. Экспрессия гибридная:
- **Числовые гены** (оптимум света, толерантность к свету, оптимум температуры, толерантность,
потребность в плодородности, макс. размер, базовая скорость, продолжительность жизни, дальность
расселения, плодовитость, темп мутаций, способность к самоопылению) — фенотип = **смешение**
(среднее двух аллелей). Реализм отбора: промежуточные значения, плавный дрейф.
- **Дискретные гены** (например, цвет/морфа, наличие признака) — **доминант/рецессив**: рецессивный
фенотип виден только в гомозиготе (aa).
Наследование (мейоз): каждый родитель отдаёт по одному случайному аллелю на ген; потомок получает
по одному от каждого. Затем — мутация (с вероятностью из гена темпа мутаций сдвигаем числовой
аллель/переключаем дискретный). Всё детерминировано сидом мира.
Стартовая популяция: геномы генерируются из сида (вид задаёт базовый геном и разброс аллелей в
дефе; особи отклоняются).
## Размножение (полуреалистичное)
- **Перекрёстное опыление (приоритет):** зрелое растение ищет зрелого соседа того же вида в радиусе
(ген дальности; запрос через spatial-hash движка). Есть партнёр → семя с геномом от двух
родителей (Мендель + мутация).
- **Самоопыление (запас):** нет партнёра и ген «способность к самоопылению» позволяет → семя из
одного родителя (само-скрещивание аллелей + мутация).
- **Расселение:** семя падает в клетку в радиусе расселения; прорастает, если клетка подходит
(суша, не занято сверх лимита, пригодность не нулевая).
- **Лимит плотности:** не больше N растений на клетку/площадь — иначе взрыв численности.
- **Старение/смерть:** возраст превышает ген жизни → смерть; или длительный стресс (пригодность
около нуля) → гибель. Смерть освобождает место — баланс с размножением и основа отбора.
## Производительность и сейв
- Представление — **по сущности на растение** (видно в рендере и ECS-инспекторе, индивидуальные
гены). Защита от взрыва: лимит плотности, смерть, разумные радиусы, проверки размножения по
таймеру/сезону (не каждый кадр на каждое растение), spatial-hash для соседей.
- **Персист:** геномы, позиции, возраст и стадия растений сохраняются в `WorldSave` (мир больше не
воспроизводится только из сида — эволюция переживает загрузку).
- **Детерминизм:** мутации/расселение/опыление — на сидированном `Random`, как в остальной симуляции.
## Фазы реализации
- **A — климат и день/ночь (движок).** `Climate` (температура/сезон поверх `Calendar`),
`DayNight` (амбиент, `Renderer2D.AmbientLight` на World-слоях, сэмплируемый `SampleAt`),
затемнение мира ночью, HUD: сезон/температура/время. Фундамент для влияния среды на рост.
- **B — гены и рост по пригодностям (игра).** Компонент `PlantGenome`, расчёт пригодностей
(свет из `DayNight.SampleAt`, температура из `Climate`, плодородность из террейна), замена
плоского `GrowthRate`. Геном виден/правится в инспекторе.
- **C — размножение и смерть (игра).** Перекрёстное/самоопыление, расселение, лимит плотности,
старение/стресс-смерть, персист популяции в сейв.
- **D — полноценное 2D-освещение (движок).** Точечные источники + тени (окклюзия от препятствий);
`DayNight.SampleAt` начинает возвращать локальную освещённость, и затенённые растения растут хуже.
+1 -1
Submodule engine updated: d044cafad9...96e19c7c61
+3 -1
View File
@@ -9,7 +9,9 @@ $Project = Join-Path $Root "src\LittleSim\LittleSim.csproj"
Push-Location $Root
try {
dotnet build LittleSim.sln -c $Configuration
# Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с
# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты).
dotnet build $Project -c $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
+3 -1
View File
@@ -18,5 +18,7 @@ done
cd "$ROOT"
dotnet build LittleSim.sln -c "$CONFIGURATION"
# Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с
# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты).
dotnet build src/LittleSim/LittleSim.csproj -c "$CONFIGURATION"
dotnet run --project src/LittleSim/LittleSim.csproj -c "$CONFIGURATION" --no-build "$@"
+14 -5
View File
@@ -16,24 +16,33 @@ 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!;
private WorldViewScene _scene = null!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
public LittleSimWebGame(Uri server)
/// <summary>Игра-наблюдатель: мира не создаёт, ждёт <see cref="Connect"/> из UI.</summary>
public LittleSimWebGame()
{
_server = server;
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}
/// <summary>Состояние соединения со сценой-наблюдателем — читает оверлей подключения.</summary>
public ConnectionStatus Status => _scene?.Status ?? ConnectionStatus.Idle;
/// <summary>Число реплицированных жителей — для статуса в оверлее.</summary>
public int EntityCount => _scene?.EntityCount ?? 0;
/// <summary>Подключиться к серверу <paramref name="server"/> (вызывается из Blazor-оверлея).</summary>
public void Connect(Uri server) => _scene?.Connect(server);
/// <inheritdoc />
protected override void Initialize()
{
base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
_scene = new WorldViewScene(() => _spriteBatch, () => _pixel);
Context.Scenes.Switch(_scene);
}
/// <inheritdoc />
+29 -20
View File
@@ -1,20 +1,29 @@
@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>
@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>
<div class="connect-bar">
<span class="title">LittleSim</span>
<input class="server" placeholder="ws://host:9050"
@bind="_serverAddress" @bind:event="oninput"
@onkeydown="OnAddressKey" />
<button class="connect" @onclick="Connect">Подключиться</button>
<span class="status @StatusClass">@StatusText</span>
</div>
+80 -8
View File
@@ -1,7 +1,7 @@
using System;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
namespace LittleSim.Web.Pages
{
@@ -10,7 +10,12 @@ namespace LittleSim.Web.Pages
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private Game? _game;
private LittleSimWebGame? _game;
private string _serverAddress = "";
private Uri? _pendingServer; // запрошен Connect до создания игры — подключим в первом тике
private ConnectionStatus _lastStatus = ConnectionStatus.Idle;
private int _lastCount = -1;
private int _frame;
protected override void OnAfterRender(bool firstRender)
{
@@ -18,25 +23,92 @@ namespace LittleSim.Web.Pages
if (firstRender)
{
_serverAddress = DefaultServerAddress();
StateHasChanged();
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
}
}
// Гонится из requestAnimationFrame (index.html). Игру создаём лениво в первом тике —
// мира она не создаёт, лишь ждёт адрес сервера из формы.
[JSInvokable]
public void TickDotNet()
{
if (_game == null)
{
_game = new LittleSimWebGame(ResolveServerUri());
_game = new LittleSimWebGame();
_game.Run();
}
_game.Tick();
if (_pendingServer is not null)
{
_game.Connect(_pendingServer);
_pendingServer = null;
}
// Перерисовываем оверлей только при смене статуса (или изредка — чтобы освежить счётчик),
// а не каждый кадр rAF.
var status = _game.Status;
var count = _game.EntityCount;
if (
status != _lastStatus
|| (status == ConnectionStatus.Connected && count != _lastCount && _frame % 15 == 0)
)
{
_lastStatus = status;
_lastCount = count;
InvokeAsync(StateHasChanged);
}
_frame++;
}
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию —
// хост самой страницы на порту LittleSim.Server.
private Uri ResolveServerUri()
private void OnAddressKey(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
Connect();
}
}
private void Connect()
{
if (!Uri.TryCreate(_serverAddress?.Trim(), UriKind.Absolute, out var uri))
{
return; // адрес не похож на ws://… — игнорируем
}
if (_game is not null)
{
_game.Connect(uri);
}
else
{
_pendingServer = uri;
}
}
private string StatusText =>
_lastStatus switch
{
ConnectionStatus.Connecting => "подключение…",
ConnectionStatus.Connected => $"подключено · {_lastCount} жит.",
ConnectionStatus.Reconnecting => "переподключение…",
_ => "не подключено",
};
private string StatusClass =>
_lastStatus switch
{
ConnectionStatus.Connected => "ok",
ConnectionStatus.Connecting or ConnectionStatus.Reconnecting => "warn",
_ => "idle",
};
// Адрес по умолчанию: ?server=ws://host:port из URL страницы, иначе хост страницы на порту сервера.
private string DefaultServerAddress()
{
var page = new Uri(Navigation.Uri);
var query = page.Query.TrimStart('?');
@@ -45,7 +117,7 @@ namespace LittleSim.Web.Pages
var separator = pair.IndexOf('=');
if (separator > 0 && pair[..separator] == "server")
{
return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..]));
return Uri.UnescapeDataString(pair[(separator + 1)..]);
}
}
@@ -54,7 +126,7 @@ namespace LittleSim.Web.Pages
Scheme = "ws",
Host = page.Host,
Port = WebNetSchema.DefaultPort,
}.Uri;
}.Uri.ToString();
}
}
}
+72
View File
@@ -0,0 +1,72 @@
.connect-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
box-sizing: border-box;
background: rgba(12, 16, 24, 0.82);
border-bottom: 1px solid rgba(108, 198, 255, 0.25);
color: #d8e0ea;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
z-index: 10;
}
.connect-bar .title {
font-weight: 600;
color: #6cc6ff;
letter-spacing: 0.02em;
}
.connect-bar .server {
flex: 0 1 320px;
min-width: 0;
padding: 5px 9px;
background: #0c1018;
border: 1px solid #2a3850;
border-radius: 4px;
color: #e6edf6;
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 13px;
}
.connect-bar .server:focus {
outline: none;
border-color: #6cc6ff;
}
.connect-bar .connect {
padding: 5px 14px;
background: #1c2c44;
border: 1px solid #3a567f;
border-radius: 4px;
color: #d8e0ea;
font-size: 13px;
cursor: pointer;
}
.connect-bar .connect:hover {
background: #25395a;
border-color: #6cc6ff;
}
.connect-bar .status {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.connect-bar .status.ok {
color: #7fdca0;
}
.connect-bar .status.warn {
color: #f0c060;
}
.connect-bar .status.idle {
color: #8a96a6;
}
+93 -17
View File
@@ -9,31 +9,74 @@ using MrGameEng.Net;
namespace LittleSim.Web;
/// <summary>Состояние соединения веб-клиента — его читает оверлей подключения (Blazor).</summary>
public enum ConnectionStatus
{
/// <summary>Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться».</summary>
Idle,
/// <summary>Идёт первая попытка подключения.</summary>
Connecting,
/// <summary>Соединение установлено, снапшоты приходят.</summary>
Connected,
/// <summary>Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф).</summary>
Reconnecting,
}
/// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
/// кадра, усталость затемняет квадратик жителя.
/// Браузерный клиент мира LittleSim: по команде <see cref="Connect"/> подключается к
/// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой
/// EntityStore и рисует жителей через KNI SpriteBatch (WebGL). Мира не создаёт — только
/// наблюдает: симуляция целиком на сервере, сюда приезжают лишь компоненты схемы (позиция +
/// потребности). Позиции сглаживаются до частоты кадра, усталость затемняет квадратик. При
/// обрыве переподключается сам с экспоненциальным бэкоффом, очищая устаревшие сущности.
/// </summary>
public sealed class WorldViewScene : Scene
{
private readonly Uri _server;
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
private const float MaxBackoffSeconds = 8f;
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
private Uri? _server; // целевой сервер; null — пользователь ещё не подключался
private double _nextAttemptAt;
private int _attempt;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
/// <summary>Текущее состояние соединения — для оверлея подключения.</summary>
public ConnectionStatus Status { get; private set; } = ConnectionStatus.Idle;
/// <summary>Сколько реплицированных жителей сейчас в мире клиента.</summary>
public int EntityCount => _replication?.EntityCount ?? 0;
/// <summary>Сцена-наблюдатель; сервер задаётся позже через <see cref="Connect"/>.</summary>
public WorldViewScene(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
{
_server = server;
_spriteBatch = spriteBatch;
_pixel = pixel;
}
/// <summary>
/// Подключиться к <paramref name="server"/> (ws:// или wss://). Сбрасывает прежнее
/// соединение и счётчик попыток — подключение начнётся в ближайшем тике. Дальше клиент сам
/// переподключается к этому адресу при обрывах.
/// </summary>
public void Connect(Uri server)
{
_connection?.Close();
_connection = null;
_replication?.Clear();
_server = server;
_attempt = 0;
_nextAttemptAt = 0f;
Status = ConnectionStatus.Connecting;
}
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
private struct NetLerp : IComponent
{
@@ -51,35 +94,68 @@ public sealed class WorldViewScene : Scene
UpdateSystems.Add(new NetSmoothingSystem());
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
Log.Info($"LittleSim.Web: connecting to {_server}…");
_connecting = WebSocketClient.ConnectAsync(_server);
Log.Info("LittleSim.Web ready — awaiting connect");
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_server is null)
{
return; // адрес ещё не задан — ждём команды Connect из оверлея
}
var now = Context.Clock.UnscaledTotalTime;
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
if (finished.IsCompletedSuccessfully)
{
_connection = finished.Result;
_replication.Clear(); // сбрасываем устаревшие сущности перед свежим полным снапшотом
_attempt = 0;
Status = ConnectionStatus.Connected;
Log.Info($"Connected to {_server}");
}
else
{
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
ScheduleReconnect(now);
}
else
{
_connection = finished.Result;
Log.Info($"Connected to {_server}");
}
}
// Обрыв установленного соединения: чистим и уходим в переподключение.
if (_connection is { IsOpen: false })
{
_connection = null;
_replication.Clear();
ScheduleReconnect(now);
}
if (_connection is not null)
{
_replication.Pump(_connection);
}
else if (_connecting is null && now >= _nextAttemptAt)
{
Status = _attempt == 0 ? ConnectionStatus.Connecting : ConnectionStatus.Reconnecting;
_connecting = WebSocketClient.ConnectAsync(_server);
}
}
// Экспоненциальный бэкофф: 1, 2, 4, 8, 8, … секунд между попытками.
private void ScheduleReconnect(double now)
{
var delay = MathF.Min(MaxBackoffSeconds, 1f * (1 << Math.Min(_attempt, 3)));
_attempt++;
_nextAttemptAt = now + delay;
Status = ConnectionStatus.Reconnecting;
}
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
+6
View File
@@ -28,6 +28,12 @@ public sealed class GameSettings
/// <summary>Общая громкость 0..1.</summary>
public float Volume { get; set; } = 1f;
/// <summary>Масштаб интерфейса (1 — обычный); применяется к Myra-десктопу всех сцен.</summary>
public float UiScale { get; set; } = 1f;
/// <summary>Режим разработчика: открывает дев-консоль (backtick) и дев-спавнер. По умолчанию включён.</summary>
public bool DeveloperMode { get; set; } = true;
}
/// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary>
+148
View File
@@ -62,6 +62,145 @@ public sealed class PawnSave
public float ChangeIn { get; set; }
}
/// <summary>Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.</summary>
public sealed class HediffSave
{
/// <summary>Имя дефа хедифа (гон/болезнь).</summary>
public string DefName { get; set; } = "";
/// <summary>Текущая тяжесть.</summary>
public float Severity { get; set; }
/// <summary>Набранный иммунитет [0,1].</summary>
public float Immunity { get; set; }
}
/// <summary>Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания.</summary>
public sealed class ThoughtSave
{
/// <summary>Имя дефа мысли.</summary>
public string DefName { get; set; } = "";
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays { get; set; }
}
/// <summary>
/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и
/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и
/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние)
/// переживала сейв/лоад, а не пересыпалась заново из сида.
/// </summary>
public sealed class AnimalSave
{
/// <summary>Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Номер поколения (для popstats/дрейфа).</summary>
public int Generation { get; set; }
/// <summary>Текущая стадия роста.</summary>
public int Stage { get; set; }
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays { get; set; }
/// <summary>Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
/// <summary>Значения нужд по индексам NeedSet.</summary>
public float[] Needs { get; set; } = [];
/// <summary>Накоплено дней истощения.</summary>
public float Starve { get; set; }
/// <summary>Уровень крови [0,1].</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>HP частей тела по порядку анатомии вида (пусто — у вида нет тела).</summary>
public float[] PartHp { get; set; } = [];
/// <summary>Активные хедифы (гон/болезни).</summary>
public List<HediffSave> Hediffs { get; set; } = [];
/// <summary>Текущее настроение [0,1].</summary>
public float Mood { get; set; }
/// <summary>Активно ли настроение (тир 4+).</summary>
public bool MoodActive { get; set; }
/// <summary>Активные событийные мысли.</summary>
public List<ThoughtSave> Thoughts { get; set; } = [];
/// <summary>Беременна ли самка.</summary>
public bool Pregnant { get; set; }
/// <summary>Геном отца (если беременна) — «слепок» на момент зачатия.</summary>
public Dictionary<string, Allele> FatherGenome { get; set; } = new();
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays { get; set; }
/// <summary>Размер помёта.</summary>
public int Litter { get; set; }
/// <summary>Уровни навыков [0..1] по индексам SkillSet.</summary>
public float[] SkillLevels { get; set; } = [];
/// <summary>Страсть к навыкам (0/1/2) по индексам SkillSet.</summary>
public int[] SkillPassions { get; set; } = [];
}
/// <summary>Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо.</summary>
public sealed class CorpseSave
{
/// <summary>Имя дефа вида — по нему берётся спрайт трупа.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays { get; set; }
/// <summary>Размер тела (для масштаба спрайта).</summary>
public float BodySize { get; set; }
/// <summary>Доступное мясо.</summary>
public float Meat { get; set; }
}
/// <summary>Сериализуемое яйцо (тип рождения «яйцекладка»): вид, позиция, геном детёныша, поколение, инкубация.</summary>
public sealed class EggSave
{
/// <summary>Имя дефа вида — по нему берётся индекс/спрайт/тело при вылуплении.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Поколение будущего детёныша.</summary>
public int Generation { get; set; }
/// <summary>Остаток инкубации в игровых днях.</summary>
public float IncubateDays { get; set; }
/// <summary>Геном будущего детёныша (уже скрещён на момент кладки).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
}
/// <summary>
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -99,6 +238,15 @@ public sealed class WorldSave
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> Plants { get; set; } = [];
/// <summary>Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность).</summary>
public List<AnimalSave> Animals { get; set; } = [];
/// <summary>Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).</summary>
public List<CorpseSave> Corpses { get; set; } = [];
/// <summary>Снимок яиц (вид, позиция, геном детёныша, поколение, инкубация).</summary>
public List<EggSave> Eggs { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() =>
new()
+253
View File
@@ -0,0 +1,253 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Content;
/// <summary>
/// Готовая (resolved) таблица животных для сцены: по каждому <see cref="AnimalDef"/> — загруженный
/// регион атласа и набор генов вида (<see cref="GenomeTemplate"/>) для генерации особей. Строится
/// один раз при загрузке сцены (нужны атлас и устройство), как <see cref="PlantSet"/>. Системы и
/// фабрика индексируются в неё без словарей. Несёт направленные спрайты (восток/север/юг + флип) по
/// полу/стадии, спрайты трупа/яйца, анатомию, шаблон генома и предпосчёты для горячих путей.
/// </summary>
public sealed class AnimalSet
{
/// <summary>Направленные спрайты одной «внешности» (пол/стадия): восток, север, юг. Запад = зеркало
/// востока (флип по X). Север/юг берутся по конвенции имени (_east→_north/_south), иначе = восток.</summary>
public readonly record struct Directional(
Texture2DRegion East,
Texture2DRegion North,
Texture2DRegion South
);
/// <summary>Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи.</summary>
public sealed class Species
{
/// <summary>Исходный деф животного.</summary>
public required AnimalDef Def { get; init; }
/// <summary>Направленные спрайты взрослой самки (она же общий/fallback).</summary>
public required Directional FemaleDirs { get; init; }
/// <summary>Направленные спрайты взрослого самца (fallback — самка).</summary>
public required Directional MaleDirs { get; init; }
/// <summary>Направленные спрайты детёныша/подростка (fallback — самка).</summary>
public required Directional BabyDirs { get; init; }
/// <summary>Спрайт трупа (fallback — самка, восток).</summary>
public required Texture2DRegion Corpse { get; init; }
/// <summary>Спрайт яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт-точка.</summary>
public required Texture2DRegion Egg { get; init; }
/// <summary>Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа.</summary>
public required AnimalStageDef[] Stages { get; init; }
/// <summary>Анатомия вида (части тела) или null, если вид без тела.</summary>
public required BodyDef? Body { get; init; }
/// <summary>Набор генов вида: базовые значения для генерации особи.</summary>
public required GenomeTemplate Template { get; init; }
/// <summary>Эталонный размер тела вида (база GeneMaxBodySize) — предпосчитан для горячих путей
/// (масса/метаболизм Клайбера каждый кадр), чтобы не делать строковый словарный лукап в цикле.</summary>
public required float RefBodySize { get; init; }
/// <summary>Направленные спрайты для стадии/пола: детёныш → BabyDirs; иначе по полу (самец/самка).</summary>
public Directional LookDirs(int stage, bool isMale) =>
string.Equals(Stages[stage].Texture, "baby", StringComparison.Ordinal) ? BabyDirs
: isMale ? MaleDirs
: FemaleDirs;
}
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
private static readonly AnimalStageDef[] DefaultStages =
[
new()
{
Name = "Baby",
EnterAt = 0f,
RelativeTo = "maturity",
Scale = 0.45f,
Texture = "baby",
},
new()
{
Name = "Juvenile",
EnterAt = 0.22f,
RelativeTo = "maturity",
Scale = 0.7f,
Texture = "baby",
},
new()
{
Name = "Adult",
EnterAt = 1f,
RelativeTo = "maturity",
Scale = 1f,
Texture = "adult",
Adult = true,
},
new()
{
Name = "Senior",
EnterAt = 0.8f,
RelativeTo = "lifespan",
Scale = 0.92f,
Texture = "adult",
Adult = true,
},
];
private readonly Species[] _species;
private readonly Dictionary<AnimalDef, int> _index = new();
/// <summary>Общий реестр генов (по id) для скрещивания и вычисления признаков животных.</summary>
public IReadOnlyDictionary<string, GeneDef> GeneRegistry { get; }
/// <summary>Строит таблицу из всех (не-abstract) <see cref="AnimalDef"/> мода.</summary>
public AnimalSet(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<AnimalDef>().ToArray();
_species = new Species[defs.Length];
for (var i = 0; i < defs.Length; i++)
{
var def = defs[i];
var femaleKey = def.Texture;
var maleKey = string.IsNullOrEmpty(def.MaleTexture) ? femaleKey : def.MaleTexture;
var babyKey = string.IsNullOrEmpty(def.BabyTexture) ? femaleKey : def.BabyTexture;
_species[i] = new Species
{
Def = def,
FemaleDirs = LoadDirs(atlases, device, femaleKey),
MaleDirs = LoadDirs(atlases, device, maleKey),
BabyDirs = LoadDirs(atlases, device, babyKey),
Corpse = string.IsNullOrEmpty(def.CorpseTexture)
? atlases.GetRegion(device, femaleKey)
: atlases.GetRegion(device, def.CorpseTexture),
Egg = string.IsNullOrEmpty(def.EggTexture)
? atlases.GetRegion(device, femaleKey)
: atlases.GetRegion(device, def.EggTexture),
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes),
RefBodySize =
def.Genome.TryGetValue("GeneMaxBodySize", out var refSize) && refSize > 0f
? refSize
: 1f,
};
_index[def] = i;
}
}
// Грузит направленный набор по восточному ключу: север/юг — по конвенции имени (_east→_north/_south),
// если такой регион есть в атласе; иначе откат на восток (вид с одним спрайтом просто не разворачивается
// вверх/вниз, но влево/вправо — флипом востока). Запад всегда = зеркало востока.
private static Directional LoadDirs(ModAtlases atlases, GraphicsDevice device, string eastKey)
{
var east = atlases.GetRegion(device, eastKey);
return new Directional(
east,
TryDir(atlases, device, eastKey, "_north", east),
TryDir(atlases, device, eastKey, "_south", east)
);
}
private static Texture2DRegion TryDir(
ModAtlases atlases,
GraphicsDevice device,
string eastKey,
string suffix,
Texture2DRegion fallback
) =>
eastKey.Contains("_east", StringComparison.Ordinal)
&& atlases.TryGetRegion(
device,
eastKey.Replace("_east", suffix, StringComparison.Ordinal),
out var region
)
? region
: fallback;
/// <summary>Число видов в таблице.</summary>
public int Count => _species.Length;
/// <summary>Вид по индексу.</summary>
public Species this[int index] => _species[index];
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(AnimalDef def) => _index[def];
/// <summary>
/// Генерирует геном новой особи-основателя вида: аллели из шаблона + явно назначенный пол
/// (XX самка / XY самец, 50/50) — чтобы не возник невозможный YY (см. Defs/Genes/Animal.json GeneSex).
/// Скрещивание (A4) наследует пол само (мать XX отдаёт только X).
/// </summary>
public Genome GenerateGenome(int species, Random random)
{
var genome = _species[species].Template.Generate(random);
genome["GeneSex"] = random.NextSingle() < 0.5f ? new Allele(0f, 0f) : new Allele(0f, 1f);
return genome;
}
/// <summary>
/// ОСНОВА создания гибрида (полноценная команда/UI — позже): геном помеси, привязанный к базовому виду
/// <paramref name="baseSpecies"/> (его тело/спрайт/набор генов), но с общими генами, скрещёнными с
/// <paramref name="otherSpecies"/> (по одной аллели-гамете от каждого родителя, как при размножении).
/// Пол берётся от базового вида; гены, которых нет у базового, игнорируются (его тело их не читает).
/// Сущность создаётся обычной <see cref="Sim.AnimalFactory.Create"/> с этим геномом и базовым видом —
/// то есть «создать любой вид» = Create(вид, GenerateGenome), «любой гибрид» = Create(вид, HybridGenome).
/// </summary>
public Genome HybridGenome(int baseSpecies, int otherSpecies, Random random)
{
var hybrid = GenerateGenome(baseSpecies, random);
var other = _species[otherSpecies].Template.Generate(random);
foreach (var (geneId, allele) in other.ToDictionary())
{
if (string.Equals(geneId, "GeneSex", StringComparison.Ordinal) || !hybrid.Has(geneId))
{
continue; // пол — от базового вида; чужие гены базовое тело не читает
}
var mine = hybrid[geneId];
hybrid[geneId] = new Allele(
random.NextSingle() < 0.5f ? mine.A : mine.B,
random.NextSingle() < 0.5f ? allele.A : allele.B
);
}
return hybrid;
}
// Строит шаблон генома вида из ДАННЫХ (geneId→base из Defs/Animals/) generically: разброс берётся из
// самого гена. Состав открыт — модер добавляет ген строкой JSON, без правки кода. Дискретные гены
// (база/разброс не нужны — варианты из GeneDef) тоже поддержаны.
private static GenomeTemplate BuildTemplate(
IReadOnlyDictionary<string, float> genome,
IReadOnlyDictionary<string, GeneDef> genes
)
{
var entries = new List<GenomeTemplate.Entry>(genome.Count);
foreach (var (geneId, baseValue) in genome)
{
if (!genes.TryGetValue(geneId, out var gene))
{
throw new KeyNotFoundException(
$"Animal genome references unknown gene '{geneId}' — check Defs/Genes/."
);
}
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
return new GenomeTemplate(entries);
}
}
+7
View File
@@ -63,6 +63,13 @@ public sealed class GameContent
defs.RegisterType<ProductDef>("Product");
defs.RegisterType<PlantDef>("Plant");
defs.RegisterType<PawnDef>("Pawn");
defs.RegisterType<AnimalDef>("Animal");
defs.RegisterType<HediffDef>("Hediff");
defs.RegisterType<NeedDef>("Need");
defs.RegisterType<ThoughtDef>("Thought");
defs.RegisterType<BodyDef>("Body");
defs.RegisterType<SkillDef>("Skill");
defs.RegisterType<DevToolDef>("DevTool");
defs.RegisterType<WorldPresetDef>("WorldPreset");
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+284 -8
View File
@@ -14,7 +14,7 @@ public sealed class ScatterEntry
public List<string> Options { get; init; } = [];
}
/// <summary>Тип клетки рельефа (Defs/terrain.json): порог высоты, цвет, поверхность, скаттер растений.</summary>
/// <summary>Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений.</summary>
public sealed class TerrainDef : Def
{
/// <summary>Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты).</summary>
@@ -27,7 +27,7 @@ public sealed class TerrainDef : Def
/// <summary>Суша: здесь появляются жители, животные и растения.</summary>
public bool IsLand { get; init; }
/// <summary>Загораживает свет (горы) — базовый окклюдер для лайтмапа/теней.</summary>
/// <summary>Светонепроницаемый террейн — базовый окклюдер лайтмапа/теней (сейчас такого нет).</summary>
public bool BlocksLight { get; init; }
/// <summary>Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл.</summary>
@@ -67,7 +67,7 @@ public sealed class PlantGrowthStage
}
/// <summary>
/// Продукт (Defs/products.json): что растение даёт при сборе (древесина, трава) или плодоношении
/// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении
/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество
/// определяют гены. <see cref="Def.Label"/> — ключ локализации (product.*).
/// </summary>
@@ -75,6 +75,9 @@ public sealed class ProductDef : Def
{
/// <summary>Категория продукта (например "material" или "food") — для будущей экономики/инвентаря.</summary>
public string Kind { get; init; } = "";
/// <summary>Масса одной единицы продукта (кг) — основа под перенос/инвентарь (грузоподъёмность).</summary>
public float MassKg { get; init; } = 1f;
}
/// <summary>
@@ -93,9 +96,15 @@ public sealed class GenomeDef
/// <summary>Оптимальная температура (°C) и её толерантность.</summary>
public float OptimalTemperature { get; init; } = 18f;
/// <summary>Ширина толерантности по температуре (°C).</summary>
/// <summary>Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума.</summary>
public float TemperatureTolerance { get; init; } = 12f;
/// <summary>Морозостойкость (°C): на сколько ниже плато рост спадает до нуля (жёсткая нижняя граница).</summary>
public float ColdHardiness { get; init; } = 8f;
/// <summary>Жаростойкость (°C): на сколько выше плато рост спадает до нуля (жёсткая верхняя граница).</summary>
public float HeatHardiness { get; init; } = 10f;
/// <summary>Оптимальная плодородность почвы и её толерантность.</summary>
public float OptimalFertility { get; init; } = 1f;
@@ -137,9 +146,18 @@ public sealed class GenomeDef
/// <summary>Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета.</summary>
public float LeafHue { get; init; } = 0.33f;
/// <summary>Токсичность (0..1): отравляет поедателя (коэволюция); тормозит рост (цена защиты).</summary>
public float Toxicity { get; init; }
/// <summary>Шипы (0..1): травмируют поедателя; тормозят рост (цена защиты).</summary>
public float Thorns { get; init; }
/// <summary>Вкусность (0..1): обратная привлекательность для умных травоядных (избегание в C2).</summary>
public float Palatability { get; init; } = 1f;
}
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
/// <summary>Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
public sealed class PlantDef : Def
{
/// <summary>Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры.</summary>
@@ -162,10 +180,13 @@ public sealed class PlantDef : Def
/// <summary>Имя <see cref="ProductDef"/>, выдаваемого плодами (ягоды/жёлудь); null — не плодоносит.</summary>
public string? FruitProduct { get; init; }
/// <summary>Эталонная масса (кг) зрелого растения базового размера; масса особи ∝ (размер/эталон)³.</summary>
public float BaseMassKg { get; init; } = 20f;
}
/// <summary>
/// Пресет размера мира (Defs/worldpresets.json): габариты сетки и население. Меню «Новый
/// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый
/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не
/// хардкодом. <see cref="Def.Label"/> — ключ локализации (preset.*).
/// </summary>
@@ -184,8 +205,8 @@ public sealed class WorldPresetDef : Def
public int Order { get; init; }
}
/// <summary>Существо (Defs/pawns.json): текстура, размер и вид (житель/животное).</summary>
public sealed class PawnDef : Def
/// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — <see cref="AnimalDef"/> наследует.</summary>
public class PawnDef : Def
{
/// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary>
public const string KindBeing = "being";
@@ -202,3 +223,258 @@ public sealed class PawnDef : Def
/// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary>
public string Kind { get; init; } = "";
}
/// <summary>
/// Стадия роста животного как ДАННЫЕ (вложенный объект <see cref="AnimalDef.Stages"/>): возраст входа =
/// <see cref="EnterAt"/> × якорь (<see cref="RelativeTo"/> — созревание или продолжительность жизни),
/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа.
/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода.
/// </summary>
public sealed class AnimalStageDef
{
/// <summary>Имя стадии (для тултипов/отладки).</summary>
public string Name { get; init; } = "";
/// <summary>Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни).</summary>
public float EnterAt { get; init; }
/// <summary>Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни).</summary>
public string RelativeTo { get; init; } = "maturity";
/// <summary>Масштаб размера тела на этой стадии (× ген размера).</summary>
public float Scale { get; init; } = 1f;
/// <summary>Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка).</summary>
public string Texture { get; init; } = "adult";
/// <summary>Взрослая стадия — способна размножаться.</summary>
public bool Adult { get; init; }
}
/// <summary>
/// Вид-животное (Defs/Animals/): подтип <see cref="PawnDef"/> (текстура/размер/вид) плюс геном
/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа —
/// существующие 9 животных-<see cref="PawnDef"/> остаются валидными.
/// </summary>
public sealed class AnimalDef : PawnDef
{
/// <summary>Текстура взрослого самца (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/> (самка/общая).</summary>
public string MaleTexture { get; init; } = "";
/// <summary>Текстура детёныша/подростка (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string BabyTexture { get; init; } = "";
/// <summary>Текстура трупа (фаза A5); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string CorpseTexture { get; init; } = "";
/// <summary>Текстура яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт семени-точки.</summary>
public string EggTexture { get; init; } = "things/plant/seed_default";
/// <summary>Эталонная масса (кг) взрослой особи базового размера вида; масса особи ∝ (размер/эталон)³.</summary>
public float BaseMassKg { get; init; } = 40f;
/// <summary>Имя <see cref="BodyDef"/> — анатомия вида (части тела/органы); пусто — без частей тела.</summary>
public string Body { get; init; } = "";
/// <summary>
/// Геном вида как ДАННЫЕ: ключ — id гена (<see cref="MrGameEng.Genetics.GeneDef"/>), значение —
/// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки
/// кода; разброс берётся из самого гена (<c>GeneDef.Spread</c>). Дискретные гены (напр. пол) сюда
/// не пишутся — назначаются генерацией.
/// </summary>
public Dictionary<string, float> Genome { get; init; } = new();
/// <summary>
/// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное).
/// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода.
/// </summary>
public string[] Diet { get; init; } = [];
/// <summary>
/// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию.
/// </summary>
public AnimalStageDef[] Stages { get; init; } = [];
/// <summary>Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам).</summary>
public float SpawnPer1000Cells { get; init; }
/// <summary>Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1).</summary>
public float BaseSpeed { get; init; } = 28f;
/// <summary>Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра.</summary>
public float VisionCells { get; init; } = 14f;
/// <summary>Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки.</summary>
public float ForageBiteDays { get; init; } = 40f;
/// <summary>Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт).</summary>
public float AttackRangeCells { get; init; } = 1.2f;
/// <summary>Урон части тела добычи за игровой день укусов (0 — вид не атакует, не хищник по бою).</summary>
public float AttackDamage { get; init; }
/// <summary>Прирост тяжести раны-кровотечения у добычи за игровой день укусов.</summary>
public float AttackBleed { get; init; }
/// <summary>Острая кровопотеря добычи за игровой день укусов (доля объёма крови) — основной путь к смерти.</summary>
public float AttackBloodLoss { get; init; }
}
/// <summary>
/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под
/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог
/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее
/// действие — без правки кода. Хранилище нужд особи — динамическое (см. <c>NeedSet</c>/<c>AnimalNeeds</c>).
/// </summary>
public sealed class NeedDef : Def
{
/// <summary>Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием).</summary>
public string Kind { get; init; } = "deplete";
/// <summary>Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…).</summary>
public string Action { get; init; } = "";
/// <summary>Темп убывания за игровой день (drive: темп спада, когда условие отсутствует).</summary>
public float DecayPerDay { get; init; } = 0.5f;
/// <summary>drive: темп роста за день, пока активно условие <see cref="RisesUnder"/>.</summary>
public float RisePerDay { get; init; } = 1f;
/// <summary>deplete: насколько за день восполняет нужду исполнение действия.</summary>
public float FeedPerDay { get; init; } = 4f;
/// <summary>drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда.</summary>
public string? RisesUnder { get; init; }
/// <summary>deplete: на нуле копит счётчик голодной смерти (A3).</summary>
public bool Lethal { get; init; }
/// <summary>Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7).</summary>
public float MinBrain { get; init; }
}
/// <summary>
/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие
/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за
/// <see cref="DurationDays"/>. Триггеры событий — код (реестр <c>AnimalThoughts</c>), а величины/метка —
/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки.
/// </summary>
public sealed class ThoughtDef : Def
{
/// <summary>Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе).</summary>
public float MoodOffset { get; init; }
/// <summary>Сколько игровых дней мысль живёт, прежде чем угаснуть.</summary>
public float DurationDays { get; init; } = 1f;
}
/// <summary>
/// Пункт дев-инструмента (Defs/DevTools/) — ЧТОБЫ МОДЫ ДОБАВЛЯЛИ кнопки в дев-спавнер без правки кода.
/// <see cref="Kind"/> — id обработчика из реестра (spawn-animal/spawn-plant/infect/damage/kill/…),
/// <see cref="Target"/> — параметр (defName вида/хедифа). Виды животных/растений спавнер берёт из их
/// дефов сам (модовый контент появляется автоматически); DevToolDef добавляет ПРОЧИЕ действия.
/// </summary>
public sealed class DevToolDef : Def
{
/// <summary>Категория для группировки в панели (например "tool").</summary>
public string Category { get; init; } = "tool";
/// <summary>Id обработчика-инструмента из реестра кода (spawn-animal/spawn-plant/infect/damage/kill).</summary>
public string Kind { get; init; } = "";
/// <summary>Параметр инструмента: defName вида/хедифа и т.п. (по смыслу Kind).</summary>
public string Target { get; init; } = "";
}
/// <summary>
/// Навык (Defs/Skills/) как ДАННЫЕ: уровень особи [0..1] растёт от практики (XP при действии,
/// ускоряется страстью) и медленно угасает без неё к полу <see cref="FloorLevel"/>. Эффект навыка —
/// код по его id (как у нужд/действий): добыча→выпас, охота→урон, уклонение→бегство. Модер добавляет
/// навык строкой JSON; новый эффект — код. <see cref="Def.Label"/> — ключ локализации (skill.*).
/// </summary>
public sealed class SkillDef : Def
{
/// <summary>Категория (для группировки в UI), напр. "survival".</summary>
public string Category { get; init; } = "";
/// <summary>Скорость обучения за игровой день непрерывной практики (при страсти ×1).</summary>
public float LearnRate { get; init; } = 0.5f;
/// <summary>Угасание уровня за игровой день без практики.</summary>
public float DecayPerDay { get; init; } = 0.02f;
/// <summary>Пол угасания (ниже не падает) — «не забывается совсем».</summary>
public float FloorLevel { get; init; }
}
/// <summary>
/// Часть тела/орган (вложенный объект <see cref="BodyDef.Parts"/>) как ДАННЫЕ: иерархия (родитель),
/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в
/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода.
/// </summary>
public sealed class BodyPartDef
{
/// <summary>Имя части (уникально в теле); на него ссылается <see cref="Parent"/> детей.</summary>
public string Name { get; init; } = "";
/// <summary>Имя родительской части ("" — корневая, напр. торс).</summary>
public string Parent { get; init; } = "";
/// <summary>Вес попадания (доля тела) — для будущего выбора задетой части.</summary>
public float Coverage { get; init; } = 0.1f;
/// <summary>Базовый максимум HP (× размер тела при создании особи).</summary>
public float MaxHp { get; init; } = 10f;
/// <summary>Жизненно важная: уничтожение → смерть.</summary>
public bool Vital { get; init; }
/// <summary>Вклады в способности: capacity id (см. <c>AnimalCapacities</c>) → доля (сумма по телу ≈ 1).</summary>
public Dictionary<string, float> Capacities { get; init; } = new();
}
/// <summary>
/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза
/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities).
/// </summary>
public sealed class BodyDef : Def
{
/// <summary>Части тела/органы вида.</summary>
public BodyPartDef[] Parts { get; init; } = [];
}
/// <summary>
/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6:
/// болезнь прогрессирует (<see cref="SeverityPerDay"/>), параллельно растёт иммунитет
/// (<see cref="ImmunityPerDay"/>) — гонка: иммунитет→1 раньше = выздоровление, severity→
/// <see cref="LethalSeverity"/> раньше = смерть. Снижает способности (<see cref="CapMods"/>) и даёт боль.
/// </summary>
public sealed class HediffDef : Def
{
/// <summary>Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05).</summary>
public float InitialSeverity { get; init; } = 1f;
/// <summary>Прирост тяжести за игровой день (0 — статичный хедиф вроде гона).</summary>
public float SeverityPerDay { get; init; }
/// <summary>Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам).</summary>
public float ImmunityPerDay { get; init; }
/// <summary>Тяжесть, при которой наступает смерть (0 — не летально).</summary>
public float LethalSeverity { get; init; }
/// <summary>Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение.</summary>
public float Pain { get; init; }
/// <summary>Шанс подхватить фоном за день (0 — не фоновая болезнь).</summary>
public float AmbientPerDay { get; init; }
/// <summary>
/// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую
/// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья).
/// </summary>
public float BloodLossPerDay { get; init; }
/// <summary>Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity).</summary>
public Dictionary<string, float> CapMods { get; init; } = new();
}
+9
View File
@@ -48,6 +48,15 @@ public sealed class ModAtlases : IDisposable
return Get(device, name).GetRegion(textureKey);
}
/// <summary>Регион по ключу, если он есть (для опциональных вариантов, напр. направленных спрайтов).</summary>
public bool TryGetRegion(GraphicsDevice device, string textureKey, out Texture2DRegion region)
{
var segments = textureKey.Split('/');
var depth = Math.Min(GroupDepth, segments.Length - 1);
var name = depth == 0 ? "Atlas" : string.Join('.', segments[..depth]);
return Get(device, name).TryGetRegion(textureKey, out region);
}
/// <inheritdoc />
public void Dispose()
{
+54
View File
@@ -0,0 +1,54 @@
namespace LittleSim.Content;
/// <summary>
/// Готовый реестр нужд из <see cref="NeedDef"/>: присваивает каждой нужде индекс (по нему системы
/// читают значения в <c>AnimalNeeds.Values</c> без словарей), знает тип (deplete/drive), начальное
/// значение и какое действие какую нужду утоляет. Делает набор нужд расширяемым данными: добавил
/// <see cref="NeedDef"/> — особи получают новую нужду, без правки кода (если действие уже есть).
/// </summary>
public sealed class NeedSet
{
private readonly NeedDef[] _defs;
private readonly Dictionary<string, int> _byAction = new(StringComparer.Ordinal);
/// <summary>Строит реестр из всех <see cref="NeedDef"/> мода (порядок = индексы нужд).</summary>
public NeedSet(GameContent content)
{
_defs = content.Defs.All<NeedDef>().ToArray();
for (var i = 0; i < _defs.Length; i++)
{
if (!string.IsNullOrEmpty(_defs[i].Action))
{
_byAction[_defs[i].Action] = i;
}
}
}
/// <summary>Число нужд.</summary>
public int Count => _defs.Length;
/// <summary>Деф нужды по индексу.</summary>
public NeedDef this[int index] => _defs[index];
/// <summary>Нужда-влечение (растёт под условием), а не убывающая.</summary>
public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal);
/// <summary>Убывающая нужда (голод/жажда/сон) — низкое значение срочно.</summary>
public bool IsDeplete(int index) => !IsDrive(index);
/// <summary>Индекс нужды, которую утоляет действие, или -1.</summary>
public int NeedForAction(string actionId) =>
_byAction.TryGetValue(actionId, out var index) ? index : -1;
/// <summary>Новый массив значений нужд особи: deplete стартуют полными (1), drive — пустыми (0).</summary>
public float[] NewValues()
{
var values = new float[_defs.Length];
for (var i = 0; i < values.Length; i++)
{
values[i] = IsDrive(i) ? 0f : 1f;
}
return values;
}
}
+8 -2
View File
@@ -81,7 +81,7 @@ public sealed class PlantSet
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(PlantDef def) => _index[def];
// Переводит базовый геном вида (числа из plants.json) в набор генов: каждое число — центр
// Переводит базовый геном вида (числа из Defs/Plants/) в набор генов: каждое число — центр
// аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида.
private static GenomeTemplate BuildTemplate(
GenomeDef g,
@@ -92,7 +92,7 @@ public sealed class PlantSet
genes.TryGetValue(id, out var def)
? def
: throw new KeyNotFoundException(
$"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?"
$"Gene def '{id}' not found — is Mods/Core/Defs/Genes/ loaded?"
);
GenomeTemplate.Entry Numeric(string id, float baseValue) =>
@@ -103,6 +103,8 @@ public sealed class PlantSet
Numeric("GeneLightTolerance", g.LightTolerance),
Numeric("GeneOptimalTemperature", g.OptimalTemperature),
Numeric("GeneTemperatureTolerance", g.TemperatureTolerance),
Numeric("GeneColdHardiness", g.ColdHardiness),
Numeric("GeneHeatHardiness", g.HeatHardiness),
Numeric("GeneOptimalFertility", g.OptimalFertility),
Numeric("GeneFertilityTolerance", g.FertilityTolerance),
Numeric("GeneVigor", g.Vigor),
@@ -116,6 +118,10 @@ public sealed class PlantSet
new GenomeTemplate.Entry(Gene("GeneFruitSeason"), g.FruitSeason, 0f), // сезон фиксирован по виду
Numeric("GeneHarvestAmount", g.HarvestAmount),
new GenomeTemplate.Entry(Gene("GeneLeafHue"), g.LeafHue, 0.04f),
// Защита (коэволюция с травоядными): токсичность/шипы/вкусность.
Numeric("GeneToxicity", g.Toxicity),
Numeric("GeneThorns", g.Thorns),
Numeric("GenePalatability", g.Palatability),
new GenomeTemplate.Entry(
Gene("GeneMorph"),
0f,
+81
View File
@@ -0,0 +1,81 @@
namespace LittleSim.Content;
/// <summary>
/// Готовый реестр навыков из <see cref="SkillDef"/>: каждому навыку — индекс (по нему системы читают
/// уровни в <c>Skills.Levels</c> без словарей), плюс генерация стартовых уровней и страсти особи.
/// Делает набор навыков расширяемым данными (как <see cref="NeedSet"/>): добавил <see cref="SkillDef"/> —
/// у особей появляется навык, без правки кода (если его эффект/начисление XP уже есть в коде).
/// </summary>
public sealed class SkillSet
{
// Веса страсти при генерации особи: нет / интерес / страсть (множители обучения см. PassionMultiplier).
private static readonly float[] PassionWeights = [0.6f, 0.3f, 0.1f];
private readonly SkillDef[] _defs;
private readonly Dictionary<string, int> _byId = new(StringComparer.Ordinal);
/// <summary>Строит реестр из всех <see cref="SkillDef"/> мода (порядок = индексы навыков).</summary>
public SkillSet(GameContent content)
{
_defs = content.Defs.All<SkillDef>().ToArray();
for (var i = 0; i < _defs.Length; i++)
{
_byId[_defs[i].DefName] = i;
}
}
/// <summary>Число навыков.</summary>
public int Count => _defs.Length;
/// <summary>Деф навыка по индексу.</summary>
public SkillDef this[int index] => _defs[index];
/// <summary>Индекс навыка по id, или -1.</summary>
public int IndexOf(string id) => _byId.TryGetValue(id, out var index) ? index : -1;
/// <summary>Стартовые уровни особи (с пола угасания каждого навыка).</summary>
public float[] NewLevels()
{
var levels = new float[_defs.Length];
for (var i = 0; i < levels.Length; i++)
{
levels[i] = _defs[i].FloorLevel;
}
return levels;
}
/// <summary>Бросает страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) детерминированно по rng.</summary>
public byte[] RollPassions(Random rng)
{
var passions = new byte[_defs.Length];
for (var i = 0; i < passions.Length; i++)
{
var roll = rng.NextSingle();
byte p = 0;
var acc = 0f;
for (byte k = 0; k < PassionWeights.Length; k++)
{
acc += PassionWeights[k];
if (roll < acc)
{
p = k;
break;
}
}
passions[i] = p;
}
return passions;
}
/// <summary>Множитель скорости обучения по уровню страсти (нет/интерес/страсть).</summary>
public static float PassionMultiplier(byte passion) =>
passion switch
{
0 => 0.35f,
1 => 1.0f,
_ => 1.7f,
};
}
+22
View File
@@ -0,0 +1,22 @@
namespace LittleSim.Content;
/// <summary>
/// Реестр мыслей настроения (<see cref="ThoughtDef"/>) с доступом по id (фаза A8): системы-источники
/// событий (роды/спаривание/жажда) берут нужную мысль по строковому id (см. <c>AnimalThoughts</c>) и
/// вешают её на особь. Метка/сила/длительность мысли — данные; модер правит их в thoughts.json. Зеркало
/// <see cref="NeedSet"/> по духу. Удалил модер мысль из дефов — <see cref="Get"/> вернёт null (no-op).
/// </summary>
public sealed class ThoughtSet
{
private readonly Dictionary<string, ThoughtDef> _byId;
/// <summary>Строит реестр из всех <see cref="ThoughtDef"/> мода.</summary>
public ThoughtSet(GameContent content) =>
_byId = content.Defs.All<ThoughtDef>().ToDictionary(t => t.DefName, StringComparer.Ordinal);
/// <summary>Число мыслей.</summary>
public int Count => _byId.Count;
/// <summary>Деф мысли по id или null, если такой нет (мод убрал).</summary>
public ThoughtDef? Get(string id) => _byId.GetValueOrDefault(id);
}
+31
View File
@@ -1,6 +1,7 @@
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
using MrGameEng.Host;
@@ -20,6 +21,36 @@ if (args.Contains("--check-content"))
+ $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits "
+ $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})"
);
// Диета животных из генов (предатор-кластер): для каждого вида строим центральный геном из его
// баз, считаем признаки формулами и выводим рацион (травоядное/хищник/всеядное) — проверка без
// GPU, что хищники заданы и роль выводится из генов herbivory/carnivory/omnivory.
foreach (var def in content.Defs.All<AnimalDef>())
{
var entries = new List<GenomeTemplate.Entry>();
foreach (var (geneId, baseValue) in def.Genome)
{
if (registry.TryGetValue(geneId, out var gene))
{
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
}
var genome = new GenomeTemplate(entries).Generate(new Random(7));
var t = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, registry));
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(t, def);
var role =
eatsMeat ? (eatsPlants ? "omnivore" : "carnivore")
: eatsPlants ? "herbivore"
: "none";
var hunt = eatsMeat && def.AttackDamage > 0f ? $", attack {def.AttackDamage:0}/d" : "";
Console.WriteLine(
$" {def.DefName}: {role} (herb {t.Herbivory:0.##}/carn {t.Carnivory:0.##}/omni {t.Omnivory:0.##}),"
+ $" spawn {def.SpawnPer1000Cells:0.##}/1000{hunt}"
);
}
return;
}
catch (Exception error)
+91 -91
View File
@@ -1,91 +1,91 @@
using System;
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
/// </summary>
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();
Context.UseGameSpeed(1f, 3f, 6f);
_settings = GameSettingsStore.Load();
var host = (GameHost)Context.Services.Get<Game>();
host.Graphics.IsFullScreen = _settings.Fullscreen;
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
host.Graphics.PreferredBackBufferWidth = _settings.Width;
host.Graphics.PreferredBackBufferHeight = _settings.Height;
host.Graphics.ApplyChanges();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label
{
TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.Screen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(() => GameContent.Load());
UpdateSystems.Add(new CallbackSystem(Tick));
}
private void Tick()
{
var word = _settings.Language == "en" ? _steps[1] : _steps[0];
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots;
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
{
return;
}
if (_load.IsFaulted)
{
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
Log.Error($"Content load failed: {_label.Text}");
return;
}
var content = _load.Result;
_load = null;
Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language);
Scene next = _connectTo is null
? new MainMenuScene()
: new MultiplayerScene(new Uri(_connectTo));
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
}
}
using System;
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
/// </summary>
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();
Context.UseGameSpeed(1f, 3f, 6f);
_settings = GameSettingsStore.Load();
var host = (GameHost)Context.Services.Get<Game>();
host.Graphics.IsFullScreen = _settings.Fullscreen;
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
host.Graphics.PreferredBackBufferWidth = _settings.Width;
host.Graphics.PreferredBackBufferHeight = _settings.Height;
host.Graphics.ApplyChanges();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label
{
TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.MenuScreen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(() => GameContent.Load());
UpdateSystems.Add(new CallbackSystem(Tick));
}
private void Tick()
{
var word = _settings.Language == "en" ? _steps[1] : _steps[0];
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots;
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
{
return;
}
if (_load.IsFaulted)
{
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
Log.Error($"Content load failed: {_label.Text}");
return;
}
var content = _load.Result;
_load = null;
Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language);
Scene next = _connectTo is null
? new MainMenuScene()
: new MultiplayerScene(new Uri(_connectTo));
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
}
}
+5 -5
View File
@@ -15,17 +15,17 @@ public sealed class CreditsScene : Scene
{
var lang = Context.Services.Get<GameContent>().Languages;
var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
var column = Ui.Card(10);
column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(Ui.AccentRule());
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 12 });
column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
desktop.Root = Ui.Screen(column);
desktop.Root = Ui.MenuScreen(column);
UpdateSystems.Add(
new CallbackSystem(() =>
{
+5 -1
View File
@@ -13,7 +13,10 @@ public static class GameLayers
/// <summary>Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.</summary>
public static LayerId Beings { get; private set; }
/// <summary>Регистрирует слои при первом вызове.</summary>
/// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary>
public static LayerId Selection { get; private set; }
/// <summary>Регистрирует слои при первом вызове (порядок = порядок отрисовки).</summary>
public static void EnsureRegistered(Renderer2D renderer)
{
if (_registered)
@@ -22,6 +25,7 @@ public static class GameLayers
}
Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort);
Selection = renderer.Layers.Register("Selection", LayerSpace.World, LayerSortMode.Depth);
_registered = true;
}
}
+5 -5
View File
@@ -24,7 +24,7 @@ public sealed class LoadGameScene : Scene
{
_content = Context.Services.Get<GameContent>();
var input = this.UseInput();
_desktop = this.UseUI();
_desktop = this.UseScaledUI();
Rebuild();
UpdateSystems.Add(
@@ -41,9 +41,9 @@ public sealed class LoadGameScene : Scene
private void Rebuild()
{
var lang = _content.Languages;
var column = Ui.Column(8);
var column = Ui.Card(8);
column.Widgets.Add(Ui.Title(lang.Get("load.title")));
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.AccentRule());
var saves = _store.List();
if (saves.Count == 0)
@@ -73,7 +73,7 @@ public sealed class LoadGameScene : Scene
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(
new WorldScene(save.ToConfig(), save),
new WorldLoadingScene(save.ToConfig(), save),
Transitions.Fade(0.6f)
);
}
@@ -98,7 +98,7 @@ public sealed class LoadGameScene : Scene
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
_desktop.Root = Ui.Screen(column);
_desktop.Root = Ui.MenuScreen(column);
}
private void Back()
+11 -11
View File
@@ -17,21 +17,21 @@ public sealed class MainMenuScene : Scene
{
var content = Context.Services.Get<GameContent>();
var lang = content.Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
column.Widgets.Add(
var card = Ui.Card(8);
card.Widgets.Add(Ui.Title(lang.Get("menu.title")));
card.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
card.Widgets.Add(Ui.AccentRule());
card.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
card.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
card.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
card.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
card.Widgets.Add(
Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get<Game>().Exit())
);
desktop.Root = Ui.Screen(column);
desktop.Root = Ui.MenuScreen(card);
}
private void Go(Scene scene)
+42 -14
View File
@@ -30,6 +30,9 @@ public sealed class MultiplayerScene : Scene
{
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
private const float MaxBackoffSeconds = 8f;
private readonly Uri _server;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
@@ -38,6 +41,8 @@ public sealed class MultiplayerScene : Scene
private GameContent _content = null!;
private Label _hud = null!;
private string _statusKey = "net.connecting";
private double _nextAttemptAt; // время (unscaled) следующей попытки подключения
private int _attempt; // 0 — первая попытка; растёт при обрывах, задаёт бэкофф
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server;
@@ -64,7 +69,7 @@ public sealed class MultiplayerScene : Scene
entity.AddComponent(new NetLerp());
};
var desktop = this.UseUI();
var desktop = this.UseScaledUI();
_hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud);
@@ -86,39 +91,53 @@ public sealed class MultiplayerScene : Scene
}
);
_connecting = WebSocketClient.ConnectAsync(_server);
// Первую попытку запускает сам Pump (единый путь с переподключением): _nextAttemptAt = 0.
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
var now = Context.Clock.UnscaledTotalTime;
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
if (finished.IsCompletedSuccessfully)
{
_connection = finished.Result;
_replication.Clear(); // сбрасываем устаревшие сущности перед свежим полным снапшотом
_attempt = 0;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
else
{
_statusKey = "net.failed";
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
ScheduleReconnect(now);
}
else
{
_connection = finished.Result;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
}
// Обрыв уже установленного соединения: чистим и уходим в переподключение.
if (_connection is { IsOpen: false })
{
_connection = null;
_replication.Clear();
ScheduleReconnect(now);
}
if (_connection is not null)
{
_replication.Pump(_connection);
if (!_connection.IsOpen)
{
_statusKey = "net.lost";
}
}
else if (_connecting is null && now >= _nextAttemptAt)
{
_statusKey = _attempt == 0 ? "net.connecting" : "net.reconnecting";
_connecting = WebSocketClient.ConnectAsync(_server);
}
_hud.Text = _content.Languages.Format(
@@ -133,4 +152,13 @@ public sealed class MultiplayerScene : Scene
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
// Экспоненциальный бэкофф: 1, 2, 4, 8, 8, … секунд между попытками.
private void ScheduleReconnect(double now)
{
var delay = MathF.Min(MaxBackoffSeconds, 1f * (1 << Math.Min(_attempt, 3)));
_attempt++;
_nextAttemptAt = now + delay;
_statusKey = "net.reconnecting";
}
}
+5 -5
View File
@@ -33,14 +33,14 @@ public sealed class NewWorldScene : Scene
var content = Context.Services.Get<GameContent>();
var lang = content.Languages;
var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
_preset = presets[0];
var column = Ui.Column(10);
var column = Ui.Card(10);
column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.AccentRule());
_name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
@@ -106,7 +106,7 @@ public sealed class NewWorldScene : Scene
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
column.Widgets.Add(buttons);
desktop.Root = Ui.Screen(column);
desktop.Root = Ui.MenuScreen(column);
Refresh();
UpdateSystems.Add(
@@ -154,7 +154,7 @@ public sealed class NewWorldScene : Scene
Seed = seed,
SmoothPasses = _smoothing,
};
Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
Context.Scenes.Switch(new WorldLoadingScene(config), Transitions.Fade(0.6f));
}
private void Back()
+4
View File
@@ -24,6 +24,7 @@ internal sealed class PauseMenu
private readonly GameSpeed _speed;
private readonly GameHost _host;
private readonly AudioManager _audio;
private readonly Desktop _desktop;
private readonly Func<string> _onSave;
private readonly Action _onMainMenu;
private readonly Action _onQuit;
@@ -36,6 +37,7 @@ internal sealed class PauseMenu
EngineContext context,
GameContent content,
GameSpeed speed,
Desktop desktop,
Func<string> onSave,
Action onMainMenu,
Action onQuit
@@ -44,6 +46,7 @@ internal sealed class PauseMenu
_context = context;
_content = content;
_speed = speed;
_desktop = desktop;
_host = (GameHost)context.Services.Get<Game>();
_audio = context.Services.Get<AudioManager>();
_onSave = onSave;
@@ -144,6 +147,7 @@ internal sealed class PauseMenu
{
GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(_context, _desktop, _settings.UiScale); // масштаб UI — сразу
ShowSettings(); // язык мог смениться
},
onBack: () =>
+5 -2
View File
@@ -32,7 +32,7 @@ public sealed class SettingsScene : Scene
_host = (GameHost)Context.Services.Get<Game>();
var input = this.UseInput();
_desktop = this.UseUI();
_desktop = this.UseScaledUI();
Rebuild();
UpdateSystems.Add(
@@ -47,12 +47,15 @@ public sealed class SettingsScene : Scene
}
private void Rebuild() =>
_desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
_desktop.Root = Ui.MenuScreen(
SettingsPanel.Build(_settings, _content.Languages, Apply, Back)
);
private void Apply()
{
GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(Context, _desktop, _settings.UiScale); // масштаб UI — сразу
Rebuild(); // язык мог смениться — перестраиваем подписи
}
+63
View File
@@ -0,0 +1,63 @@
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using MrGameEng.Core;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Экран загрузки между окном создания/выбора мира и игровой сценой. Генерация мира
/// (<see cref="WorldScene.OnLoad"/>: рельеф, атласы, спавн) идёт синхронно и блокирует кадр; раньше под
/// ней был лишь сплошной фейд без индикатора. Эта сцена рисует надпись «Создание мира…», даёт ей
/// прорисоваться, затем переключается на <see cref="WorldScene"/> БЕЗ перехода — её последний кадр
/// (надпись) и остаётся на экране, пока идёт генерация, давая игроку понятную обратную связь.
/// </summary>
public sealed class WorldLoadingScene : Scene
{
private readonly WorldConfig _config;
private readonly WorldSave? _save;
private GameSettings _settings = new();
private Label _label = null!;
private int _frames;
/// <summary>Новый мир (<paramref name="save"/> = null) или загрузка сохранения.</summary>
public WorldLoadingScene(WorldConfig config, WorldSave? save = null)
{
_config = config;
_save = save;
}
protected override void OnLoad()
{
_settings = GameSettingsStore.Load();
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label
{
TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.MenuScreen(_label);
UpdateSystems.Add(new CallbackSystem(Tick));
}
private void Tick()
{
var content = Context.Services.Get<GameContent>();
var word = content.Languages.Get("loading.world");
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots;
// Дождаться, пока надпись точно прорисована (переход въезда завершён + пара кадров), затем
// мгновенно переключиться: блокирующий WorldScene.OnLoad оставит на экране кадр с надписью.
_frames++;
if (Context.Scenes.IsTransitioning || _frames < 2)
{
return;
}
Context.Scenes.Switch(new WorldScene(_config, _save));
}
}
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Единая точка создания сущности-животного: спрайт вида, признаки из генома формулами генов, тинт по
/// оттенку меха и компонент-организм. Переиспользуется начальным спавном, размножением и загрузкой —
/// чтобы все животные собирались одинаково. Зеркало <see cref="PlantFactory"/>.
/// </summary>
public static class AnimalFactory
{
// Якоря оттенка меха: тёплый рыжевато-песочный → холодный серо-голубой (по гену furColor).
private static readonly Color FurWarm = new(255, 226, 188);
private static readonly Color FurCool = new(206, 222, 235);
/// <summary>Создаёт животное вида <paramref name="species"/> заданного возраста, генома и поколения.</summary>
public static Entity Create(
EntityStore store,
AnimalSet animals,
NeedSet needs,
SkillSet skills,
int species,
Vector2 position,
float ageDays,
Genome genome,
int generation,
int cellSize
)
{
var sp = animals[species];
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry));
var isMale = IsMaleGenome(genome);
var stage = StageAt(ageDays, traits.MaturityAge, traits.Lifespan, sp.Stages);
var region = RegionFor(sp, stage, isMale);
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = FurTint(traits);
var entity = store.CreateEntity(
new Transform2D(
position,
scale: new Vector2(Scale(sp, traits, stage, region, cellSize))
),
sprite,
new AnimalOrganism
{
Genome = genome,
Traits = traits,
Species = species,
IsMale = isMale,
Generation = generation,
},
new AnimalGrowth { Stage = stage, AgeDays = ageDays },
// Нужды — динамический массив по NeedSet (deplete полны, drive пусты); «мозг» решает сразу.
new AnimalNeeds { Values = needs.NewValues() },
new AnimalBrain
{
Action = AnimalActions.Wander,
TargetPlant = -1,
TargetMate = -1,
},
new Health { State = HealthState.Create(sp.Body, traits.BodySize) },
// Настроение (фаза A8): нейтральное на старте, активность по тиру мозга считает MoodSystem.
new Mood
{
Value = 0.5f,
Active = false,
Thoughts = [],
}
);
// Навыки: стартовые уровни + страсть (детерминированно по геному — воспроизводимо без RNG-параметра).
entity.AddComponent(
new Skills
{
Levels = skills.NewLevels(),
Passions = skills.RollPassions(new Random(SkillSeed(genome) ^ species)),
}
);
// Память: объём ∝ размеру мозга (примитивные ~0 — ничего не помнят).
entity.AddComponent(
new Memory
{
Entries = [],
Capacity = (int)MathF.Round(MathF.Max(0f, traits.BrainSize) * MemoryPerBrain),
}
);
return entity;
}
/// <summary>Сколько записей памяти даёт единица размера мозга (объём памяти = brainSize × это).</summary>
public const float MemoryPerBrain = 16f;
// Стабильный сид из генома (сумма аллелей — порядконезависима) для детерминированной страсти к навыкам.
private static int SkillSeed(Genome genome)
{
var acc = 0;
foreach (var (_, allele) in genome.Alleles) // IReadOnlyDictionary без копии — без аллокации
{
acc += (int)(allele.A * 7919f) + (int)(allele.B * 104729f);
}
return acc;
}
// Тинт свежего трупа (стадии гниения/скелета задаёт CorpseSystem).
private static readonly Color CorpseFreshTint = new(176, 148, 128);
/// <summary>Создаёт сущность-труп после смерти животного: спрайт-падаль (масштаб ∝ размер тела) и
/// запас мяса <paramref name="meatKg"/> (∝ масса тела, считается вызывающим через <see cref="Mass"/>).</summary>
public static Entity CreateCorpse(
EntityStore store,
AnimalSet animals,
int species,
Vector2 position,
float bodySize,
float meatKg,
int cellSize
)
{
var sp = animals[species];
var region = sp.Corpse;
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = CorpseFreshTint;
var sizeCells = bodySize > 0f ? bodySize : sp.Def.SizeCells;
return store.CreateEntity(
new Transform2D(position, scale: new Vector2(cellSize * sizeCells / region.Width)),
sprite,
new Corpse
{
Species = species,
RotDays = 0f,
Stage = 0,
Meat = meatKg,
BodySize = bodySize,
}
);
}
// Тинт яйца (бледно-кремовый) и доля размера клетки, до которой масштабируется спрайт яйца.
private static readonly Color EggTint = new(238, 230, 208);
private const float EggSizeFraction = 0.45f;
/// <summary>
/// Создаёт сущность-яйцо (тип рождения «яйцекладка»): спрайт-яйцо и компонент <see cref="Egg"/> с уже
/// скрещённым геномом будущего детёныша. Инкубируется и вылупляется в <see cref="EggSystem"/>.
/// </summary>
public static Entity CreateEgg(
EntityStore store,
AnimalSet animals,
int species,
Vector2 position,
Genome genome,
int generation,
float incubateDays,
int cellSize
)
{
var sp = animals[species];
var region = sp.Egg;
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = EggTint;
return store.CreateEntity(
new Transform2D(
position,
scale: new Vector2(cellSize * EggSizeFraction / region.Width)
),
sprite,
new Egg
{
Species = species,
Genome = genome,
Generation = generation,
IncubateDays = incubateDays,
}
);
}
/// <summary>Порог выраженности признака диеты, с которого вид реально ест данный корм.</summary>
public const float DietThreshold = 0.5f;
/// <summary>
/// Рацион особи из ГЕНОВ: ест растения, если max(herbivory, omnivory) ≥ порога; ест мясо
/// (охота/падаль), если max(carnivory, omnivory) ≥ порога — всеядность (omnivory) включает оба
/// источника, специалисты задаются высоким herbivory ЛИБО carnivory. Если у вида нет генов диеты
/// (все три ≈ 0) — откат на список <see cref="AnimalDef.Diet"/> (легаси/модерский fallback).
/// </summary>
public static (bool EatsPlants, bool EatsMeat) Diet(in AnimalPhenotype traits, AnimalDef def)
{
if (traits.Herbivory <= 0f && traits.Carnivory <= 0f && traits.Omnivory <= 0f)
{
return (DietContains(def, "plant"), DietContains(def, "meat"));
}
var eatsPlants = MathF.Max(traits.Herbivory, traits.Omnivory) >= DietThreshold;
var eatsMeat = MathF.Max(traits.Carnivory, traits.Omnivory) >= DietThreshold;
return (eatsPlants, eatsMeat);
}
private static bool DietContains(AnimalDef def, string food)
{
foreach (var f in def.Diet)
{
if (string.Equals(f, food, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>Гибель особи: оставляет труп (мясо ∝ масса тела) и удаляет сущность. Общий путь смерти.</summary>
public static void Die(EntityStore store, AnimalSet animals, Entity entity, int cellSize)
{
if (entity.IsNull || !entity.HasComponent<AnimalOrganism>())
{
return;
}
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var position = entity.GetComponent<Transform2D>().Position;
var sp = animals[org.Species];
var stageScale = entity.TryGetComponent<AnimalGrowth>(out var grow)
? sp.Stages[Math.Clamp(grow.Stage, 0, sp.Stages.Length - 1)].Scale
: 1f;
var meat = Mass.OfAnimal(sp.Def, org.Traits, stageScale) * Mass.MeatFraction;
CreateCorpse(store, animals, org.Species, position, org.Traits.BodySize, meat, cellSize);
entity.DeleteEntity();
}
/// <summary>
/// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья).
/// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект,
/// что гейтит набор доступных нужд/действий (см. <see cref="AnimalContext"/>). Вид без частей тела
/// (сознание не вычисляется) считается полностью сознательным — чтобы не отключить ему всё.
/// </summary>
public static float Intelligence(in AnimalPhenotype traits, HealthState? health)
{
var consciousness = health is { Parts.Length: > 0 }
? health.Capacity(AnimalCapacities.Consciousness)
: 1f;
return traits.BrainSize * consciousness;
}
/// <summary>Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX).</summary>
public static bool IsMaleGenome(Genome genome) =>
genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f);
/// <summary>Индекс стадии по возрасту: последняя стадия, чей возраст входа (EnterAt × якорь) ≤ возраст.</summary>
public static int StageAt(
float ageDays,
float maturity,
float lifespan,
AnimalStageDef[] stages
)
{
var stage = 0;
for (var i = 1; i < stages.Length; i++)
{
var anchor = string.Equals(stages[i].RelativeTo, "lifespan", StringComparison.Ordinal)
? lifespan
: maturity;
if (ageDays >= stages[i].EnterAt * anchor)
{
stage = i;
}
}
return stage;
}
/// <summary>Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка).</summary>
public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) =>
sp.LookDirs(stage, isMale).East; // восток — базовое направление; разворот делает AnimalFacingSystem
/// <summary>Взрослая ли стадия (способна размножаться) — флаг из данных стадии.</summary>
public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult;
/// <summary>Масштаб Transform2D для спрайта стадии (размер тела × множитель стадии / ширина региона).</summary>
public static float Scale(
AnimalSet.Species sp,
in AnimalPhenotype traits,
int stage,
Texture2DRegion region,
int cellSize
)
{
var sizeCells =
(traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells) * sp.Stages[stage].Scale;
return cellSize * sizeCells / region.Width;
}
/// <summary>
/// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры —
/// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений).
/// Публичен, чтобы <see cref="AnimalAppearanceSystem"/> домножал его на яркость по нуждам.
/// </summary>
public static Color FurTint(in AnimalPhenotype traits)
{
var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f));
return Color.Lerp(Color.White, fur, 0.5f);
}
}
+720
View File
@@ -0,0 +1,720 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало
/// <see cref="PlantPhenotype"/>. Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
public struct AnimalPhenotype
{
/// <summary>Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии).</summary>
public float BodySize;
/// <summary>Скорость обмена веществ — множитель расхода нужд.</summary>
public float Metabolism;
/// <summary>Скорость передвижения (множитель).</summary>
public float MoveSpeed;
/// <summary>Объём крови (множитель) — масштаб скорости кровопотери.</summary>
public float BloodVolume;
/// <summary>Дальность зрения (множитель радиуса поиска).</summary>
public float Vision;
/// <summary>Острота слуха (множитель радиуса).</summary>
public float Hearing;
/// <summary>Острота обоняния (множитель радиуса).</summary>
public float Smell;
/// <summary>Острота осязания (множитель радиуса; чувство ближнего контакта).</summary>
public float Touch;
/// <summary>Камуфляж [0..1] — снижает обнаружение этой особи зрением (заметность = 1camouflage).</summary>
public float Camouflage;
/// <summary>Запах [0..1] — повышает обнаружение этой особи обонянием.</summary>
public float Scent;
/// <summary>Шумность [0..1] — повышает обнаружение этой особи слухом при движении.</summary>
public float Noise;
/// <summary>Размер мозга (0..1) — основа интеллекта.</summary>
public float BrainSize;
/// <summary>Теплоизоляция мехом (0..1).</summary>
public float Insulation;
/// <summary>Оттенок меха (0..1) — сдвиг тинта спрайта.</summary>
public float FurHue;
/// <summary>Возраст созревания (игровых дней) — порог стадии Adult.</summary>
public float MaturityAge;
/// <summary>Травоядность [0..1] — способность/тяга питаться растениями.</summary>
public float Herbivory;
/// <summary>Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль).</summary>
public float Carnivory;
/// <summary>Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо).</summary>
public float Omnivory;
/// <summary>Устойчивость к растительным ядам [0..1] — снижает дозу отравления токсичным кормом.</summary>
public float ToxinTolerance;
/// <summary>Социальность [0..1] — тяга держаться стаи/стада (когезия + спокойствие в группе).</summary>
public float Sociability;
/// <summary>Продолжительность жизни (игровых дней).</summary>
public float Lifespan;
/// <summary>Сезон гона (0 весна … 3 зима).</summary>
public int BreedingSeason;
/// <summary>Срок вынашивания (игровых дней).</summary>
public float GestationDays;
/// <summary>Размер помёта.</summary>
public float LitterSize;
/// <summary>Тип рождения [0..1]: ≥0.5 — яйцекладущий (откладывает яйца), иначе живородящий.</summary>
public float EggLaying;
/// <summary>Яйцекладущий ли вид (тип рождения) — определяет цикл зачатия.</summary>
public readonly bool Oviparous => EggLaying >= 0.5f;
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
float T(string name) => traits.GetValueOrDefault(name);
// Острота чувств по умолчанию 1 (вид без гена не «слепнет»); заметность — мягкие дефолты.
float Acuity(string name) => traits.TryGetValue(name, out var v) ? v : 1f;
return new AnimalPhenotype
{
BodySize = T("bodySize"),
Metabolism = T("metabolism"),
MoveSpeed = T("moveSpeed"),
BloodVolume = T("bloodVolume"),
Vision = T("vision"),
Hearing = Acuity("hearing"),
Smell = Acuity("smell"),
Touch = Acuity("touch"),
Camouflage = T("camouflage"),
Scent = traits.GetValueOrDefault("scent", 0.5f),
Noise = traits.GetValueOrDefault("noise", 0.5f),
BrainSize = T("brainSize"),
Insulation = T("insulation"),
FurHue = T("furHue"),
MaturityAge = T("maturityAge"),
Herbivory = T("herbivory"),
Carnivory = T("carnivory"),
Omnivory = T("omnivory"),
ToxinTolerance = T("toxinTolerance"),
Sociability = T("sociability"),
Lifespan = T("lifespan"),
BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)),
GestationDays = T("gestationDays"),
LitterSize = T("litterSize"),
EggLaying = T("eggLaying"),
};
}
}
/// <summary>
/// Компонент-организм животного: управляемый <see cref="Genome"/> (для размножения, сейва и
/// пересчёта), выраженный фенотип <see cref="Traits"/>, индекс вида в <see cref="Content.AnimalSet"/>
/// и номер поколения (для <c>popstats</c> и видимого дрейфа). Геном — ссылочный объект, поэтому
/// компонент несёт ссылку, а не копию (как у растений). Зеркало <see cref="PlantOrganism"/>.
/// </summary>
public struct AnimalOrganism : IComponent
{
/// <summary>Геном особи (аллели по генам); источник истины для признаков и размножения.</summary>
public Genome Genome;
/// <summary>Выраженные признаки — то, что читают системы.</summary>
public AnimalPhenotype Traits;
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/>.</summary>
public int Species;
/// <summary>Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании.</summary>
public bool IsMale;
/// <summary>Номер поколения: max(родителей) + 1; у основателей 0.</summary>
public int Generation;
}
/// <summary>
/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские
/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет
/// <see cref="AnimalGrowthSystem"/>, читая пороги из генов (возраст созревания, продолжительность жизни).
/// Зеркало <see cref="PlantGrowth"/>.
/// </summary>
public struct AnimalGrowth : IComponent
{
/// <summary>Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior.</summary>
public int Stage;
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays;
}
/// <summary>Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру).</summary>
public struct PartInstance
{
/// <summary>Деф части тела (анатомия вида).</summary>
public Content.BodyPartDef Def;
/// <summary>Текущий HP.</summary>
public float Hp;
/// <summary>Максимальный HP (база × размер тела).</summary>
public float MaxHp;
/// <summary>Доля сохранности [0,1].</summary>
public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f;
}
/// <summary>Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6).</summary>
public struct Hediff
{
/// <summary>Деф хедифа (гон/болезнь/…).</summary>
public HediffDef Def;
/// <summary>Текущая тяжесть.</summary>
public float Severity;
/// <summary>Набранный иммунитет [0,1]; при 1 — выздоровление.</summary>
public float Immunity;
}
/// <summary>
/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные
/// способности (capacities). Managed-объект, как <see cref="Genome"/> (Friflo допускает ссылку в
/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по
/// фазам — контейнер один.
/// </summary>
public sealed class HealthState
{
private readonly List<Hediff> _hediffs = [];
/// <summary>Активные хедифы (гон, болезни).</summary>
public IReadOnlyList<Hediff> Hediffs => _hediffs;
/// <summary>Части тела особи (из анатомии вида); пусто — у вида нет тела.</summary>
public PartInstance[] Parts { get; private set; } = [];
/// <summary>Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны).</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>Боль [0,1] — снижает сознание (когда появятся раны).</summary>
public float Pain { get; set; }
/// <summary>Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле).</summary>
public bool Dead { get; set; }
/// <summary>Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1.</summary>
public Dictionary<string, float> Capacities { get; private set; } = new();
/// <summary>Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий.</summary>
public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f;
/// <summary>
/// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы
/// способностей от хедифов (лерп 1→factor по тяжести), затем общий множитель <paramref name="capacityScale"/>
/// (старение/сенесценс: дряхлеющий организм слабее по всем способностям). Вызывать после изменения
/// частей/крови/хедифов; <paramref name="capacityScale"/>=1 — без возрастного спада.
/// </summary>
public void RecomputeCapacities(float capacityScale = 1f)
{
var pain = 0f;
foreach (var h in _hediffs)
{
pain += h.Def.Pain * h.Severity;
}
Pain = Math.Clamp(pain, 0f, 1f);
// Множитель старения применяется внутри Compute (без второго прохода/аллокации списка ключей).
var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale);
foreach (var h in _hediffs)
{
if (h.Def.CapMods.Count == 0)
{
continue;
}
var t = Math.Clamp(h.Severity, 0f, 1f);
foreach (var (capacity, factor) in h.Def.CapMods)
{
caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t);
}
}
Capacities = caps;
}
/// <summary>
/// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие
/// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести.
/// <paramref name="immunityScale"/> — множитель набора иммунитета (способность «фильтрация крови»:
/// почки/печень выводят токсины и помогают бороться с болезнью; повреждённые → медленнее).
/// </summary>
public bool AdvanceHediffs(float days, float immunityScale = 1f)
{
var lethal = false;
for (var i = _hediffs.Count - 1; i >= 0; i--)
{
var h = _hediffs[i];
if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f)
{
continue; // статичный хедиф (гон) не прогрессирует
}
h.Severity += h.Def.SeverityPerDay * days;
h.Immunity += h.Def.ImmunityPerDay * days * immunityScale;
if (h.Immunity >= 1f)
{
_hediffs.RemoveAt(i); // иммунитет победил — выздоровление
continue;
}
if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity)
{
lethal = true;
}
_hediffs[i] = h;
}
return lethal;
}
/// <summary>Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности.</summary>
public static HealthState Create(Content.BodyDef? body, float bodySize)
{
var state = new HealthState();
if (body is { Parts.Length: > 0 })
{
var scale = MathF.Max(0.2f, bodySize);
state.Parts = new PartInstance[body.Parts.Length];
for (var i = 0; i < body.Parts.Length; i++)
{
var hp = body.Parts[i].MaxHp * scale;
state.Parts[i] = new PartInstance
{
Def = body.Parts[i],
Hp = hp,
MaxHp = hp,
};
}
}
state.RecomputeCapacities();
return state;
}
/// <summary>Несёт ли особь хедиф с данным именем дефа.</summary>
public bool Has(string defName)
{
foreach (var h in _hediffs)
{
if (h.Def.DefName == defName)
{
return true;
}
}
return false;
}
/// <summary>Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа.</summary>
public void Add(HediffDef def)
{
if (!Has(def.DefName))
{
_hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity });
}
}
/// <summary>Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва).</summary>
public void AddHediff(HediffDef def, float severity, float immunity)
{
if (!Has(def.DefName))
{
_hediffs.Add(
new Hediff
{
Def = def,
Severity = severity,
Immunity = immunity,
}
);
}
}
/// <summary>Снимает хедиф по имени дефа; возвращает, был ли он.</summary>
public bool Remove(string defName)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == defName)
{
_hediffs.RemoveAt(i);
return true;
}
}
return false;
}
/// <summary>
/// Травма (укус хищника): повреждает часть тела (выбор по весу попадания <c>coverage</c>), вызывает
/// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности.
/// Первый травматический источник урона — оживляет каркас здоровья A5.
/// </summary>
public void ApplyInjury(
float partDamage,
float bloodLoss,
HediffDef? bleeding,
float bleedSeverity,
Random rng
)
{
if (Parts.Length > 0 && partDamage > 0f)
{
var idx = PickPart(rng);
var part = Parts[idx];
part.Hp = MathF.Max(0f, part.Hp - partDamage);
Parts[idx] = part;
}
BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f);
if (bleeding is not null && bleedSeverity > 0f)
{
Intensify(bleeding, bleedSeverity);
}
RecomputeCapacities();
}
// Выбор задетой части по весу попадания (coverage); детерминируется переданным rng.
private int PickPart(Random rng)
{
var total = 0f;
foreach (var p in Parts)
{
total += p.Def?.Coverage ?? 0f;
}
if (total <= 0f)
{
return rng.Next(Parts.Length);
}
var roll = (float)rng.NextDouble() * total;
for (var i = 0; i < Parts.Length; i++)
{
if (Parts[i].Def is null)
{
continue;
}
roll -= Parts[i].Def.Coverage;
if (roll <= 0f)
{
return i;
}
}
return Parts.Length - 1;
}
/// <summary>
/// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет)
/// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений.
/// </summary>
public void Intensify(HediffDef def, float addSeverity)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == def.DefName)
{
var h = _hediffs[i];
h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f);
h.Immunity = 0f; // свежая рана — заживление с нуля
_hediffs[i] = h;
return;
}
}
_hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) });
}
}
/// <summary>Здоровье особи: managed-состояние со списком хедифов (см. <see cref="HealthState"/>).</summary>
public struct Health : IComponent
{
/// <summary>Состояние здоровья (хедифы; позже части тела/кровь).</summary>
public HealthState State;
}
/// <summary>
/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям
/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела)
/// для будущей разделки/падальщиков. Управляется <see cref="Sim.CorpseSystem"/>.
/// </summary>
public struct Corpse : IComponent
{
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта, разделки, сейва).</summary>
public int Species;
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays;
/// <summary>Стадия: 0 свежий, 1 гниющий, 2 скелет.</summary>
public int Stage;
/// <summary>Доступное мясо (∝ масса тела) — для разделки/падальщиков (фаза-горизонт).</summary>
public float Meat;
/// <summary>Линейный размер тела (для масштаба спрайта трупа), независимо от запаса мяса.</summary>
public float BodySize;
}
/// <summary>
/// Яйцо (тип рождения «яйцекладка»): отдельная сущность, отложенная яйцекладущей самкой вместо живых
/// родов. Несёт уже скрещённый геном будущего детёныша, вид и поколение; инкубируется во времени и
/// вылупляется в детёныша (<see cref="Sim.EggSystem"/>). Геном — managed-ссылка (как у трупа/беременности).
/// </summary>
public struct Egg : IComponent
{
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта при вылуплении и сейва).</summary>
public int Species;
/// <summary>Геном будущего детёныша (уже скрещён из родителей на момент кладки).</summary>
public Genome Genome;
/// <summary>Поколение будущего детёныша.</summary>
public int Generation;
/// <summary>Остаток инкубации в игровых днях; ≤0 — вылупление.</summary>
public float IncubateDays;
}
/// <summary>Вид запомненного: угроза (избегать), корм/вода/партнёр (идти) — расширяемо.</summary>
public enum MemoryKind : byte
{
Threat,
Food,
Water,
Mate,
}
/// <summary>Запись памяти: что, где (воспринятая позиция — грубая, если по запаху), насколько уверенно
/// (падает со временем — забывание), сколько дней назад.</summary>
public struct MemoryEntry
{
public MemoryKind Kind;
public Vector2 Pos;
public float Confidence;
public float AgeDays;
}
/// <summary>
/// Память особи (объём ∝ мозгу): ограниченный набор записей о замеченном (сейчас — угрозы). Чувства
/// пишут сюда воспринятое; со временем уверенность падает и запись забывается; ИИ использует память,
/// когда цель не воспринимается сейчас (избегает зоны недавнего хищника). Список — managed-ссылка.
/// </summary>
public struct Memory : IComponent
{
/// <summary>Активные записи (упорядочены по добавлению; слабейшая вытесняется при переполнении).</summary>
public List<MemoryEntry> Entries;
/// <summary>Сколько записей вмещает (∝ размеру мозга; 0 — память отсутствует).</summary>
public int Capacity;
/// <summary>Запоминает/освежает наблюдение: близкое того же вида — обновляет, иначе добавляет (с
/// вытеснением слабейшего при переполнении). Вне ёмкости (Capacity 0) — ничего не помнит.</summary>
public readonly void Remember(MemoryKind kind, Vector2 pos, float mergeDist)
{
if (Entries is null || Capacity <= 0)
{
return;
}
var mergeSq = mergeDist * mergeDist;
for (var i = 0; i < Entries.Count; i++)
{
if (Entries[i].Kind == kind && Vector2.DistanceSquared(Entries[i].Pos, pos) <= mergeSq)
{
Entries[i] = new MemoryEntry
{
Kind = kind,
Pos = pos,
Confidence = 1f,
AgeDays = 0f,
};
return;
}
}
if (Entries.Count >= Capacity)
{
var weakest = 0;
for (var i = 1; i < Entries.Count; i++)
{
if (Entries[i].Confidence < Entries[weakest].Confidence)
{
weakest = i;
}
}
Entries.RemoveAt(weakest);
}
Entries.Add(
new MemoryEntry
{
Kind = kind,
Pos = pos,
Confidence = 1f,
AgeDays = 0f,
}
);
}
}
/// <summary>Id навыков-эффектов (на них ссылается код начисления XP и применения бонуса).</summary>
public static class AnimalSkills
{
/// <summary>Добыча корма: эффективность выпаса/поедания растений.</summary>
public const string Foraging = "Foraging";
/// <summary>Охота: урон по добыче.</summary>
public const string Hunting = "Hunting";
/// <summary>Уклонение: скорость бегства от хищника.</summary>
public const string Evasion = "Evasion";
}
/// <summary>
/// Навыки особи (как нужды/способности — managed-массивы по индексам <see cref="Content.SkillSet"/>):
/// уровни [0..1] (растут от практики, угасают без неё) и страсть к каждому навыку (скорость обучения).
/// Есть у животных; те же навыки получат будущие люди. Добавление навыка не меняет структуру.
/// </summary>
public struct Skills : IComponent
{
/// <summary>Уровни навыков [0..1] по индексам <see cref="Content.SkillSet"/>.</summary>
public float[] Levels;
/// <summary>Страсть к каждому навыку (0 нет / 1 интерес / 2 страсть) — множитель обучения.</summary>
public byte[] Passions;
}
/// <summary>
/// Ноша существа (задел под перенос предметов/хаул): суммарная масса переносимого груза в кг. Перегруз
/// сверх грузоподъёмности (<see cref="Sim.Mass.CarryCapacity"/>) замедляет движение. Компонент появляется
/// у существа, когда оно что-то несёт; пока источников груза нет — спящий каркас под будущий инвентарь.
/// </summary>
public struct Carrier : IComponent
{
/// <summary>Суммарная масса переносимого груза (кг).</summary>
public float CarriedKg;
}
/// <summary>
/// Проглоченное семя (эндозоохория): травоядное, съевшее плодоносящее растение, какое-то время несёт
/// его семя в кишечнике, затем «высаживает» его в другом месте (<see cref="Sim.SeedDispersalSystem"/>) —
/// зверь становится разносчиком флоры. Геном — managed-ссылка (клон родительского растения).
/// </summary>
public struct GutSeed : IComponent
{
/// <summary>Индекс вида растения в <see cref="Content.PlantSet"/>.</summary>
public int PlantSpecies;
/// <summary>Геном будущего ростка (клон съеденного растения).</summary>
public Genome Genome;
/// <summary>Остаток времени до высадки в игровых днях; ≤0 — росток падает на землю.</summary>
public float DropInDays;
}
/// <summary>Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.</summary>
public struct ThoughtInstance
{
/// <summary>Деф мысли (сдвиг настроения и длительность).</summary>
public Content.ThoughtDef Def;
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays;
}
/// <summary>
/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается
/// <see cref="Sim.AnimalMoodSystem"/> из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у
/// тира 4+ (<see cref="Active"/> по размеру мозга) — у примитивных настроения нет. Низкое настроение
/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка
/// (как <c>AnimalNeeds.Values</c>): события добавляют мысли через <see cref="Add"/>.
/// </summary>
public struct Mood : IComponent
{
/// <summary>Текущее настроение [0,1]; у неактивного (тир &lt; 4) держится нейтральным.</summary>
public float Value;
/// <summary>Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются.</summary>
public bool Active;
/// <summary>Активные событийные мысли (затухают со временем).</summary>
public List<ThoughtInstance> Thoughts;
/// <summary>
/// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления
/// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды).
/// </summary>
public readonly void Add(Content.ThoughtDef? def)
{
if (Thoughts is null || def is null)
{
return;
}
for (var i = 0; i < Thoughts.Count; i++)
{
if (Thoughts[i].Def == def)
{
var refreshed = Thoughts[i];
refreshed.RemainingDays = def.DurationDays;
Thoughts[i] = refreshed;
return;
}
}
Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays });
}
}
/// <summary>
/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток
/// срока вынашивания и размер помёта. По истечении срока <see cref="Sim.AnimalPregnancySystem"/>
/// рожает детёнышей через <see cref="Genome.Breed"/> и снимает компонент.
/// </summary>
public struct Pregnant : IComponent
{
/// <summary>Геном отца на момент зачатия.</summary>
public Genome FatherGenome;
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays;
/// <summary>Сколько детёнышей родится.</summary>
public int Litter;
}
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
using System.Collections.Generic;
namespace LittleSim.Sim;
/// <summary>Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела.</summary>
public static class AnimalCapacities
{
public const string Consciousness = "Consciousness";
public const string Moving = "Moving";
public const string Breathing = "Breathing";
public const string BloodPumping = "BloodPumping";
public const string BloodFiltration = "BloodFiltration";
public const string Sight = "Sight";
public const string Hearing = "Hearing";
public const string Smell = "Smell";
public const string Touch = "Touch";
public const string Talking = "Talking";
public const string Eating = "Eating";
public const string Digestion = "Digestion";
/// <summary>Порядок отображения способностей в UI (сверху вниз). Прочие модовые — после, как есть.</summary>
public static readonly string[] DisplayOrder =
[
Consciousness,
Moving,
Sight,
Hearing,
Smell,
Touch,
Talking,
Eating,
Breathing,
BloodPumping,
BloodFiltration,
Digestion,
];
}
/// <summary>
/// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности
/// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь →
/// сердце/лёгкие → сознание → движение/зрение), как в модели RimWorld; неизвестные (модовые) проходят
/// сырыми. У здоровой особи (полное HP, кровь 1, боль 0) все способности = 1.
/// </summary>
public static class CapacityCalc
{
/// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1);
/// <paramref name="capacityScale"/> — общий множитель (старение), применяется к итогу без лишних аллокаций.</summary>
public static Dictionary<string, float> Compute(
PartInstance[] parts,
float blood,
float pain,
float capacityScale = 1f
)
{
// Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет
// только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность —
// статична (по дефам частей), поэтому уничтоженный орган всё равно покажет способность на 0%.
var raw = new Dictionary<string, float>(System.StringComparer.Ordinal);
var declared = new HashSet<string>(System.StringComparer.Ordinal);
foreach (var part in parts)
{
if (part.Def is null)
{
continue;
}
var fraction = part.Fraction;
foreach (var (capacity, contribution) in part.Def.Capacities)
{
declared.Add(capacity);
raw[capacity] = raw.GetValueOrDefault(capacity) + contribution * fraction;
}
}
float Raw(string id) => raw.GetValueOrDefault(id);
static float Clamp01(float v) =>
v < 0f ? 0f
: v > 1f ? 1f
: v;
// Базовые (независимые) способности: кровоснабжение/дыхание/фильтрация × уровень крови.
var bloodLevel = Clamp01(blood);
var bloodPumping = Raw(AnimalCapacities.BloodPumping) * bloodLevel;
var breathing = Raw(AnimalCapacities.Breathing) * bloodLevel;
var bloodFiltration = Raw(AnimalCapacities.BloodFiltration) * bloodLevel;
// Сознание зависит от кровоснабжения, дыхания и боли (модель RimWorld).
var consciousness =
Raw(AnimalCapacities.Consciousness)
* Clamp01(bloodPumping)
* Clamp01(breathing)
* (1f - Clamp01(pain));
var cons = Clamp01(consciousness);
var pump = Clamp01(bloodPumping);
// Производные способности зависят от сознания (и кровоснабжения для движения/пищеварения).
var computed = new Dictionary<string, float>(System.StringComparer.Ordinal)
{
[AnimalCapacities.BloodPumping] = bloodPumping,
[AnimalCapacities.Breathing] = breathing,
[AnimalCapacities.BloodFiltration] = bloodFiltration,
[AnimalCapacities.Consciousness] = consciousness,
[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * cons * pump,
[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * cons,
[AnimalCapacities.Hearing] = Raw(AnimalCapacities.Hearing) * cons,
[AnimalCapacities.Smell] = Raw(AnimalCapacities.Smell) * cons,
[AnimalCapacities.Touch] = Raw(AnimalCapacities.Touch) * cons,
[AnimalCapacities.Talking] = Raw(AnimalCapacities.Talking) * cons,
[AnimalCapacities.Eating] = Raw(AnimalCapacities.Eating) * cons,
[AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * pump,
};
// В результат — только объявленные телом способности: известные по формуле зависимостей, прочие
// (модовые) — сырым вкладом. Так у вида видны ровно его способности, а не весь каталог.
var scale = Clamp01(capacityScale);
var result = new Dictionary<string, float>(System.StringComparer.Ordinal);
foreach (var capacity in declared)
{
var value = computed.TryGetValue(capacity, out var v) ? v : Raw(capacity);
result[capacity] = scale < 1f ? value * scale : value;
}
return result;
}
}
+70
View File
@@ -0,0 +1,70 @@
using LittleSim.Content;
namespace LittleSim.Sim;
/// <summary>
/// Система веса (масса тел и грузоподъёмность). Масса — производная от РАЗМЕРА по кубическому закону
/// (вдвое крупнее ⇒ в 8 раз тяжелее): <c>масса = BaseMassKg × (размер / эталон)³</c>, где эталон —
/// базовый размер вида. Так детёныш/росток легче взрослого/зрелого, а ген размера двигает и массу.
/// Грузоподъёмность — задел под перенос предметов: доля массы тела × способность (манипуляция/движение).
/// </summary>
public static class Mass
{
/// <summary>Какую долю собственной массы существо может нести (×способность). Человек (руки) — больше.</summary>
public const float CarryFraction = 0.35f;
/// <summary>Доля массы тела, превращающаяся в мясо трупа (для разделки/падальщиков).</summary>
public const float MeatFraction = 0.5f;
private static float Cube(float v) => v * v * v;
/// <summary>Масса особи-животного (кг): эталон вида × куб (текущий линейный размер / эталонный).</summary>
public static float OfAnimal(AnimalDef def, in AnimalPhenotype traits, float stageScale)
{
var reference = def.Genome.GetValueOrDefault("GeneMaxBodySize", 1f);
if (reference <= 0f)
{
reference = 1f;
}
var linear = MathF.Max(0.01f, traits.BodySize) * MathF.Max(0.05f, stageScale);
return def.BaseMassKg * Cube(linear / reference);
}
/// <summary>Масса растения (кг): эталон вида × куб (размер текущей стадии / размер зрелой).</summary>
public static float OfPlant(PlantDef def, float stageSizeCells, float matureSizeCells)
{
var ratio = matureSizeCells > 0f ? stageSizeCells / matureSizeCells : 1f;
return def.BaseMassKg * Cube(ratio);
}
/// <summary>Грузоподъёмность (кг): доля массы тела × способность нести (манипуляция/движение, 0..1+).</summary>
public static float CarryCapacity(float bodyMassKg, float capability) =>
bodyMassKg * CarryFraction * MathF.Max(0f, capability);
/// <summary>
/// Метаболический множитель по закону Клайбера: расход энергии ∝ масса^0.75. Нормирован на эталонную
/// массу вида (взрослый базового размера = 1), поэтому крупные особи прожорливее, а молодняк — экономнее,
/// без межвидового взрыва баланса.
/// </summary>
public static float KleiberFactor(float massKg, float referenceMassKg)
{
if (referenceMassKg <= 0f || massKg <= 0f)
{
return 1f;
}
return MathF.Pow(massKg / referenceMassKg, 0.75f);
}
/// <summary>Множитель скорости от ноши: в пределах грузоподъёмности — 1; перегруз тормозит (до 0.25).</summary>
public static float LoadSpeedFactor(float carriedKg, float capacityKg)
{
if (capacityKg <= 0f || carriedKg <= capacityKg)
{
return 1f;
}
return MathF.Max(0.25f, capacityKg / carriedKg);
}
}
+2 -1
View File
@@ -56,7 +56,8 @@ public static class PlantFactory
CellFertility = cellFertility,
},
new PlantOrganism { Genome = genome, Traits = traits },
new Fruiting()
new Fruiting(),
new TemperatureStress()
);
}
+4 -5
View File
@@ -53,14 +53,13 @@ public sealed class PlantGrowthSystem(
ref var traits = ref orgs[i].Traits;
var light = lighting.SampleAt(t[i].Position);
// Температура — трапеция RimWorld: плато полного роста, жёсткие края (дормантность вне).
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var rate =
traits.Vigor
* traits.DefenseGrowthFactor() // цена защиты: токсичные/колючие растут медленнее
* Suitability.Gaussian(light, traits.OptimalLight, traits.LightTolerance)
* Suitability.Gaussian(
temperature,
traits.OptimalTemperature,
traits.TemperatureTolerance
)
* Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax)
* Suitability.Gaussian(
grow.CellFertility,
traits.OptimalFertility,
+50 -13
View File
@@ -2,6 +2,7 @@ using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
@@ -9,21 +10,30 @@ using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Жизненный цикл растений: размножение (наследование по Менделю) и смерть от старости. Запускается
/// не каждый кадр, а раз в <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку
/// (счётчик плотности + «представитель» вида/генома), собирает умерших по возрасту, затем зрелые с
/// вероятностью <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления
/// (зрелый сосед того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в
/// случайную клетку радиуса, если это суша и не превышен лимит плотности. Структурные изменения
/// (создание/удаление сущностей) применяются после проходов по запросу — это безопасно.
/// Жизненный цикл растений: размножение (наследование по Менделю), смерть от старости и от
/// температурного стресса (вымерзание/перегрев). Запускается не каждый кадр, а раз в
/// <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку (счётчик плотности +
/// «представитель» вида/генома), копит/рассасывает температурный стресс по текущей температуре,
/// собирает умерших (старость или превышенный стресс), затем зрелые с вероятностью
/// <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления (зрелый сосед
/// того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в случайную клетку
/// радиуса, если это суша и не превышен лимит плотности. Структурные изменения (создание/удаление
/// сущностей) применяются после проходов по запросу — это безопасно.
/// </summary>
public sealed class PlantLifecycleSystem : BaseSystem
{
private const float IntervalDays = 0.25f;
/// <summary>Сколько игровых дней дормантности вне диапазона роста убивают растение.</summary>
private const float LethalStressDays = 6f;
/// <summary>Во сколько раз стресс рассасывается быстрее в пригодной температуре, чем копится вне.</summary>
private const float StressRecovery = 1.5f;
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly Calendar _calendar;
private readonly Climate _climate;
private readonly GameClock _clock;
private readonly int _width;
private readonly int _height;
@@ -32,7 +42,12 @@ public sealed class PlantLifecycleSystem : BaseSystem
private readonly bool[] _cellLand;
private readonly int _densityCap;
private readonly Random _rng;
private readonly ArchetypeQuery<PlantGrowth, PlantOrganism, Transform2D> _query;
private readonly ArchetypeQuery<
PlantGrowth,
PlantOrganism,
Transform2D,
TemperatureStress
> _query;
private readonly int[] _count;
private readonly CellRep[] _rep;
@@ -45,6 +60,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
EntityStore store,
PlantSet plants,
Calendar calendar,
Climate climate,
GameClock clock,
int width,
int height,
@@ -58,6 +74,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_store = store;
_plants = plants;
_calendar = calendar;
_climate = climate;
_clock = clock;
_width = width;
_height = height;
@@ -66,7 +83,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_cellLand = cellLand;
_densityCap = densityCap;
_rng = new Random(seed);
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D>();
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D, TemperatureStress>();
_count = new int[width * height];
_rep = new CellRep[width * height];
}
@@ -82,13 +99,13 @@ public sealed class PlantLifecycleSystem : BaseSystem
var elapsed = _accumulator;
_accumulator = 0f;
BuildGridAndCollect();
BuildGridAndCollect(elapsed);
Reproduce(elapsed);
Apply();
}
// Проход 1: сетка плотности + представители, сбор умерших и зрелых-кандидатов.
private void BuildGridAndCollect()
// Проход 1: сетка плотности + представители, температурный стресс, сбор умерших и зрелых-кандидатов.
private void BuildGridAndCollect(float elapsed)
{
Array.Clear(_count);
for (var c = 0; c < _rep.Length; c++)
@@ -100,11 +117,14 @@ public sealed class PlantLifecycleSystem : BaseSystem
_candidates.Clear();
_births.Clear();
foreach (var (growths, organisms, transforms, entities) in _query.Chunks)
var temperature = _climate.Temperature; // глобальная — едина для всех растений в этот тик
foreach (var (growths, organisms, transforms, stresses, entities) in _query.Chunks)
{
var g = growths.Span;
var orgs = organisms.Span;
var t = transforms.Span;
var st = stresses.Span;
for (var i = 0; i < g.Length; i++)
{
ref var grow = ref g[i];
@@ -115,6 +135,23 @@ public sealed class PlantLifecycleSystem : BaseSystem
continue;
}
// Температурный стресс: вне диапазона роста (трапеция = 0) копится, внутри — рассасывается.
var (tMin, tLow, tHigh, tMax) = org.Traits.TemperatureBand();
ref var stress = ref st[i].Days;
if (Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax) <= 0f)
{
stress += elapsed;
if (stress >= LethalStressDays)
{
_deaths.Add(entities.EntityAt(i)); // вымерзло/перегрелось
continue;
}
}
else
{
stress = MathF.Max(0f, stress - elapsed * StressRecovery);
}
var pos = t[i].Position;
var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1);
var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1);
+56 -1
View File
@@ -5,7 +5,7 @@ using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>genes.json</c>):
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую.
/// Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
@@ -15,6 +15,8 @@ public struct PlantPhenotype
public float LightTolerance;
public float OptimalTemperature;
public float TemperatureTolerance;
public float ColdHardiness;
public float HeatHardiness;
public float OptimalFertility;
public float FertilityTolerance;
public float Vigor;
@@ -30,9 +32,31 @@ public struct PlantPhenotype
public float HarvestAmount;
public float LeafHue;
// --- Защита (коэволюция с травоядными) ---
/// <summary>Токсичность [0..1]: отравляет поедателя; цена — замедление роста.</summary>
public float Toxicity;
/// <summary>Шипы [0..1]: травмируют поедателя; цена — замедление роста.</summary>
public float Thorns;
/// <summary>Вкусность [0..1]: обратная привлекательность для умных травоядных (избегание в C2).</summary>
public float Palatability;
/// <summary>Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.</summary>
public bool IsVariant;
/// <summary>
/// Четыре точки температурной трапеции роста (модель RimWorld): жёсткий минимум, нижний край плато,
/// верхний край плато, жёсткий максимум. Плато — оптимум ± толерантность; жёсткие края отстоят от
/// плато на морозо-/жаростойкость. Кормит <see cref="MrGameEng.AI.Suitability.Trapezoid"/>.
/// </summary>
public readonly (float Min, float OptimalLow, float OptimalHigh, float Max) TemperatureBand()
{
var low = OptimalTemperature - TemperatureTolerance;
var high = OptimalTemperature + TemperatureTolerance;
return (low - ColdHardiness, low, high, high + HeatHardiness);
}
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static PlantPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
@@ -43,6 +67,8 @@ public struct PlantPhenotype
LightTolerance = T("lightTolerance"),
OptimalTemperature = T("optimalTemperature"),
TemperatureTolerance = T("temperatureTolerance"),
ColdHardiness = T("coldHardiness"),
HeatHardiness = T("heatHardiness"),
OptimalFertility = T("optimalFertility"),
FertilityTolerance = T("fertilityTolerance"),
Vigor = T("vigor"),
@@ -55,9 +81,26 @@ public struct PlantPhenotype
FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)),
HarvestAmount = T("harvestAmount"),
LeafHue = T("leafHue"),
Toxicity = T("toxicity"),
Thorns = T("thorns"),
Palatability = T("palatability"),
IsVariant = T("variant") >= 0.5f,
};
}
// Цена защиты: токсичность и шипы отнимают ресурсы у роста. Без этой платы отбор гнал бы защиту к
// максимуму у всех растений и коэволюция бы встала — а так под слабым выпасом выгоднее расти быстрее.
private const float ToxicityGrowthCost = 0.4f;
private const float ThornsGrowthCost = 0.3f;
private const float MinGrowthFactor = 0.2f;
/// <summary>Множитель скорости роста с учётом цены защиты (1 — без защиты, ≥ <c>MinGrowthFactor</c>).</summary>
public readonly float DefenseGrowthFactor() =>
Math.Clamp(
1f - ToxicityGrowthCost * Toxicity - ThornsGrowthCost * Thorns,
MinGrowthFactor,
1f
);
}
/// <summary>
@@ -85,3 +128,15 @@ public struct Fruiting : IComponent
/// <summary>Накоплено зрелых плодов (0..FruitYield).</summary>
public float RipeFruit;
}
/// <summary>
/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста
/// (трапеция <see cref="PlantPhenotype.TemperatureBand"/> даёт ноль — слишком холодно или жарко),
/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение
/// (вымерзание/перегрев). Управляется <c>PlantLifecycleSystem</c>.
/// </summary>
public struct TemperatureStress : IComponent
{
/// <summary>Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель.</summary>
public float Days;
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
using MrGameEng.Input;
namespace LittleSim.Sim;
/// <summary>
/// Текущее выделение игрока (как в RimWorld): id выбранной сущности или -1. Общий источник истины
/// для системы пикинга (<see cref="SelectionSystem"/>) и панели осмотра (<c>InspectPanel</c>):
/// первая пишет, вторая читает и рисует рамку/инфо. Хранит id, а не <see cref="Entity"/>, чтобы
/// безопасно переживать удаление сущности (растение умерло) — валидность проверяется через
/// <c>EntityStore.TryGetEntityById</c>.
/// </summary>
public sealed class Selection
{
/// <summary>Runtime-id выбранной сущности, или -1, если ничего не выбрано.</summary>
public int EntityId { get; private set; } = -1;
/// <summary>Есть ли активное выделение.</summary>
public bool HasSelection => EntityId >= 0;
/// <summary>Выбрать сущность по id.</summary>
public void Select(int entityId) => EntityId = entityId;
/// <summary>Снять выделение.</summary>
public void Clear() => EntityId = -1;
}
/// <summary>
/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности
/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем
/// (консоль/инспектор) или активен <paramref name="blocked"/> (меню-пауза), и игнорирует клики над
/// UI-панелями (<paramref name="overUi"/>). Выбор работает и на паузе — мир можно осматривать стоя.
/// </summary>
public sealed class SelectionSystem(
EntityStore store,
InputManager input,
Renderer2D renderer,
Selection selection,
Func<bool> blocked,
Func<bool> overUi
) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (blocked() || overUi())
{
return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира
}
if (input.IsMousePressed(MouseButton.Right))
{
selection.Clear();
return;
}
if (!input.IsMousePressed(MouseButton.Left))
{
return;
}
var mouse = input.MousePosition;
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
if (TryPick(world, out var id))
{
selection.Select(id);
}
else
{
selection.Clear(); // клик по пустой земле снимает выделение
}
}
// Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта
// накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают
// спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий.
private bool TryPick(Vector2 world, out int id)
{
id = -1;
var best = float.MaxValue;
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>()
.Chunks
)
{
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, AnimalOrganism>()
.Chunks
)
{
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
return id >= 0;
}
// Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность.
private static void Scan(
Vector2 world,
Span<Transform2D> transforms,
Span<Sprite> sprites,
ChunkEntities entities,
ref int id,
ref float best
)
{
for (var i = 0; i < transforms.Length; i++)
{
if (sprites[i].Region is not { } region)
{
continue;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transforms[i],
region,
sprites[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < best)
{
best = distance;
id = entities.EntityAt(i).Id;
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Рамка выделения в стиле RimWorld: четыре угловые скобки вокруг выбранного объекта. Реализована
/// как четыре спрайт-сущности на слое-оверлее (<see cref="Scenes.GameLayers.Selection"/>) — их рисует
/// штатный спрайт-рендер, а одна и та же текстура уголка (<c>ui/overlays/selectionbracket</c>)
/// зеркалится во все четыре угла флагами <see cref="SpriteFlip"/> (батчер при флипе меняет только UV,
/// квадрат остаётся на месте). Скобки не имеют <c>PlantGrowth</c>, поэтому не попадают под пикинг и
/// прочие запросы растений. <see cref="Show"/> двигает их к объекту, <see cref="Hide"/> прячет.
/// </summary>
public sealed class SelectionBrackets
{
// По уголку на каждый угол: исходный арт лежит в левом-нижнем, флип разносит его по остальным.
private static readonly SpriteFlip[] Corners =
[
SpriteFlip.None,
SpriteFlip.X,
SpriteFlip.Y,
SpriteFlip.X | SpriteFlip.Y,
];
// Приглушённый голубой (раньше был ярче) — рамка обозначает выбор, но не «кричит».
private static readonly Color Tint = new(110, 150, 185);
private readonly Entity[] _brackets = new Entity[4];
private readonly Texture2DRegion _region;
/// <summary>Создаёт четыре скрытые скобки на слое <paramref name="layer"/> из текстуры <paramref name="region"/>.</summary>
public SelectionBrackets(EntityStore store, Texture2DRegion region, LayerId layer)
{
_region = region;
var origin = new Vector2(region.Width / 2f, region.Height / 2f);
for (var i = 0; i < _brackets.Length; i++)
{
_brackets[i] = store.CreateEntity(
Transform2D.At(Vector2.Zero),
new Sprite
{
Region = null, // скрыта, пока ничего не выбрано
Color = Tint,
Origin = origin, // центр текстуры → квад центрируется на позиции
Layer = layer,
Flip = Corners[i],
}
);
}
}
/// <summary>Ставит рамку вокруг точки <paramref name="center"/> с полуразмером <paramref name="radius"/>.</summary>
public void Show(Vector2 center, float radius)
{
// Квад чуть больше объекта — скобки садятся снаружи (как в RimWorld); арт-уголок ~⅓ ширины.
// radius — полудиагональ спрайта (описывающая окружность), поэтому множитель скромный, иначе
// рамка выходит заметно крупнее самой особи.
var scale = new Vector2(radius * 1.4f / _region.Width);
for (var i = 0; i < _brackets.Length; i++)
{
ref var transform = ref _brackets[i].GetComponent<Transform2D>();
ref var sprite = ref _brackets[i].GetComponent<Sprite>();
transform.Position = center;
transform.Scale = scale;
sprite.Region = _region;
}
}
/// <summary>Прячет рамку (сбрасывает регион — рендер пропускает спрайты без текстуры).</summary>
public void Hide()
{
for (var i = 0; i < _brackets.Length; i++)
{
_brackets[i].GetComponent<Sprite>().Region = null;
}
}
}
+7 -3
View File
@@ -125,13 +125,13 @@ public sealed class GodCameraSystem(
Entity cameraEntity,
InputManager input,
Renderer2D renderer,
MrGameEng.DevConsole.DevConsole console,
MrGameEng.DevConsole.DevConsole? console,
Func<bool>? blocked = null
) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (console.IsOpen || blocked?.Invoke() == true)
if (console?.IsOpen == true || blocked?.Invoke() == true)
{
return;
}
@@ -155,11 +155,15 @@ public sealed class GodCameraSystem(
if (newZoom != oldZoom)
{
// Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же.
// Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция
// ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position —
// иначе у краёв карты/при широком обзоре зум «уезжает» от курсора.
var anchor = renderer.ScreenToWorld(
new Vector2(input.MousePosition.X, input.MousePosition.Y)
);
var effective = renderer.Camera.WorldCenter;
camera.Zoom = newZoom;
camera.Position = anchor - (anchor - camera.Position) * (oldZoom / newZoom);
camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
using System.Collections.Generic;
using Microsoft.Xna.Framework;
namespace LittleSim.Sim;
/// <summary>Запись пространственного индекса: позиция (для раскладки по ячейкам) и id (для tie-break/исключения себя).</summary>
public interface ISpatialEntry
{
Vector2 Pos { get; }
int Id { get; }
}
/// <summary>Растение для поиска корма: позиция/id + признаки, нужные выбору (токсичность, вкусность).</summary>
public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability)
: ISpatialEntry;
/// <summary>Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость)
/// и заметность (камуфляж/запах/шум) — чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты.</summary>
public readonly record struct NeighborEntry(
int Id,
Vector2 Pos,
int Species,
float BodySize,
bool EatsMeat,
bool IsMale,
bool IsAdult,
float Camouflage,
float Scent,
float Noise
) : ISpatialEntry;
/// <summary>
/// Пространственный индекс (равномерная сетка ячеек) для радиус-запросов: вместо прохода по ВСЕМ
/// сущностям на каждый запрос — только из ближних ячеек. Перестраивается раз в кадр (O(n) линейно);
/// списки ячеек берутся из пула, поэтому перестройка не аллоцирует. Обход детерминирован (вложенные
/// циклы по ячейкам + порядок вставки); выбор всё равно ломает ничьи по меньшему id — детерминизм цел.
/// </summary>
public sealed class SpatialGrid<T>
where T : struct, ISpatialEntry
{
private readonly float _cell;
private readonly Dictionary<(int, int), List<T>> _cells = new();
private readonly Stack<List<T>> _pool = new();
public SpatialGrid(float cellSize) => _cell = cellSize > 0f ? cellSize : 1f;
/// <summary>Очищает сетку (списки ячеек возвращаются в пул).</summary>
public void Clear()
{
foreach (var list in _cells.Values)
{
list.Clear();
_pool.Push(list);
}
_cells.Clear();
}
/// <summary>Добавляет элемент в его ячейку.</summary>
public void Add(T item)
{
var key = CellOf(item.Pos);
if (!_cells.TryGetValue(key, out var list))
{
list = _pool.Count > 0 ? _pool.Pop() : new List<T>();
_cells[key] = list;
}
list.Add(item);
}
/// <summary>Собирает в <paramref name="results"/> элементы из ячеек, перекрывающих радиус (точную
/// дистанцию проверяет вызывающий). Буфер переиспользуется — без аллокаций на запрос.</summary>
public void Collect(Vector2 center, float radius, List<T> results)
{
results.Clear();
var minX = (int)MathF.Floor((center.X - radius) / _cell);
var maxX = (int)MathF.Floor((center.X + radius) / _cell);
var minY = (int)MathF.Floor((center.Y - radius) / _cell);
var maxY = (int)MathF.Floor((center.Y + radius) / _cell);
for (var cx = minX; cx <= maxX; cx++)
{
for (var cy = minY; cy <= maxY; cy++)
{
if (_cells.TryGetValue((cx, cy), out var list))
{
results.AddRange(list);
}
}
}
}
private (int, int) CellOf(Vector2 p) =>
((int)MathF.Floor(p.X / _cell), (int)MathF.Floor(p.Y / _cell));
}
+299
View File
@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Graphics;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Дев-спавнер в духе RimWorld (только в режиме разработчика): боковая панель со списком по категориям.
/// Виды животных/растений берутся ИЗ ДЕФОВ (модовый контент появляется сам); «инструменты» (урон/смерть/
/// болезнь) и прочие модовые пункты — из <see cref="DevToolDef"/>. Спавн-пункт «берётся в руку»: клик по
/// миру создаёт сущность в курсоре (ПКМ снимает). Инструменты-действия применяются к выбранной особи
/// сразу по нажатию. Так удобно сравнивать сущности и наносить им урон. Управляет <see cref="DevSpawnerSystem"/>.
/// </summary>
public sealed class DevSpawner
{
private readonly EntityStore _store;
private readonly AnimalSet _animals;
private readonly PlantSet _plants;
private readonly NeedSet _needs;
private readonly SkillSet _skills;
private readonly GameContent _content;
private readonly Selection _selection;
private readonly float[] _cellFertility;
private readonly int _width;
private readonly int _height;
private readonly int _cellSize;
private readonly HediffDef? _fever;
private readonly HediffDef? _bleeding;
private readonly Random _rng = new(0x5A11);
private readonly VerticalStackPanel _panel;
private readonly Label _title;
private string? _armedKind;
private string? _armedTarget;
public DevSpawner(
EntityStore store,
AnimalSet animals,
PlantSet plants,
NeedSet needs,
SkillSet skills,
GameContent content,
Selection selection,
float[] cellFertility,
int width,
int height,
int cellSize
)
{
_store = store;
_animals = animals;
_plants = plants;
_needs = needs;
_skills = skills;
_content = content;
_selection = selection;
_cellFertility = cellFertility;
_width = width;
_height = height;
_cellSize = cellSize;
_fever = content.Defs.TryGet<HediffDef>("Fever", out var f) ? f : null;
_bleeding = content.Defs.TryGet<HediffDef>("Bleeding", out var b) ? b : null;
var lang = content.Languages;
_title = new Label { TextColor = Ui.Accent, Wrap = true };
var list = new VerticalStackPanel { Spacing = 2 };
// Инструменты-действия (по выбранной особи) + модовые DevToolDef.
Header(list, lang.Get("dev.tools"));
Entry(list, lang.Get("dev.damage"), () => ActOnSelection("damage", ""));
Entry(list, lang.Get("dev.kill"), () => ActOnSelection("kill", ""));
Entry(list, lang.Get("dev.infect"), () => ActOnSelection("infect", ""));
foreach (var tool in content.Defs.All<DevToolDef>())
{
var kind = tool.Kind;
var target = tool.Target;
var label = lang.Get(tool.Label);
if (kind is "spawn-animal" or "spawn-plant")
{
Entry(list, label, () => Arm(kind, target));
}
else
{
Entry(list, label, () => ActOnSelection(kind, target));
}
}
// Виды — автоматически из дефов (включая модовые): «взять в руку» спавн.
Header(list, lang.Get("dev.animals"));
foreach (var name in content.Defs.NamesOf("Animal"))
{
var defName = name;
Entry(list, "+ " + lang.Get(AnimalLabel(defName)), () => Arm("spawn-animal", defName));
}
Header(list, lang.Get("dev.plants"));
foreach (var name in content.Defs.NamesOf("Plant"))
{
var defName = name;
Entry(list, "+ " + lang.Get(PlantLabel(defName)), () => Arm("spawn-plant", defName));
}
_panel = new VerticalStackPanel
{
Spacing = 6,
Padding = new Thickness(12),
Width = 250,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 14, 12, 0),
Background = new SolidBrush(new Color(10, 13, 18, 240)),
Border = new SolidBrush(new Color(96, 134, 168)),
BorderThickness = new Thickness(2),
Visible = false,
};
_panel.Widgets.Add(new Label { Text = lang.Get("dev.title"), TextColor = Ui.Accent });
_panel.Widgets.Add(_title);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(new ScrollViewer { Height = 460, Content = list });
UpdateTitle();
}
/// <summary>Виджет панели (добавляется в корень экрана сцены).</summary>
public Widget Panel => _panel;
/// <summary>Взведён ли спавн-инструмент (клик по миру создаёт сущность).</summary>
public bool ArmedSpawn => _armedKind is "spawn-animal" or "spawn-plant";
/// <summary>Показать/скрыть панель (горячая клавиша из системы).</summary>
public void Toggle() => _panel.Visible = !_panel.Visible;
/// <summary>Снять «инструмент с руки» (ПКМ).</summary>
public void Disarm()
{
_armedKind = null;
_armedTarget = null;
UpdateTitle();
}
/// <summary>Создаёт взведённую сущность в точке мира (вызывает система по клику ЛКМ).</summary>
public void SpawnAt(Vector2 world)
{
if (
_armedKind == "spawn-animal"
&& _content.Defs.TryGet<AnimalDef>(_armedTarget ?? "", out var adef)
)
{
var idx = _animals.IndexOf(adef);
var age = adef.Genome.GetValueOrDefault("GeneMaturityAge", 90f); // взрослый
AnimalFactory.Create(
_store,
_animals,
_needs,
_skills,
idx,
world,
age,
_animals.GenerateGenome(idx, _rng),
generation: 0,
_cellSize
);
}
else if (
_armedKind == "spawn-plant"
&& _content.Defs.TryGet<PlantDef>(_armedTarget ?? "", out var pdef)
)
{
var idx = _plants.IndexOf(pdef);
var cx = Math.Clamp((int)(world.X / _cellSize), 0, _width - 1);
var cy = Math.Clamp((int)(world.Y / _cellSize), 0, _height - 1);
var cell = cy * _width + cx;
var fertility = cell >= 0 && cell < _cellFertility.Length ? _cellFertility[cell] : 1f;
PlantFactory.Create(
_store,
_plants,
idx,
world,
_plants[idx].MaturityDays, // зрелое
_plants[idx].Template.Generate(_rng),
fertility,
_cellSize
);
}
}
// Применяет инструмент-действие к выбранной особи (урон/смерть/болезнь) — для сравнения/тестов.
private void ActOnSelection(string kind, string target)
{
if (
!_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var e)
|| e.IsNull
)
{
return;
}
switch (kind)
{
case "kill" when e.HasComponent<AnimalOrganism>():
AnimalFactory.Die(_store, _animals, e, _cellSize);
break;
case "damage" when e.TryGetComponent<Health>(out var h) && h.State is not null:
h.State.ApplyInjury(15f, 0.08f, _bleeding, 1.5f, _rng);
break;
case "infect" when e.TryGetComponent<Health>(out var h2) && h2.State is not null:
var def =
target.Length > 0 && _content.Defs.TryGet<HediffDef>(target, out var hd)
? hd
: _fever;
if (def is not null)
{
h2.State.Add(def);
}
break;
}
}
private void Arm(string kind, string target)
{
_armedKind = kind;
_armedTarget = target;
UpdateTitle();
}
private void UpdateTitle() =>
_title.Text = _armedKind is null
? _content.Languages.Get("dev.hint")
: _content.Languages.Format("dev.armed", _armedTarget ?? "");
private static void Header(VerticalStackPanel list, string text) =>
list.Widgets.Add(new Label { Text = text, TextColor = Ui.Muted });
private static void Entry(VerticalStackPanel list, string text, Action onClick)
{
var button = new TextButton { Text = text };
button.Click += (_, _) => onClick();
list.Widgets.Add(button);
}
private string AnimalLabel(string defName) =>
_content.Defs.TryGet<AnimalDef>(defName, out var def) ? def.Label : defName;
private string PlantLabel(string defName) =>
_content.Defs.TryGet<PlantDef>(defName, out var def) ? def.Label : defName;
}
/// <summary>
/// Управление дев-спавнером: горячая клавиша F9 открывает/закрывает панель; при взведённом спавн-
/// инструменте ЛКМ по миру (не над UI) создаёт сущность в курсоре, ПКМ снимает инструмент.
/// </summary>
public sealed class DevSpawnerSystem(Renderer2D renderer, DevSpawner spawner, Func<bool> overUi)
: BaseSystem
{
private bool _prevToggle;
private bool _prevLeft;
private bool _prevRight;
protected override void OnUpdateGroup()
{
var keyboard = Keyboard.GetState();
var toggle = keyboard.IsKeyDown(Keys.F9);
if (toggle && !_prevToggle)
{
spawner.Toggle();
}
_prevToggle = toggle;
var mouse = Mouse.GetState();
var left = mouse.LeftButton == ButtonState.Pressed;
var right = mouse.RightButton == ButtonState.Pressed;
if (spawner.ArmedSpawn && left && !_prevLeft && !overUi())
{
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
spawner.SpawnAt(world);
}
if (right && !_prevRight)
{
spawner.Disarm();
}
_prevLeft = left;
_prevRight = right;
}
}
+693
View File
@@ -0,0 +1,693 @@
using System;
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Mods;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками. Набор вкладок
/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Навыки/Здоровье/Настроение».
/// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и пересобирает тело
/// активной вкладки каждый кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Числовые
/// показатели (нужды, навыки, способности, кровь/боль, настроение) рисуются цветными полосами
/// (<see cref="StatBar"/>), описательные данные — строками с заголовками-секциями. Тело прокручивается
/// (<see cref="ScrollViewer"/> фиксированной высоты), поэтому панель не «прыгает» при смене вкладок.
/// Все строки идут через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary>
internal sealed class InspectPanel
{
private enum Tab
{
Overview,
Genes,
Products,
Needs,
Skills,
Health,
Mood,
}
private static readonly Tab[] PlantTabs = [Tab.Overview, Tab.Genes, Tab.Products];
private static readonly Tab[] AnimalTabs =
[
Tab.Overview,
Tab.Needs,
Tab.Skills,
Tab.Health,
Tab.Mood,
];
// Палитра тела панели и полос.
private static readonly Color Body = new(210, 216, 226);
private static readonly Color Warn = new(220, 150, 90);
private static readonly Color Neutral = new(86, 132, 178); // нейтральная величина (навык, влечение)
private static readonly IBrush ActiveTab = new SolidBrush(new Color(40, 54, 72));
private const int BarWidth = 300;
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly SkillSet _skills;
private readonly GameContent _content;
private readonly Climate _climate;
private readonly Selection _selection;
private readonly SelectionBrackets _brackets;
private readonly VerticalStackPanel _panel;
private readonly Label _title;
private readonly VerticalStackPanel _bodyPanel;
private readonly List<(Tab Tab, TextButton Button)> _tabs = [];
private Tab _tab = Tab.Overview;
public InspectPanel(
EntityStore store,
PlantSet plants,
AnimalSet animals,
NeedSet needs,
SkillSet skills,
GameContent content,
Climate climate,
Selection selection,
SelectionBrackets brackets
)
{
_store = store;
_plants = plants;
_animals = animals;
_needs = needs;
_skills = skills;
_content = content;
_climate = climate;
_selection = selection;
_brackets = brackets;
_title = new Label { TextColor = Ui.Accent, Wrap = true };
var tabBar = new HorizontalStackPanel { Spacing = 2 };
AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
AddTab(tabBar, Tab.Products, "inspect.tab.products");
AddTab(tabBar, Tab.Needs, "inspect.tab.needs");
AddTab(tabBar, Tab.Skills, "inspect.tab.skills");
AddTab(tabBar, Tab.Health, "inspect.tab.health");
AddTab(tabBar, Tab.Mood, "inspect.tab.mood");
// Тело вкладки пересобирается каждый кадр; живёт в скролле фиксированной высоты, чтобы панель
// держала постоянный размер и не «прыгала» при переключении вкладок и появлении/исчезании строк.
_bodyPanel = new VerticalStackPanel { Spacing = 3 };
var scroll = new ScrollViewer
{
Content = _bodyPanel,
Height = 280,
ShowHorizontalScrollBar = false,
};
_panel = new VerticalStackPanel
{
Spacing = 6,
Padding = new Thickness(14),
Width = 360,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(12, 0, 0, 14),
Background = new SolidBrush(new Color(10, 13, 18, 240)),
Border = new SolidBrush(new Color(96, 134, 168)),
BorderThickness = new Thickness(2),
Visible = false,
};
_panel.Widgets.Add(_title);
_panel.Widgets.Add(tabBar);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(scroll);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(
new Label
{
Text = _content.Languages.Get("inspect.hint"),
TextColor = Ui.Muted,
Wrap = true,
}
);
}
/// <summary>Боковая инфо-панель — добавляется в корень экрана.</summary>
public VerticalStackPanel Panel => _panel;
/// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку к объекту и пересобирает тело активной вкладки. Осматриваются растения и животные.
/// </summary>
public void Refresh()
{
if (
!_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{
ClearSelection();
return;
}
var isPlant = entity.HasComponent<PlantGrowth>() && entity.HasComponent<PlantOrganism>();
var isAnimal = entity.HasComponent<AnimalOrganism>();
if (!isPlant && !isAnimal)
{
ClearSelection(); // не контент (труп/прочее) — пока не осматривается
return;
}
var transform = entity.GetComponent<Transform2D>();
var sprite = entity.GetComponent<Sprite>();
if (sprite.Region is { } region)
{
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transform,
region,
sprite.Origin
);
_brackets.Show(center, radius);
}
_bodyPanel.Widgets.Clear();
if (isPlant)
{
UpdatePlant(entity);
}
else
{
UpdateAnimal(entity);
}
_panel.Visible = true;
}
private void ClearSelection()
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение/зверь погибли) или не осматривается
}
_panel.Visible = false;
_brackets.Hide();
}
private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{
var button = new TextButton
{
Text = _content.Languages.Get(labelKey),
Padding = new Thickness(6, 3),
};
button.Click += (_, _) => _tab = tab;
bar.Widgets.Add(button);
_tabs.Add((tab, button));
}
// Показывает только вкладки текущего типа, подсвечивает активную (цвет + фон); если активная не из
// набора — откатывает на первую (Обзор) при смене типа выбранного объекта.
private void ShowTabs(Tab[] valid)
{
if (Array.IndexOf(valid, _tab) < 0)
{
_tab = valid[0];
}
foreach (var (tab, button) in _tabs)
{
var visible = Array.IndexOf(valid, tab) >= 0;
button.Visible = visible;
var active = tab == _tab;
button.TextColor = active ? Ui.Accent : Ui.Muted;
button.Background = active ? ActiveTab : null;
}
}
// — Виджеты-кирпичики тела вкладки —
private void Header(string key) =>
_bodyPanel.Widgets.Add(
new Label
{
Text = _content.Languages.Get(key),
TextColor = Ui.Accent,
Margin = new Thickness(0, 6, 0, 1),
}
);
private void Line(string text, Color? color = null) =>
_bodyPanel.Widgets.Add(
new Label
{
Text = text,
TextColor = color ?? Body,
Wrap = true,
}
);
// Полоса показателя: ширина заливки = доля, цвет задаётся вызывающим (см. GoodBad/Neutral).
private void Bar(string name, float fraction, Color color, string? value = null) =>
_bodyPanel.Widgets.Add(
new StatBar(BarWidth, name, fraction, value ?? Pct(fraction), color)
);
private static string Pct(float fraction) =>
((int)MathF.Round(Math.Clamp(fraction, 0f, 1f) * 100f)).ToString() + "%";
// Цвет «хорошо↔плохо»: 0 — красный, 0.5 — янтарный, 1 — зелёный (приглушённые, чтобы белый текст читался).
private static Color GoodBad(float fraction)
{
fraction = Math.Clamp(fraction, 0f, 1f);
var bad = new Color(176, 72, 66);
var mid = new Color(176, 146, 58);
var good = new Color(86, 158, 96);
return fraction < 0.5f
? Color.Lerp(bad, mid, fraction * 2f)
: Color.Lerp(mid, good, (fraction - 0.5f) * 2f);
}
private void UpdatePlant(Entity entity)
{
var languages = _content.Languages;
ref readonly var grow = ref entity.GetComponent<PlantGrowth>();
ref readonly var org = ref entity.GetComponent<PlantOrganism>();
var species = _plants[grow.Species];
var def = species.Def;
var traits = org.Traits;
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
ShowTabs(PlantTabs);
switch (_tab)
{
case Tab.Overview:
BuildOverview(entity, grow, traits, species);
break;
case Tab.Genes:
BuildGenes(traits);
break;
case Tab.Products:
BuildProducts(entity, def, traits);
break;
}
}
private void UpdateAnimal(Entity entity)
{
var languages = _content.Languages;
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var sp = _animals[org.Species];
_title.Text = languages.Get(sp.Def.Label);
ShowTabs(AnimalTabs);
switch (_tab)
{
case Tab.Overview:
BuildAnimalOverview(entity, org, sp);
break;
case Tab.Needs:
BuildAnimalNeeds(entity);
break;
case Tab.Skills:
BuildAnimalSkills(entity);
break;
case Tab.Health:
BuildAnimalHealth(entity);
break;
case Tab.Mood:
BuildAnimalMood(entity);
break;
}
}
private void BuildOverview(
Entity entity,
in PlantGrowth grow,
in PlantPhenotype traits,
PlantSet.Species species
)
{
var languages = _content.Languages;
var lastStage = species.Stages.Length - 1;
var mature = grow.Stage >= lastStage;
Line(
languages.Format(
"inspect.stage",
StageLabel(species.Def, grow.Stage),
grow.Stage + 1,
species.Stages.Length
)
);
if (mature)
{
Line(languages.Get("inspect.mature"));
}
else
{
var from = species.Stages[grow.Stage].EnterDay;
var to = species.Stages[grow.Stage + 1].EnterDay;
var pct = to > from ? (grow.AgeDays - from) / (to - from) : 0f;
Bar(languages.Get("inspect.tab.overview"), Math.Clamp(pct, 0f, 1f), Neutral);
}
Line(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan));
// Вес растения (∝ кубу размера текущей стадии относительно зрелой).
var plantMass = Mass.OfPlant(
species.Def,
species.Stages[grow.Stage].SizeCells,
species.Stages[^1].SizeCells
);
Line(languages.Format("inspect.plantmass", plantMass));
// Состояние по температуре (рост/покой) и накопленный стресс.
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var temperature = _climate.Temperature;
var suit = Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax);
var state =
suit <= 0f
? languages.Get(
temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
)
: languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
Line(languages.Format("inspect.state", state));
if (entity.TryGetComponent<TemperatureStress>(out var stress) && stress.Days > 0.05f)
{
Line(languages.Format("inspect.stress", stress.Days), Warn);
}
Line(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh));
}
private void BuildGenes(in PlantPhenotype traits)
{
var languages = _content.Languages;
Line(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan));
Line(languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility));
Line(
languages.Format("inspect.gene.repro", traits.DispersalRange, traits.ReproduceInterval)
);
Line(languages.Format("inspect.gene.repro2", traits.SelfPollination, traits.MutationRate));
Line(
languages.Format(
"inspect.gene.hardy",
traits.ColdHardiness,
traits.HeatHardiness,
traits.LeafHue
)
);
if (traits.Toxicity > 0.01f || traits.Thorns > 0.01f)
{
Line(languages.Format("inspect.gene.defense", traits.Toxicity, traits.Thorns), Warn);
}
if (traits.IsVariant)
{
Line(languages.Get("inspect.gene.variant"));
}
}
private void BuildProducts(Entity entity, PlantDef def, in PlantPhenotype traits)
{
var languages = _content.Languages;
if (def.HarvestProduct is { } harvest)
{
Line(languages.Format("inspect.harvest", ProductLabel(harvest), traits.HarvestAmount));
}
if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f)
{
var ripe = entity.TryGetComponent<Fruiting>(out var fruiting) ? fruiting.RipeFruit : 0f;
var season = languages.Get(
"season." + ((Season)traits.FruitSeason).ToString().ToLowerInvariant()
);
Line(
languages.Format(
"inspect.fruit",
ProductLabel(fruit),
traits.FruitYield,
season,
ripe
)
);
}
else
{
Line(languages.Get("inspect.barren"));
}
}
// — Животное: Обзор — стадия, возраст, пол/поколение, вес, беременность.
private void BuildAnimalOverview(Entity entity, in AnimalOrganism org, AnimalSet.Species sp)
{
var languages = _content.Languages;
var grow = entity.GetComponent<AnimalGrowth>();
var stageName = languages.Get(
"animalstage." + sp.Stages[grow.Stage].Name.ToLowerInvariant()
);
Line(languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length));
Line(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
Line(languages.Format("inspect.animal.sex", sex, org.Generation));
// Вес тела и грузоподъёмность (∝ массе × способность нести; четвероногие — через Moving).
var stageScale = sp.Stages[grow.Stage].Scale;
var mass = Mass.OfAnimal(sp.Def, org.Traits, stageScale);
var carryCap =
entity.TryGetComponent<Health>(out var health) && health.State is not null
? Mass.CarryCapacity(mass, health.State.Capacity(AnimalCapacities.Moving))
: Mass.CarryCapacity(mass, 1f);
Line(languages.Format("inspect.mass", mass, carryCap));
if (entity.TryGetComponent<Pregnant>(out var preg))
{
Line(languages.Format("inspect.animal.pregnant", preg.DueInDays), Ui.Accent);
}
}
// — Животное: Нужды — полоса на каждую нужду; убывающие красят «хорошо↔плохо», влечения — нейтрально.
private void BuildAnimalNeeds(Entity entity)
{
var languages = _content.Languages;
var values = entity.GetComponent<AnimalNeeds>().Values;
if (values is null)
{
return;
}
for (var k = 0; k < _needs.Count && k < values.Length; k++)
{
var v = values[k];
var color = _needs.IsDrive(k) ? Neutral : GoodBad(v);
Bar(languages.Get(_needs[k].Label), v, color);
}
}
// — Животное: Навыки — полоса уровня (нейтральный цвет) + страсть (★/★★) в подписи.
private void BuildAnimalSkills(Entity entity)
{
var languages = _content.Languages;
if (!entity.TryGetComponent<Skills>(out var sk) || sk.Levels is null)
{
Line(languages.Get("inspect.health.none"));
return;
}
for (var i = 0; i < _skills.Count && i < sk.Levels.Length; i++)
{
var passion =
sk.Passions is not null && i < sk.Passions.Length ? sk.Passions[i] : (byte)0;
var stars = passion switch
{
2 => " ★★",
1 => " ★",
_ => "",
};
Bar(languages.Get(_skills[i].Label) + stars, sk.Levels[i], Neutral);
}
}
// — Животное: Здоровье — полосы жизненных показателей, способностей и активных состояний.
private void BuildAnimalHealth(Entity entity)
{
var languages = _content.Languages;
var state = entity.GetComponent<Health>().State;
if (state is null)
{
Line(languages.Get("inspect.health.none"));
return;
}
Header("inspect.health.vitals");
Bar(languages.Get("inspect.vital.blood"), state.BloodLevel, GoodBad(state.BloodLevel));
// Боль: длина = уровень боли, цвет инвертирован (много боли — красный).
Bar(languages.Get("inspect.vital.pain"), state.Pain, GoodBad(1f - state.Pain));
if (state.Capacities.Count > 0)
{
Header("inspect.health.caps");
// Сначала известные способности в порядке отображения, затем прочие (модовые) — как есть.
var shown = new HashSet<string>(StringComparer.Ordinal);
foreach (var capId in AnimalCapacities.DisplayOrder)
{
if (state.Capacities.ContainsKey(capId))
{
var v = state.Capacity(capId);
Bar(languages.Get(CapacityLabel(capId)), v, GoodBad(v));
shown.Add(capId);
}
}
foreach (var capId in state.Capacities.Keys)
{
if (shown.Add(capId))
{
var v = state.Capacity(capId);
Bar(languages.Get(CapacityLabel(capId)), v, GoodBad(v));
}
}
}
Header("inspect.health.hediffs");
if (state.Hediffs.Count > 0)
{
foreach (var hediff in state.Hediffs)
{
Bar(
languages.Get(hediff.Def.Label),
hediff.Severity,
GoodBad(1f - hediff.Severity)
);
}
}
else
{
Line(languages.Get("inspect.health.none"));
}
}
// Ключ локализации метки способности: cap.<id в нижнем регистре> (cap.consciousness, cap.bloodpumping…).
private static string CapacityLabel(string capId) => "cap." + capId.ToLowerInvariant();
// — Животное: Настроение — полоса значения и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(Entity entity)
{
var languages = _content.Languages;
var mood = entity.GetComponent<Mood>();
if (!mood.Active)
{
Line(languages.Get("inspect.mood.none"));
return;
}
Bar(languages.Get("inspect.tab.mood"), mood.Value, GoodBad(mood.Value));
if (mood.Thoughts is { Count: > 0 })
{
Header("inspect.mood.thoughts");
foreach (var thought in mood.Thoughts)
{
var pct = thought.Def.MoodOffset * 100f;
var offset = (pct >= 0f ? "+" : "") + pct.ToString("0") + "%";
Line(
languages.Format(
"inspect.mood.thoughtline",
languages.Get(thought.Def.Label),
offset
),
pct >= 0f ? Body : Warn
);
}
}
else
{
Line(languages.Get("inspect.mood.calm"));
}
}
private string StageLabel(PlantDef def, int stage) =>
stage < def.Stages.Count && def.Stages[stage].Label is { } key
? _content.Languages.Get(key)
: "—";
private string ProductLabel(string productDefName) =>
_content.Defs.TryGet<ProductDef>(productDefName, out var product)
? _content.Languages.Get(product.Label)
: productDefName;
}
/// <summary>
/// Полоса-индикатор показателя в стиле RimWorld: тёмная дорожка фиксированной ширины, цветная заливка
/// длиной <c>fraction</c>, поверх — подпись слева и значение справа. Самодостаточный виджет (ничего не
/// обновляет — пересоздаётся при каждой пересборке тела панели), цвет заливки задаёт вызывающий.
/// </summary>
internal sealed class StatBar : Panel
{
private static readonly IBrush Track = new SolidBrush(new Color(28, 34, 44));
private static readonly IBrush TrackBorder = new SolidBrush(new Color(64, 78, 98));
private const int BarHeight = 22;
public StatBar(int width, string name, float fraction, string value, Color fill)
{
Width = width;
Height = BarHeight;
Background = Track;
Border = TrackBorder;
BorderThickness = new Thickness(1);
var fillWidth = (int)MathF.Round((width - 2) * Math.Clamp(fraction, 0f, 1f));
if (fillWidth > 0)
{
Widgets.Add(
new Panel
{
Background = new SolidBrush(fill),
Width = fillWidth,
Height = BarHeight - 2,
Left = 1,
Top = 1,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
}
);
}
Widgets.Add(
new Label
{
Text = name,
TextColor = Color.White,
Left = 8,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
}
);
Widgets.Add(
new Label
{
Text = value,
TextColor = Color.White,
Margin = new Thickness(0, 0, 8, 0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
}
);
}
}
+104 -4
View File
@@ -1,13 +1,18 @@
using System;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Общие фабрики виджетов Myra для экранов меню — единый стиль и отсутствие дублирования
/// разметки между сценами. Все подписи приходят готовыми строками (локализованными вызывающим).
/// разметки между сценами. Тёмная тема с холодным акцентом: затемнённый фон-подложка на весь
/// экран (<see cref="Screen"/>), карточки с рамкой (<see cref="Card"/>), кнопки с подсветкой
/// при наведении и нажатии (<see cref="Button"/>). Все подписи приходят готовыми строками
/// (локализованными вызывающим).
/// </summary>
internal static class Ui
{
@@ -17,7 +22,20 @@ internal static class Ui
/// <summary>Приглушённый цвет вторичного текста.</summary>
public static readonly Color Muted = new(150, 160, 175);
private const int ButtonWidth = 280;
/// <summary>Основной цвет текста на кнопках/карточках.</summary>
public static readonly Color Text = new(214, 222, 232);
// Палитра тёмной темы (фон → карточка → кнопка и её состояния).
private static readonly Color Backdrop = new(12, 15, 21);
private static readonly Color CardBg = new(22, 28, 38, 235);
private static readonly Color CardBorder = new(70, 100, 134);
private static readonly Color ButtonBg = new(30, 39, 52);
private static readonly Color ButtonOver = new(46, 64, 88);
private static readonly Color ButtonPress = new(62, 88, 120);
private static readonly Color ButtonBorder = new(64, 88, 118);
private static readonly Color ChoiceActiveBg = new(48, 84, 120);
private const int ButtonWidth = 300;
/// <summary>Крупный заголовок по центру.</summary>
public static Label Title(string text) =>
@@ -37,7 +55,18 @@ internal static class Ui
HorizontalAlignment = HorizontalAlignment.Center,
};
/// <summary>Кнопка фиксированной ширины с обработчиком клика.</summary>
/// <summary>Тонкая акцентная черта-разделитель по центру (под заголовком).</summary>
public static Panel AccentRule(int width = 130) =>
new()
{
Width = width,
Height = 2,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 4, 0, 10),
Background = new SolidBrush(Accent),
};
/// <summary>Кнопка фиксированной ширины: тёмная заливка, акцентная рамка, подсветка hover/press.</summary>
public static TextButton Button(string text, Action onClick, int width = ButtonWidth)
{
var button = new TextButton
@@ -45,11 +74,44 @@ internal static class Ui
Text = text,
Width = width,
HorizontalAlignment = HorizontalAlignment.Center,
Padding = new Thickness(12, 9),
TextColor = Text,
Background = new SolidBrush(ButtonBg),
OverBackground = new SolidBrush(ButtonOver),
PressedBackground = new SolidBrush(ButtonPress),
Border = new SolidBrush(ButtonBorder),
BorderThickness = new Thickness(1),
};
button.Click += (_, _) => onClick();
return button;
}
/// <summary>Небольшая кнопка-выбор (сегмент/тумблер): тёмная заливка, рамка, подсветка hover/press.
/// Активное состояние задаётся отдельно через <see cref="SetChoiceActive"/>.</summary>
public static TextButton ChoiceButton(string text)
{
var button = new TextButton
{
Text = text,
Padding = new Thickness(10, 6),
TextColor = Text,
Background = new SolidBrush(ButtonBg),
OverBackground = new SolidBrush(ButtonOver),
PressedBackground = new SolidBrush(ButtonPress),
Border = new SolidBrush(ButtonBorder),
BorderThickness = new Thickness(1),
};
return button;
}
/// <summary>Подсвечивает кнопку-выбор как выбранную (акцентная заливка/рамка) или обычную.</summary>
public static void SetChoiceActive(TextButton button, bool active)
{
button.Background = new SolidBrush(active ? ChoiceActiveBg : ButtonBg);
button.Border = new SolidBrush(active ? Accent : ButtonBorder);
button.TextColor = active ? Color.White : Muted;
}
/// <summary>Вертикальная колонка по центру экрана.</summary>
public static VerticalStackPanel Column(int spacing = 10) =>
new()
@@ -59,11 +121,25 @@ internal static class Ui
VerticalAlignment = VerticalAlignment.Center,
};
/// <summary>Карточка по центру: вертикальная колонка с тёмным фоном, акцентной рамкой и отступами.</summary>
public static VerticalStackPanel Card(int spacing = 8) =>
new()
{
Spacing = spacing,
Padding = new Thickness(30, 26),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Background = new SolidBrush(CardBg),
Border = new SolidBrush(CardBorder),
BorderThickness = new Thickness(1),
};
/// <summary>Горизонтальный ряд.</summary>
public static HorizontalStackPanel Row(int spacing = 8) =>
new() { Spacing = spacing, HorizontalAlignment = HorizontalAlignment.Center };
/// <summary>Оборачивает содержимое в панель на весь экран (абсолютное позиционирование детей).</summary>
/// <summary>Оборачивает содержимое в панель на весь экран (абсолютное позиционирование детей).
/// Без фона — годится и для HUD поверх игрового мира.</summary>
public static Panel Screen(params Widget[] children)
{
var panel = new Panel();
@@ -74,6 +150,30 @@ internal static class Ui
return panel;
}
/// <summary>
/// Как <see cref="Screen"/>, но с тёмной подложкой на весь экран под содержимым — для экранов меню,
/// чтобы фон был единообразно тёмным независимо от цвета очистки кадра. НЕ использовать для HUD над
/// игровым миром (подложка непрозрачна и скрыла бы мир).
/// </summary>
public static Panel MenuScreen(params Widget[] children)
{
var panel = new Panel();
panel.Widgets.Add(
new Panel
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Background = new SolidBrush(Backdrop),
}
);
foreach (var child in children)
{
panel.Widgets.Add(child);
}
return panel;
}
}
/// <summary>
+142 -56
View File
@@ -7,13 +7,17 @@ using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость).
/// Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>; общий
/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты
/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra.
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, масштаб UI, громкость,
/// режим разработчика). Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>;
/// общий для сцены настроек и оверлея паузы. Каждая настройка — ряд «подпись + контрол» с единым
/// выравниванием: сегменты-кнопки (активная подсвечена акцентом) и тумблеры Вкл/Откл вместо
/// неоднозначных комбо/чекбоксов, что переносимо между версиями Myra. Любой клик мгновенно
/// перерисовывает подсветку (все рефрешеры), а не только после «Применить».
/// </summary>
internal static class SettingsPanel
{
private const int LabelWidth = 200;
private static readonly (int W, int H)[] Resolutions =
{
(1280, 720),
@@ -30,47 +34,58 @@ internal static class SettingsPanel
)
{
var refreshers = new List<Action>();
var column = Ui.Column(10);
void RefreshAll()
{
foreach (var refresh in refreshers)
{
refresh();
}
}
column.Widgets.Add(Ui.Title(lang.Get("settings.title")));
column.Widgets.Add(new Label { Height = 8 });
var card = Ui.Card(8);
card.Widgets.Add(Ui.Title(lang.Get("settings.title")));
card.Widgets.Add(Ui.AccentRule());
// Язык — по одному сегменту на доступный язык.
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.language"),
lang.AvailableLanguages,
code => code.ToUpperInvariant(),
() => settings.Language,
code => settings.Language = code,
refreshers
refreshers,
RefreshAll
)
);
// Полный экран и VSync — тумблеры (цвет = состояние).
column.Widgets.Add(
// Полный экран и VSync — тумблеры Вкл/Откл.
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.fullscreen"),
() => settings.Fullscreen,
v => settings.Fullscreen = v,
refreshers
refreshers,
RefreshAll
)
);
column.Widgets.Add(
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.vsync"),
() => settings.VSync,
v => settings.VSync = v,
refreshers
refreshers,
RefreshAll
)
);
// Разрешение — сегменты пресетов.
var resolutions = new List<(int, int)>(Resolutions);
column.Widgets.Add(
card.Widgets.Add(
Segment(
lang.Get("settings.resolution"),
resolutions,
new List<(int, int)>(Resolutions),
r => $"{r.Item1}×{r.Item2}",
() => (settings.Width, settings.Height),
r =>
@@ -78,25 +93,69 @@ internal static class SettingsPanel
settings.Width = r.Item1;
settings.Height = r.Item2;
},
refreshers
refreshers,
RefreshAll
)
);
// Масштаб интерфейса — сегменты пресетов.
card.Widgets.Add(
Segment(
lang.Get("settings.uiscale"),
new List<float> { 0.75f, 1f, 1.25f, 1.5f },
s => $"{(int)MathF.Round(s * 100)}%",
() => settings.UiScale,
s => settings.UiScale = s,
refreshers,
RefreshAll
)
);
// Громкость — кнопки −/+ с подписью процента.
column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers));
card.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers, RefreshAll));
column.Widgets.Add(new Label { Height = 8 });
// Режим разработчика — тумблер (дев-консоль и спавнер; по умолчанию включён).
card.Widgets.Add(
Toggle(
lang,
lang.Get("settings.devmode"),
() => settings.DeveloperMode,
v => settings.DeveloperMode = v,
refreshers,
RefreshAll
)
);
card.Widgets.Add(new Label { Height = 8 });
var buttons = Ui.Row(10);
buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150));
buttons.Widgets.Add(Ui.Button(lang.Get("settings.back"), onBack, width: 150));
column.Widgets.Add(buttons);
card.Widgets.Add(buttons);
foreach (var refresh in refreshers)
RefreshAll();
return card;
}
// Ряд «подпись слева фиксированной ширины + контрол» — даёт ровные колонки между настройками.
private static HorizontalStackPanel Field(string label, Widget control)
{
var row = new HorizontalStackPanel
{
refresh();
}
return column;
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Left,
};
row.Widgets.Add(
new Label
{
Text = label,
Width = LabelWidth,
Wrap = true,
TextColor = Ui.Muted,
VerticalAlignment = VerticalAlignment.Center,
}
);
row.Widgets.Add(control);
return row;
}
private static Widget Segment<T>(
@@ -105,67 +164,94 @@ internal static class SettingsPanel
Func<T, string> text,
Func<T> get,
Action<T> set,
List<Action> refreshers
List<Action> refreshers,
Action onChange
)
{
var row = Ui.Row(6);
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
var group = new HorizontalStackPanel
{
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
foreach (var option in options)
{
var value = option;
var button = new TextButton { Text = text(value) };
button.Click += (_, _) => set(value);
row.Widgets.Add(button);
var button = Ui.ChoiceButton(text(value));
button.Click += (_, _) =>
{
set(value);
onChange();
};
group.Widgets.Add(button);
refreshers.Add(() =>
button.TextColor = EqualityComparer<T>.Default.Equals(get(), value)
? Ui.Accent
: Ui.Muted
Ui.SetChoiceActive(button, EqualityComparer<T>.Default.Equals(get(), value))
);
}
return row;
return Field(label, group);
}
private static Widget Toggle(
LanguageManager lang,
string label,
Func<bool> get,
Action<bool> set,
List<Action> refreshers
List<Action> refreshers,
Action onChange
)
{
var button = new TextButton();
button.Click += (_, _) => set(!get());
var button = Ui.ChoiceButton("");
button.Width = 90;
button.Click += (_, _) =>
{
set(!get());
onChange();
};
refreshers.Add(() =>
{
button.Text = (get() ? "● " : "○ ") + label;
button.TextColor = get() ? Ui.Accent : Ui.Muted;
button.Text = lang.Get(get() ? "settings.on" : "settings.off");
Ui.SetChoiceActive(button, get());
});
return button;
return Field(label, button);
}
private static Widget Volume(string label, GameSettings settings, List<Action> refreshers)
private static Widget Volume(
string label,
GameSettings settings,
List<Action> refreshers,
Action onChange
)
{
var row = Ui.Row(6);
var caption = new Label { TextColor = Ui.Muted };
refreshers.Add(() => caption.Text = $"{label}: {(int)MathF.Round(settings.Volume * 100)}%");
var value = new Label
{
TextColor = Ui.Text,
Width = 56,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
refreshers.Add(() => value.Text = $"{(int)MathF.Round(settings.Volume * 100)}%");
void Adjust(float delta)
{
settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
foreach (var refresh in refreshers)
{
refresh();
}
onChange();
}
var minus = new TextButton { Text = "" };
var minus = Ui.ChoiceButton("");
minus.Width = 42;
minus.Click += (_, _) => Adjust(-0.1f);
var plus = new TextButton { Text = "+" };
var plus = Ui.ChoiceButton("+");
plus.Width = 42;
plus.Click += (_, _) => Adjust(+0.1f);
row.Widgets.Add(caption);
row.Widgets.Add(minus);
row.Widgets.Add(plus);
return row;
var group = new HorizontalStackPanel
{
Spacing = 4,
VerticalAlignment = VerticalAlignment.Center,
};
group.Widgets.Add(minus);
group.Widgets.Add(value);
group.Widgets.Add(plus);
return Field(label, group);
}
}
+47
View File
@@ -0,0 +1,47 @@
using LittleSim.App;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Масштаб интерфейса для Myra-десктопа. Scale увеличивает всю отрисовку, а <c>BoundsFetcher</c>
/// отдаёт логический размер «экран ÷ масштаб», поэтому раскладка перетекает (а не обрезается):
/// при масштабе 1.25 виджеты крупнее, но углы/центры остаются прижаты к краям экрана. Ввод Myra
/// трансформируется тем же преобразованием, так что попадания/<c>IsMouseOverGUI</c> остаются верными.
/// </summary>
internal static class UiScaling
{
/// <summary>Применяет масштаб <paramref name="scale"/> (клампится в разумные пределы) к десктопу.</summary>
public static void Apply(EngineContext context, Desktop desktop, float scale)
{
scale = System.Math.Clamp(scale, 0.75f, 2f);
desktop.TransformOrigin = Vector2.Zero; // масштабируем от левого-верхнего угла — края на месте
desktop.Scale = new Vector2(scale);
desktop.BoundsFetcher = () =>
{
var viewport = context.GetGraphicsDevice().Viewport;
return new Rectangle(
0,
0,
(int)MathF.Round(viewport.Width / scale),
(int)MathF.Round(viewport.Height / scale)
);
};
}
}
/// <summary>Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек.</summary>
internal static class ScaledUiExtensions
{
/// <summary>Как <c>UseUI</c>, но десктоп уже масштабирован по <see cref="GameSettings.UiScale"/>.</summary>
public static Desktop UseScaledUI(this Scene scene)
{
var desktop = scene.UseUI();
UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale);
return desktop;
}
}