Files
LittleSim/src/LittleSim/Scenes/WorldScene.cs
T

653 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.Sim;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Formulas;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.Inspector;
using MrGameEng.Lighting;
using MrGameEng.Tilemaps;
using MrGameEng.UI;
using Myra.Graphics2D;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
/// (вода — тонированным тайлом). Поверх растёт растительность с геномом (рост по свету/температуре/
/// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости
/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1).
/// </summary>
public sealed class WorldScene : Scene
{
public const int CellSize = 16;
/// <summary>
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
/// </summary>
public const float SecondsPerDay = 480f;
/// <summary>Максимум растений на клетку — потолок плотности для размножения.</summary>
public const int DensityCap = 4;
private readonly WorldConfig _config;
private readonly WorldSave? _save;
private readonly RectF _bounds;
private GameSpeed _speed = null!;
private PauseMenu _pause = null!;
private Selection _selection = null!;
private InspectPanel _inspect = null!;
private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!;
private float[] _cellFertility = [];
private bool[] _cellLand = [];
private bool[] _cellOccluderBase = []; // горы (статично из террейна)
private bool[] _cellOccluder = []; // горы + зрелые деревья (пересобирается лайтмапом)
/// <summary>Новый мир из конфига.</summary>
public WorldScene(WorldConfig config)
: this(config, null) { }
/// <summary>Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).</summary>
public WorldScene(WorldConfig config, WorldSave? save)
{
_config = config;
_save = save;
_bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
}
protected override void OnLoad()
{
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var content = Context.Services.Get<GameContent>();
var atlases = Context.Services.Get<ModAtlases>();
var device = Context.GetGraphicsDevice();
var input = this.UseInput();
_speed = Context.Services.Get<GameSpeed>();
_speed.SetStep(0); // новый мир/загрузка стартуют на x1
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var calendar = Context.UseCalendar(SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var dayNight = new DayNight(calendar, DayNightSettings.Default);
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
_plants = new PlantSet(content, atlases, device);
var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain(
content,
atlases,
device,
assets,
loadingPlants ? null : new Random(_config.Seed)
);
if (loadingPlants)
{
RestorePlants(content);
}
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
var lighting = this.UseLighting(
renderer,
dayNight,
_config.Width,
_config.Height,
CellSize,
Vector2.Zero,
BuildOccluders
);
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI();
var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu(
Context,
content,
_speed,
onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get<Game>().Exit()
);
_selection = new Selection();
_inspect = new InspectPanel(Store, _plants, content, climate, _selection);
desktop.Root = Ui.Screen(
hudLabel,
_inspect.Highlight,
_inspect.Panel,
speedBar,
_pause.Root
);
this.UseInspector(renderer);
var console = this.UseDevConsole();
RegisterCommands(console, content, atlases);
console.Register(
"light",
"light [radius] — place a point light at the cursor (night shadows demo)",
(c, args) =>
{
var radius =
args.Length > 0
? float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture)
: 120f;
var mouse = Mouse.GetState();
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
Store.CreateEntity(
Transform2D.At(world),
new PointLight
{
Radius = radius,
Color = Color.White,
Intensity = 0.9f,
}
);
c.WriteLine($"light at {world.X:0},{world.Y:0} r{radius:0}");
}
);
UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, lighting, CellSize));
UpdateSystems.Add(new PlantFruitingSystem(_plants, calendar, climate));
UpdateSystems.Add(
new PlantLifecycleSystem(
Store,
_plants,
calendar,
climate,
Context.Clock,
_config.Width,
_config.Height,
CellSize,
_cellFertility,
_cellLand,
DensityCap,
_config.Seed
)
);
UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
);
UpdateSystems.Add(
new SelectionSystem(
Store,
input,
renderer,
_selection,
() => _pause.IsOpen,
p => _inspect.IsOverPanel(p) || speedBar.Bounds.Contains(p)
)
);
UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh(renderer)));
UpdateSystems.Add(
new HudSystem(
Context,
content.Languages,
hudLabel,
"hud.world",
_config.Seed,
calendar,
climate
)
);
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
Log.Info(
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
+ (_save is null ? " (new)" : " (loaded)")
);
}
/// <summary>
/// Строит сущность-<see cref="Tilemap"/> (тайлсет из дефов рельефа + сетка по карте высот) и
/// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если
/// <paramref name="scatterRandom"/> задан (новый мир) — рассыпает растительность по дефам;
/// при загрузке передаётся null (растения восстанавливаются из сейва отдельно).
/// </summary>
private void BuildTerrain(
GameContent content,
ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
AssetManager assets,
Random? scatterRandom
)
{
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
// Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
var tiles = new TileSet();
var tileByDef = new Dictionary<TerrainDef, ushort>();
foreach (var terrain in content.Terrains.All)
{
tileByDef[terrain] = terrain.Surface is { } surface
? tiles.Add(atlases.GetRegion(device, surface))
: tiles.Add(white, terrain.Tint);
}
// Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
var heights = WorldGenerator.Generate(
_config.Width,
_config.Height,
_config.Seed,
_config.SmoothPasses
);
var grid = new TileGrid(_config.Width, _config.Height);
var cells = _config.Width * _config.Height;
_cellFertility = new float[cells];
_cellLand = new bool[cells];
_cellOccluderBase = new bool[cells];
_cellOccluder = new bool[cells];
for (var x = 0; x < _config.Width; x++)
{
for (var y = 0; y < _config.Height; y++)
{
var terrain = content.Terrains.Classify(heights[x, y]);
grid[x, y] = tileByDef[terrain];
var cell = y * _config.Width + x;
_cellFertility[cell] = terrain.Fertility;
_cellLand[cell] = terrain.IsLand;
_cellOccluderBase[cell] = terrain.BlocksLight;
if (scatterRandom is not null)
{
ScatterSpawner.Spawn(
this,
content,
_plants,
terrain,
new Point(x, y),
CellSize,
scatterRandom
);
}
}
}
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
}
private HorizontalStackPanel BuildSpeedBar(GameContent content)
{
var bar = Ui.Row(6);
bar.HorizontalAlignment = HorizontalAlignment.Center;
bar.VerticalAlignment = VerticalAlignment.Bottom;
bar.Margin = new Thickness(0, 0, 0, 12);
var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
pause.Click += (_, _) => _speed.Pause();
bar.Widgets.Add(pause);
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
for (var i = 0; i < _speed.Steps.Count; i++)
{
var index = i;
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
button.Click += (_, _) => _speed.SetStep(index);
bar.Widgets.Add(button);
_speedRefreshers.Add(() =>
button.TextColor =
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
);
}
void Refresh()
{
foreach (var refresh in _speedRefreshers)
{
refresh();
}
}
_speed.Changed += Refresh;
RegisterUnload(() => _speed.Changed -= Refresh);
Refresh();
return bar;
}
private void Hotkeys(InputManager input)
{
if (input.IsKeyPressed(Keys.Escape))
{
// Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу.
if (_selection.HasSelection)
{
_selection.Clear();
}
else
{
_pause.Toggle();
}
}
if (_pause.IsOpen)
{
return;
}
if (input.IsKeyPressed(Keys.Space))
{
_speed.TogglePause();
}
if (input.IsKeyPressed(Keys.D1))
{
_speed.SetStep(0);
}
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
{
_speed.SetStep(1);
}
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
{
_speed.SetStep(2);
}
}
private string SaveWorld()
{
var save = new WorldSave
{
Name = _config.Name,
Width = _config.Width,
Height = _config.Height,
Seed = _config.Seed,
SmoothPasses = _config.SmoothPasses,
Population = _config.Population,
SavedUtc = DateTime.UtcNow,
ElapsedSeconds = Context.Clock.TotalTime,
};
// Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном.
Store
.Query<PlantGrowth, PlantOrganism, Transform2D>()
.ForEachEntity(
(
ref PlantGrowth grow,
ref PlantOrganism org,
ref Transform2D transform,
Entity _
) =>
{
save.Plants.Add(
new PlantSave
{
Species = _plants[grow.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
AgeDays = grow.AgeDays,
Stage = grow.Stage,
CellFertility = grow.CellFertility,
Genome = org.Genome.ToDictionary(),
}
);
}
);
new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)");
return _config.Name;
}
private void RestorePlants(GameContent content)
{
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
foreach (var plant in _save!.Plants)
{
if (!content.Defs.TryGet<PlantDef>(plant.Species, out var def))
{
continue; // вид пропал (мод убрали) — пропускаем растение
}
var index = _plants.IndexOf(def);
var genome = plant.Genome is { Count: > 0 }
? new Genome(plant.Genome)
: _plants[index].Template.Generate(fallback);
PlantFactory.Create(
Store,
_plants,
index,
new Vector2(plant.X, plant.Y),
plant.AgeDays,
genome,
plant.CellFertility,
CellSize
);
}
}
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
private bool[] BuildOccluders()
{
Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length);
Store
.Query<PlantGrowth, Transform2D>()
.ForEachEntity(
(ref PlantGrowth grow, ref Transform2D transform, Entity _) =>
{
var species = _plants[grow.Species];
if (
species.Def.TrunkRadiusCells <= 0f
|| grow.Stage < species.Stages.Length - 1
)
{
return; // затеняют только зрелые деревья (со стволом)
}
var cx = Math.Clamp(
(int)(transform.Position.X / CellSize),
0,
_config.Width - 1
);
var cy = Math.Clamp(
(int)(transform.Position.Y / CellSize),
0,
_config.Height - 1
);
_cellOccluder[cy * _config.Width + cx] = true;
}
);
return _cellOccluder;
}
private void Switch(Scene scene)
{
if (!Context.Scenes.IsTransitioning)
{
_speed.Resume();
Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
}
}
private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
{
ContentCommands.Register(console, content, atlases);
console.Register(
"regen",
"regen [seed] — regenerate the world with a new seed",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}");
Context.Scenes.Switch(
new WorldScene(_config with { Seed = seed }),
Transitions.Fade(0.6f)
);
}
);
console.Register(
"formula",
"formula <expr> — compile and evaluate an expression (gene-formula engine demo)",
(c, args) =>
{
if (args.Length == 0)
{
c.WriteLine(
"usage: formula <expr> e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)"
);
return;
}
var expression = string.Join(' ', args);
try
{
c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}");
}
catch (FormulaException e)
{
c.WriteLine($"error: {e.Message}");
}
}
);
console.Register(
"gene",
"gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child",
(c, args) => RunGeneDemo(c, content, args)
);
console.Register(
"plant",
"plant <species> [seed] — sample a species genome and show its gene-driven traits/products",
(c, args) => RunPlantDemo(c, content, args)
);
console.Register(
"menu",
"menu — return to the main menu",
(_, _) => Switch(new MainMenuScene())
);
}
// Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели,
// выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей.
private static void RunGeneDemo(DevConsole console, GameContent content, string[] args)
{
var genes = content.Defs.All<GeneDef>();
if (genes.Count == 0)
{
console.WriteLine("no Gene defs loaded");
return;
}
var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
var random = new Random(seed);
console.WriteLine($"genome from {genes.Count} genes, seed {seed}:");
var parentA = Genome.Generate(genes, random);
foreach (var gene in genes)
{
var allele = parentA[gene.DefName];
console.WriteLine(
$" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}"
);
}
console.WriteLine("traits:");
foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key))
{
console.WriteLine($" {trait} = {value:0.###}");
}
var parentB = Genome.Generate(genes, random);
var child = Genome.Breed(parentA, parentB, registry, random);
var childTraits = Phenotype.Compute(child, registry);
console.WriteLine(
$"bred child: {child.Alleles.Count} genes, "
+ $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, "
+ $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, "
+ $"variant={childTraits.GetValueOrDefault("variant"):0}"
);
}
// Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и
// печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов).
private void RunPlantDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Plant");
if (args.Length == 0)
{
console.WriteLine("usage: plant <species> [seed] e.g. plant TreeOakA");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<PlantDef>(args[0], out var def))
{
console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var index = _plants.IndexOf(def);
var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next();
var genome = _plants[index].Template.Generate(new Random(seed));
var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry));
console.WriteLine(
$"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:"
);
console.WriteLine(
$" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, "
+ $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}"
);
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
console.WriteLine(
$" temp: grows {tMin:0.#}..{tMax:0.#}°C, optimal {tLow:0.#}..{tHigh:0.#}°C "
+ $"(cold {traits.ColdHardiness:0.#}, heat {traits.HeatHardiness:0.#})"
);
if (def.HarvestProduct is { } harvest)
{
console.WriteLine(
$" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}"
);
}
if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f)
{
var season = content.Languages.Get(
$"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}"
);
console.WriteLine(
$" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}"
);
}
else
{
console.WriteLine(" barren (no fruit)");
}
}
private static string ProductLabel(GameContent content, string productDefName) =>
content.Defs.TryGet<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label)
: productDefName;
}