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 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 09:47:18 +03:00
co-authored by Claude Opus 4.8
parent 923644dd19
commit 1030f27c50
11 changed files with 288 additions and 48 deletions
+16 -3
View File
@@ -1,15 +1,28 @@
{ {
"type": "Plant", "type": "Plant",
"defs": [ "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": "TreeOakA", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakA" },
{ "defName": "TreeOakB", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakB" }, { "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": "TreeBirchA", "parent": "BaseTree", "label": "plant.birch", "texture": "things/plant/treebirch/TreeBirchA" },
{ "defName": "TreeGrayPineA", "parent": "BaseTree", "label": "plant.pine", "texture": "things/plant/treegraypine/TreeGrayPineA" }, { "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": "BushA", "parent": "BaseBush", "texture": "things/plant/bush/BushA" },
{ "defName": "BushB", "parent": "BaseBush", "texture": "things/plant/bush/BushB" }, { "defName": "BushB", "parent": "BaseBush", "texture": "things/plant/bush/BushB" },
{ "defName": "BushC", "parent": "BaseBush", "texture": "things/plant/bush/BushC" } { "defName": "BushC", "parent": "BaseBush", "texture": "things/plant/bush/BushC" }
+5 -1
View File
@@ -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.controls": "WASD — camera, wheel — zoom, ` — console ('help' lists commands)",
"hud.paused": "PAUSED", "hud.paused": "PAUSED",
@@ -67,6 +67,10 @@
"plant.pine": "pine", "plant.pine": "pine",
"plant.grass": "grass tuft", "plant.grass": "grass tuft",
"plant.bush": "bush", "plant.bush": "bush",
"plant.stage.seedling": "seedling",
"plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling",
"plant.stage.mature": "mature",
"pawn.being": "being", "pawn.being": "being",
"pawn.bear": "bear", "pawn.bear": "bear",
+5 -1
View File
@@ -1,5 +1,5 @@
{ {
"hud.world": "LittleSim | сид {0} | {2} FPS", "hud.world": "LittleSim | сид {0} | день {1} | {2} FPS",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль ('help' — список команд)", "hud.controls": "WASD — камера, колесо — зум, ` — консоль ('help' — список команд)",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
@@ -67,6 +67,10 @@
"plant.pine": "сосна", "plant.pine": "сосна",
"plant.grass": "пучок травы", "plant.grass": "пучок травы",
"plant.bush": "куст", "plant.bush": "куст",
"plant.stage.seedling": "росток",
"plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое",
"pawn.being": "житель", "pawn.being": "житель",
"pawn.bear": "медведь", "pawn.bear": "медведь",
+1 -1
Submodule engine updated: 53659450a6...f791ee6c95
+26 -3
View File
@@ -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; public Color Tint => Rgb is [var r, var g, var b, ..] ? new(r, g, b) : Color.Magenta;
} }
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие.</summary> /// <summary>
/// Одна стадия роста растения (вложенный объект <see cref="PlantDef.Stages"/>): свой спрайт и
/// размер, и сколько игровых дней растение проводит на этой стадии до перехода к следующей.
/// Растёт на месте — последняя стадия терминальная (её <see cref="GrowDays"/> игнорируется).
/// </summary>
public sealed class PlantGrowthStage
{
/// <summary>Ключ текстуры стадии; null — берётся базовая <see cref="PlantDef.Texture"/>.</summary>
public string? Texture { get; init; }
/// <summary>Размер спрайта в клетках на этой стадии; 0 — берётся базовый <see cref="PlantDef.SizeCells"/>.</summary>
public float SizeCells { get; init; }
/// <summary>Сколько игровых дней расти на этой стадии до перехода к следующей.</summary>
public float GrowDays { get; init; }
/// <summary>Ключ локализации названия стадии (опционально, для будущих тултипов).</summary>
public string? Label { get; init; }
}
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста.</summary>
public sealed class PlantDef : Def public sealed class PlantDef : Def
{ {
/// <summary>Ключ текстуры (путь региона в атласах).</summary> /// <summary>Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры.</summary>
public string Texture { get; init; } = ""; public string Texture { get; init; } = "";
/// <summary>Размер спрайта в клетках.</summary> /// <summary>Базовый размер спрайта в клетках; база для стадий без своего размера.</summary>
public float SizeCells { get; init; } = 1f; public float SizeCells { get; init; } = 1f;
/// <summary>Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты).</summary> /// <summary>Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты).</summary>
public float TrunkRadiusCells { get; init; } public float TrunkRadiusCells { get; init; }
/// <summary>Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры.</summary>
public List<PlantGrowthStage> Stages { get; init; } = [];
} }
/// <summary> /// <summary>
+89
View File
@@ -0,0 +1,89 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Graphics;
namespace LittleSim.Content;
/// <summary>
/// Готовая (resolved) таблица растений для сцены: по каждому <see cref="PlantDef"/> — массив
/// стадий с уже загруженными регионами атласа, размерами и порогами входа в днях. Строится один
/// раз при загрузке сцены (как <see cref="MrGameEng.Tilemaps.TileSet"/>, потому что нужны атлас и
/// устройство). <see cref="Sim.PlantGrowthSystem"/> индексируется в неё без словарей и аллокаций.
/// </summary>
public sealed class PlantSet
{
/// <summary>Разрешённая стадия: спрайт, размер в клетках и день, с которого стадия активна.</summary>
public readonly record struct Stage(Texture2DRegion Region, float SizeCells, float EnterDay);
/// <summary>Вид растения: его деф и стадии роста по возрастанию <see cref="Stage.EnterDay"/>.</summary>
public sealed class Species
{
/// <summary>Исходный деф растения.</summary>
public required PlantDef Def { get; init; }
/// <summary>Стадии роста по порядку; последняя — терминальная.</summary>
public required Stage[] Stages { get; init; }
/// <summary>День, с которого растение достигает последней (зрелой) стадии.</summary>
public float MaturityDays => Stages[^1].EnterDay;
/// <summary>Индекс стадии для данного возраста в днях.</summary>
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<PlantDef, int> _index = new();
/// <summary>Строит таблицу из всех (не-abstract) <see cref="PlantDef"/> мода.</summary>
public PlantSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
{
var defs = content.Defs.All<PlantDef>().ToArray();
_species = new Species[defs.Length];
for (var i = 0; i < defs.Length; i++)
{
var def = defs[i];
_species[i] = new Species { Def = def, Stages = BuildStages(def, atlases, device) };
_index[def] = i;
}
}
/// <summary>Число видов в таблице.</summary>
public int Count => _species.Length;
/// <summary>Вид по индексу.</summary>
public Species this[int index] => _species[index];
/// <summary>Индекс вида по дефу.</summary>
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;
}
}
+31 -31
View File
@@ -1,32 +1,30 @@
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Sim;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole; using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
namespace LittleSim.Scenes; namespace LittleSim.Scenes;
/// <summary>Спавн растительности по скаттеру дефов террейна (общий для обеих сцен).</summary> /// <summary>Спавн растительности по скаттеру дефов террейна.</summary>
internal static class ScatterSpawner internal static class ScatterSpawner
{ {
/// <summary> /// <summary>
/// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение /// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со
/// со случайным смещением. Деревья (ствол &gt; 0) получают коллайдер-препятствие, /// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации
/// когда заданы слои коллизий. /// разновозрастный. Стадия и спрайт берутся из <paramref name="plants"/>, дальше растением
/// управляет <see cref="PlantGrowthSystem"/>.
/// </summary> /// </summary>
public static void Spawn( public static void Spawn(
Scene scene, Scene scene,
GameContent content, GameContent content,
ModAtlases atlases, PlantSet plants,
GraphicsDevice device,
TerrainDef terrain, TerrainDef terrain,
Point cell, Point cell,
int cellSize, int cellSize,
Random random, Random random
uint obstacleLayer = 0,
uint collidesWith = 0
) )
{ {
foreach (var entry in terrain.Scatter) foreach (var entry in terrain.Scatter)
@@ -36,28 +34,30 @@ internal static class ScatterSpawner
continue; continue;
} }
var plant = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]); var def = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]);
var region = atlases.GetRegion(device, plant.Texture); var index = plants.IndexOf(def);
var sprite = new Sprite(region, GameLayers.Beings); var species = plants[index];
sprite.CenterOrigin(); var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize;
var entity = scene.Store.CreateEntity( var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize;
new Transform2D( var age = random.NextSingle() * species.MaturityDays;
new Vector2( var stage = species.StageAt(age);
(cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize, var resolved = species.Stages[stage];
(cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize
),
scale: new Vector2(cellSize * plant.SizeCells / region.Width)
),
sprite
);
if (obstacleLayer != 0 && plant.TrunkRadiusCells > 0f) var sprite = new Sprite(resolved.Region, GameLayers.Beings);
{ sprite.CenterOrigin();
var trunk = MrGameEng.Collisions.Collider.Circle(cellSize * plant.TrunkRadiusCells); scene.Store.CreateEntity(
trunk.Layer = obstacleLayer; new Transform2D(
trunk.CollidesWith = collidesWith; new Vector2(px, py),
entity.AddComponent(trunk); scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width)
} ),
sprite,
new PlantGrowth
{
Species = index,
Stage = stage,
AgeDays = age,
}
);
} }
} }
} }
+27 -5
View File
@@ -30,6 +30,9 @@ public sealed class WorldScene : Scene
{ {
public const int CellSize = 16; public const int CellSize = 16;
/// <summary>Сколько секунд масштабированного времени длится один игровой день.</summary>
public const float SecondsPerDay = 30f;
private readonly WorldConfig _config; private readonly WorldConfig _config;
private readonly WorldSave? _save; private readonly WorldSave? _save;
private readonly RectF _bounds; private readonly RectF _bounds;
@@ -66,7 +69,12 @@ public sealed class WorldScene : Scene
GameLayers.EnsureRegistered(renderer); GameLayers.EnsureRegistered(renderer);
this.UseTilemaps(); 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)); 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(); var console = this.UseDevConsole();
RegisterCommands(console, content, atlases); RegisterCommands(console, content, atlases);
UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, CellSize));
UpdateSystems.Add(new GodCameraSystem(camera, input, console, () => _pause.IsOpen)); UpdateSystems.Add(new GodCameraSystem(camera, input, console, () => _pause.IsOpen));
UpdateSystems.Add( 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))); UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
@@ -101,13 +110,16 @@ public sealed class WorldScene : Scene
/// <summary> /// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из /// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации. /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
/// </summary> /// </summary>
private void BuildTerrain( private void BuildTerrain(
GameContent content, GameContent content,
ModAtlases atlases, ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device, Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
AssetManager assets AssetManager assets,
PlantSet plants,
Random random
) )
{ {
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); 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++) 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
);
} }
} }
+20
View File
@@ -0,0 +1,20 @@
using Friflo.Engine.ECS;
namespace LittleSim.Sim;
/// <summary>
/// Состояние роста растения: индекс вида в <see cref="Content.PlantSet"/>, текущая стадия и
/// накопленный возраст в игровых днях. Плоские данные — переход между стадиями и смену спрайта
/// выполняет <see cref="PlantGrowthSystem"/>, читая пороги стадий из таблицы вида.
/// </summary>
public struct PlantGrowth : IComponent
{
/// <summary>Индекс вида растения в <see cref="Content.PlantSet"/>.</summary>
public int Species;
/// <summary>Текущая стадия роста (индекс в стадиях вида).</summary>
public int Stage;
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays;
}
+65
View File
@@ -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;
/// <summary>
/// Двигает рост растений: копит возраст по игровому календарю и при пересечении порога стадии
/// переключает спрайт на текстуру новой стадии, переотцентровывает и масштабирует его. Время
/// идёт по <see cref="Calendar"/> (пауза/скорость учитываются через <c>Tick.deltaTime</c>),
/// данные стадий — из <see cref="PlantSet"/>. Терминальную стадию пропускает (рост окончен).
/// </summary>
public sealed class PlantGrowthSystem(PlantSet plants, Calendar calendar, int cellSize)
: QuerySystem<PlantGrowth, Sprite, Transform2D>
{
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);
}
}
+3 -3
View File
@@ -161,7 +161,7 @@ public sealed class GodCameraSystem(
} }
/// <summary> /// <summary>
/// Строка статуса в HUD: заголовок сцены (ключ локализации с сидом, населением и FPS) /// Строка статуса в HUD: заголовок сцены (ключ локализации с сидом, днём календаря и FPS)
/// и подсказка управления. Обновляется 4 раза в секунду; смена языка подхватывается сама. /// и подсказка управления. Обновляется 4 раза в секунду; смена языка подхватывается сама.
/// </summary> /// </summary>
public sealed class HudSystem( public sealed class HudSystem(
@@ -170,7 +170,7 @@ public sealed class HudSystem(
Myra.Graphics2D.UI.Label label, Myra.Graphics2D.UI.Label label,
string titleKey, string titleKey,
int seed, int seed,
int population MrGameEng.Core.Calendar? calendar = null
) : BaseSystem ) : BaseSystem
{ {
private float _accumulated; private float _accumulated;
@@ -189,7 +189,7 @@ public sealed class HudSystem(
_accumulated = 0f; _accumulated = 0f;
_frames = 0; _frames = 0;
label.Text = label.Text =
languages.Format(titleKey, seed, population, fps) languages.Format(titleKey, seed, calendar?.Day ?? 0, fps)
+ "\n" + "\n"
+ languages.Get("hud.controls"); + languages.Get("hud.controls");
} }