From 1030f27c50deb2f55c39872c11bd2aa712b365b0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 09:47:18 +0300 Subject: [PATCH] Bring plants back as data-driven growth stages over the calendar Bump the engine submodule to f791ee6 (Calendar) and use it to drive plant growth. PlantDef gains a Stages list (texture/size/growDays per stage, inheritable via abstract parents in plants.json); a resolved PlantSet table turns those into atlas regions and day thresholds at scene load. New PlantGrowth component plus PlantGrowthSystem advance each plant by in-game days from the Calendar and swap its sprite/scale at stage borders. WorldScene scatters plants again with a seed-deterministic, varied initial maturity, registers the calendar and growth system, and the HUD now shows the current day. Stage durations and visuals are pure data in plants.json. Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/plants.json | 19 +++++- Mods/Core/Languages/en/ui.json | 6 +- Mods/Core/Languages/ru/ui.json | 6 +- engine | 2 +- src/LittleSim/Content/GameDefs.cs | 29 ++++++++- src/LittleSim/Content/PlantSet.cs | 89 ++++++++++++++++++++++++++ src/LittleSim/Scenes/ContentHelpers.cs | 62 +++++++++--------- src/LittleSim/Scenes/WorldScene.cs | 32 +++++++-- src/LittleSim/Sim/PlantGrowth.cs | 20 ++++++ src/LittleSim/Sim/PlantGrowthSystem.cs | 65 +++++++++++++++++++ src/LittleSim/Sim/SimSystems.cs | 6 +- 11 files changed, 288 insertions(+), 48 deletions(-) create mode 100644 src/LittleSim/Content/PlantSet.cs create mode 100644 src/LittleSim/Sim/PlantGrowth.cs create mode 100644 src/LittleSim/Sim/PlantGrowthSystem.cs diff --git a/Mods/Core/Defs/plants.json b/Mods/Core/Defs/plants.json index dd3bd6c..0124e0c 100644 --- a/Mods/Core/Defs/plants.json +++ b/Mods/Core/Defs/plants.json @@ -1,15 +1,28 @@ { "type": "Plant", "defs": [ - { "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28 }, + { "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28, + "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 }, + { "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2, + "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 }, + { "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4, + "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" } diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index 53fc0f3..437bc32 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -1,5 +1,5 @@ { - "hud.world": "LittleSim | seed {0} | {2} FPS", + "hud.world": "LittleSim | seed {0} | day {1} | {2} FPS", "hud.controls": "WASD — camera, wheel — zoom, ` — console ('help' lists commands)", "hud.paused": "PAUSED", @@ -67,6 +67,10 @@ "plant.pine": "pine", "plant.grass": "grass tuft", "plant.bush": "bush", + "plant.stage.seedling": "seedling", + "plant.stage.sprout": "sprout", + "plant.stage.sapling": "sapling", + "plant.stage.mature": "mature", "pawn.being": "being", "pawn.bear": "bear", diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index f7bed53..4ac8cfd 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -1,5 +1,5 @@ { - "hud.world": "LittleSim | сид {0} | {2} FPS", + "hud.world": "LittleSim | сид {0} | день {1} | {2} FPS", "hud.controls": "WASD — камера, колесо — зум, ` — консоль ('help' — список команд)", "hud.paused": "ПАУЗА", @@ -67,6 +67,10 @@ "plant.pine": "сосна", "plant.grass": "пучок травы", "plant.bush": "куст", + "plant.stage.seedling": "росток", + "plant.stage.sprout": "всходы", + "plant.stage.sapling": "саженец", + "plant.stage.mature": "взрослое", "pawn.being": "житель", "pawn.bear": "медведь", diff --git a/engine b/engine index 5365945..f791ee6 160000 --- a/engine +++ b/engine @@ -1 +1 @@ -Subproject commit 53659450a6de73a19fd8dd911cd145d01635ab74 +Subproject commit f791ee6c95be1ac215fb0cc4183f5461b0d42160 diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 96c0af2..1bd2207 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -37,17 +37,40 @@ public sealed class TerrainDef : Def public Color Tint => Rgb is [var r, var g, var b, ..] ? new(r, g, b) : Color.Magenta; } -/// Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие. +/// +/// Одна стадия роста растения (вложенный объект ): свой спрайт и +/// размер, и сколько игровых дней растение проводит на этой стадии до перехода к следующей. +/// Растёт на месте — последняя стадия терминальная (её игнорируется). +/// +public sealed class PlantGrowthStage +{ + /// Ключ текстуры стадии; null — берётся базовая . + public string? Texture { get; init; } + + /// Размер спрайта в клетках на этой стадии; 0 — берётся базовый . + public float SizeCells { get; init; } + + /// Сколько игровых дней расти на этой стадии до перехода к следующей. + public float GrowDays { get; init; } + + /// Ключ локализации названия стадии (опционально, для будущих тултипов). + public string? Label { get; init; } +} + +/// Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста. public sealed class PlantDef : Def { - /// Ключ текстуры (путь региона в атласах). + /// Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры. public string Texture { get; init; } = ""; - /// Размер спрайта в клетках. + /// Базовый размер спрайта в клетках; база для стадий без своего размера. public float SizeCells { get; init; } = 1f; /// Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты). public float TrunkRadiusCells { get; init; } + + /// Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры. + public List Stages { get; init; } = []; } /// diff --git a/src/LittleSim/Content/PlantSet.cs b/src/LittleSim/Content/PlantSet.cs new file mode 100644 index 0000000..74872cb --- /dev/null +++ b/src/LittleSim/Content/PlantSet.cs @@ -0,0 +1,89 @@ +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Graphics; + +namespace LittleSim.Content; + +/// +/// Готовая (resolved) таблица растений для сцены: по каждому — массив +/// стадий с уже загруженными регионами атласа, размерами и порогами входа в днях. Строится один +/// раз при загрузке сцены (как , потому что нужны атлас и +/// устройство). индексируется в неё без словарей и аллокаций. +/// +public sealed class PlantSet +{ + /// Разрешённая стадия: спрайт, размер в клетках и день, с которого стадия активна. + public readonly record struct Stage(Texture2DRegion Region, float SizeCells, float EnterDay); + + /// Вид растения: его деф и стадии роста по возрастанию . + public sealed class Species + { + /// Исходный деф растения. + public required PlantDef Def { get; init; } + + /// Стадии роста по порядку; последняя — терминальная. + public required Stage[] Stages { get; init; } + + /// День, с которого растение достигает последней (зрелой) стадии. + public float MaturityDays => Stages[^1].EnterDay; + + /// Индекс стадии для данного возраста в днях. + public int StageAt(float ageDays) + { + var stage = 0; + while (stage + 1 < Stages.Length && ageDays >= Stages[stage + 1].EnterDay) + { + stage++; + } + + return stage; + } + } + + private readonly Species[] _species; + private readonly Dictionary _index = new(); + + /// Строит таблицу из всех (не-abstract) мода. + public PlantSet(GameContent content, ModAtlases atlases, GraphicsDevice device) + { + var defs = content.Defs.All().ToArray(); + _species = new Species[defs.Length]; + for (var i = 0; i < defs.Length; i++) + { + var def = defs[i]; + _species[i] = new Species { Def = def, Stages = BuildStages(def, atlases, device) }; + _index[def] = i; + } + } + + /// Число видов в таблице. + public int Count => _species.Length; + + /// Вид по индексу. + public Species this[int index] => _species[index]; + + /// Индекс вида по дефу. + public int IndexOf(PlantDef def) => _index[def]; + + private static Stage[] BuildStages(PlantDef def, ModAtlases atlases, GraphicsDevice device) + { + // Без явных стадий растение существует как одна терминальная стадия из базовой текстуры. + if (def.Stages.Count == 0) + { + var region = atlases.GetRegion(device, def.Texture); + return [new Stage(region, def.SizeCells, 0f)]; + } + + var stages = new Stage[def.Stages.Count]; + var enterDay = 0f; + for (var i = 0; i < def.Stages.Count; i++) + { + var stage = def.Stages[i]; + var region = atlases.GetRegion(device, stage.Texture ?? def.Texture); + var size = stage.SizeCells > 0f ? stage.SizeCells : def.SizeCells; + stages[i] = new Stage(region, size, enterDay); + enterDay += stage.GrowDays; // следующая стадия входит после GrowDays этой + } + + return stages; + } +} diff --git a/src/LittleSim/Scenes/ContentHelpers.cs b/src/LittleSim/Scenes/ContentHelpers.cs index 74da80b..c737641 100644 --- a/src/LittleSim/Scenes/ContentHelpers.cs +++ b/src/LittleSim/Scenes/ContentHelpers.cs @@ -1,32 +1,30 @@ using Friflo.Engine.ECS; using LittleSim.Content; +using LittleSim.Sim; using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; using MrGameEng.Core; using MrGameEng.DevConsole; using MrGameEng.Graphics; namespace LittleSim.Scenes; -/// Спавн растительности по скаттеру дефов террейна (общий для обеих сцен). +/// Спавн растительности по скаттеру дефов террейна. internal static class ScatterSpawner { /// - /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение - /// со случайным смещением. Деревья (ствол > 0) получают коллайдер-препятствие, - /// когда заданы слои коллизий. + /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со + /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации + /// разновозрастный. Стадия и спрайт берутся из , дальше растением + /// управляет . /// public static void Spawn( Scene scene, GameContent content, - ModAtlases atlases, - GraphicsDevice device, + PlantSet plants, TerrainDef terrain, Point cell, int cellSize, - Random random, - uint obstacleLayer = 0, - uint collidesWith = 0 + Random random ) { foreach (var entry in terrain.Scatter) @@ -36,28 +34,30 @@ internal static class ScatterSpawner continue; } - var plant = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]); - var region = atlases.GetRegion(device, plant.Texture); - var sprite = new Sprite(region, GameLayers.Beings); - sprite.CenterOrigin(); - var entity = scene.Store.CreateEntity( - new Transform2D( - new Vector2( - (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize, - (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize - ), - scale: new Vector2(cellSize * plant.SizeCells / region.Width) - ), - sprite - ); + var def = content.Defs.Get(entry.Options[random.Next(entry.Options.Count)]); + var index = plants.IndexOf(def); + var species = plants[index]; + var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; + var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize; + var age = random.NextSingle() * species.MaturityDays; + var stage = species.StageAt(age); + var resolved = species.Stages[stage]; - if (obstacleLayer != 0 && plant.TrunkRadiusCells > 0f) - { - var trunk = MrGameEng.Collisions.Collider.Circle(cellSize * plant.TrunkRadiusCells); - trunk.Layer = obstacleLayer; - trunk.CollidesWith = collidesWith; - entity.AddComponent(trunk); - } + var sprite = new Sprite(resolved.Region, GameLayers.Beings); + sprite.CenterOrigin(); + scene.Store.CreateEntity( + new Transform2D( + new Vector2(px, py), + scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width) + ), + sprite, + new PlantGrowth + { + Species = index, + Stage = stage, + AgeDays = age, + } + ); } } } diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 772057e..779b150 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -30,6 +30,9 @@ public sealed class WorldScene : Scene { public const int CellSize = 16; + /// Сколько секунд масштабированного времени длится один игровой день. + public const float SecondsPerDay = 30f; + private readonly WorldConfig _config; private readonly WorldSave? _save; private readonly RectF _bounds; @@ -66,7 +69,12 @@ public sealed class WorldScene : Scene GameLayers.EnsureRegistered(renderer); this.UseTilemaps(); - BuildTerrain(content, atlases, device, assets); + var calendar = Context.UseCalendar(SecondsPerDay); + + // Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed). + var plants = new PlantSet(content, atlases, device); + var random = new Random(_config.Seed); + BuildTerrain(content, atlases, device, assets, plants, random); var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds)); @@ -87,9 +95,10 @@ public sealed class WorldScene : Scene var console = this.UseDevConsole(); RegisterCommands(console, content, atlases); + UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, CellSize)); UpdateSystems.Add(new GodCameraSystem(camera, input, console, () => _pause.IsOpen)); UpdateSystems.Add( - new HudSystem(Context, content.Languages, hudLabel, "hud.world", _config.Seed, 0) + new HudSystem(Context, content.Languages, hudLabel, "hud.world", _config.Seed, calendar) ); UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input))); @@ -101,13 +110,16 @@ public sealed class WorldScene : Scene /// /// Строит одну сущность-: тайлсет из дефов рельефа (поверхность из - /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации. + /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а + /// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости). /// private void BuildTerrain( GameContent content, ModAtlases atlases, Microsoft.Xna.Framework.Graphics.GraphicsDevice device, - AssetManager assets + AssetManager assets, + PlantSet plants, + Random random ) { var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); @@ -134,7 +146,17 @@ public sealed class WorldScene : Scene { for (var y = 0; y < _config.Height; y++) { - grid[x, y] = tileByDef[content.Terrains.Classify(heights[x, y])]; + var terrain = content.Terrains.Classify(heights[x, y]); + grid[x, y] = tileByDef[terrain]; + ScatterSpawner.Spawn( + this, + content, + plants, + terrain, + new Point(x, y), + CellSize, + random + ); } } diff --git a/src/LittleSim/Sim/PlantGrowth.cs b/src/LittleSim/Sim/PlantGrowth.cs new file mode 100644 index 0000000..2ed7d43 --- /dev/null +++ b/src/LittleSim/Sim/PlantGrowth.cs @@ -0,0 +1,20 @@ +using Friflo.Engine.ECS; + +namespace LittleSim.Sim; + +/// +/// Состояние роста растения: индекс вида в , текущая стадия и +/// накопленный возраст в игровых днях. Плоские данные — переход между стадиями и смену спрайта +/// выполняет , читая пороги стадий из таблицы вида. +/// +public struct PlantGrowth : IComponent +{ + /// Индекс вида растения в . + public int Species; + + /// Текущая стадия роста (индекс в стадиях вида). + public int Stage; + + /// Накопленный возраст в игровых днях. + public float AgeDays; +} diff --git a/src/LittleSim/Sim/PlantGrowthSystem.cs b/src/LittleSim/Sim/PlantGrowthSystem.cs new file mode 100644 index 0000000..7ef16bf --- /dev/null +++ b/src/LittleSim/Sim/PlantGrowthSystem.cs @@ -0,0 +1,65 @@ +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Двигает рост растений: копит возраст по игровому календарю и при пересечении порога стадии +/// переключает спрайт на текстуру новой стадии, переотцентровывает и масштабирует его. Время +/// идёт по (пауза/скорость учитываются через Tick.deltaTime), +/// данные стадий — из . Терминальную стадию пропускает (рост окончен). +/// +public sealed class PlantGrowthSystem(PlantSet plants, Calendar calendar, int cellSize) + : QuerySystem +{ + protected override void OnUpdate() + { + var deltaDays = Tick.deltaTime / calendar.SecondsPerDay; + if (deltaDays <= 0f) + { + return; // пауза — рост стоит + } + + foreach (var (growths, sprites, transforms, _) in Query.Chunks) + { + var g = growths.Span; + var s = sprites.Span; + var t = transforms.Span; + for (var i = 0; i < g.Length; i++) + { + ref var grow = ref g[i]; + var stages = plants[grow.Species].Stages; + if (grow.Stage >= stages.Length - 1) + { + continue; // уже зрелое — рост окончен + } + + grow.AgeDays += deltaDays; + var changed = false; + while ( + grow.Stage < stages.Length - 1 + && grow.AgeDays >= stages[grow.Stage + 1].EnterDay + ) + { + grow.Stage++; + changed = true; + } + + if (changed) + { + Apply(ref s[i], ref t[i], stages[grow.Stage]); + } + } + } + } + + private void Apply(ref Sprite sprite, ref Transform2D transform, in PlantSet.Stage stage) + { + sprite.Region = stage.Region; + sprite.Origin = new Vector2(stage.Region.Width / 2f, stage.Region.Height / 2f); + transform.Scale = new Vector2(cellSize * stage.SizeCells / stage.Region.Width); + } +} diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs index 8870ce0..5ac8537 100644 --- a/src/LittleSim/Sim/SimSystems.cs +++ b/src/LittleSim/Sim/SimSystems.cs @@ -161,7 +161,7 @@ public sealed class GodCameraSystem( } /// -/// Строка статуса в HUD: заголовок сцены (ключ локализации с сидом, населением и FPS) +/// Строка статуса в HUD: заголовок сцены (ключ локализации с сидом, днём календаря и FPS) /// и подсказка управления. Обновляется 4 раза в секунду; смена языка подхватывается сама. /// public sealed class HudSystem( @@ -170,7 +170,7 @@ public sealed class HudSystem( Myra.Graphics2D.UI.Label label, string titleKey, int seed, - int population + MrGameEng.Core.Calendar? calendar = null ) : BaseSystem { private float _accumulated; @@ -189,7 +189,7 @@ public sealed class HudSystem( _accumulated = 0f; _frames = 0; label.Text = - languages.Format(titleKey, seed, population, fps) + languages.Format(titleKey, seed, calendar?.Day ?? 0, fps) + "\n" + languages.Get("hud.controls"); }