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",
"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" }
+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.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",
+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.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": "медведь",
+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;
}
/// <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
{
/// <summary>Ключ текстуры (путь региона в атласах).</summary>
/// <summary>Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры.</summary>
public string Texture { get; init; } = "";
/// <summary>Размер спрайта в клетках.</summary>
/// <summary>Базовый размер спрайта в клетках; база для стадий без своего размера.</summary>
public float SizeCells { get; init; } = 1f;
/// <summary>Радиус коллайдера ствола в клетках; 0 — без коллайдера (трава, кусты).</summary>
public float TrunkRadiusCells { get; init; }
/// <summary>Стадии роста по порядку; пусто — одна терминальная стадия из базовой текстуры.</summary>
public List<PlantGrowthStage> Stages { get; init; } = [];
}
/// <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 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;
/// <summary>Спавн растительности по скаттеру дефов террейна (общий для обеих сцен).</summary>
/// <summary>Спавн растительности по скаттеру дефов террейна.</summary>
internal static class ScatterSpawner
{
/// <summary>
/// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение
/// со случайным смещением. Деревья (ствол &gt; 0) получают коллайдер-препятствие,
/// когда заданы слои коллизий.
/// Бросает каждую запись скаттера клетки независимо; при успехе ставит одно растение со
/// случайным смещением и случайной (детерминированной сидом) зрелостью — мир после генерации
/// разновозрастный. Стадия и спрайт берутся из <paramref name="plants"/>, дальше растением
/// управляет <see cref="PlantGrowthSystem"/>.
/// </summary>
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<PlantDef>(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<PlantDef>(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,
}
);
}
}
}
+27 -5
View File
@@ -30,6 +30,9 @@ public sealed class WorldScene : Scene
{
public const int CellSize = 16;
/// <summary>Сколько секунд масштабированного времени длится один игровой день.</summary>
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
/// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации.
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
/// </summary>
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
);
}
}
+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>
/// Строка статуса в HUD: заголовок сцены (ключ локализации с сидом, населением и FPS)
/// Строка статуса в HUD: заголовок сцены (ключ локализации с сидом, днём календаря и FPS)
/// и подсказка управления. Обновляется 4 раза в секунду; смена языка подхватывается сама.
/// </summary>
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");
}