Rework world scene into a textured tile terrain on engine WorldGen

Bump the engine submodule to 5365945 (MrGameEng.WorldGen) and drive
terrain from it: WorldGenerator is now a thin adapter over the engine's
seed-based Perlin/fBm/island HeightmapGenerator instead of the old
random+smoothing pass.

Strip WorldScene down to terrain + camera: render the world as a single
Tilemap entity using per-biome surface textures from the atlas (water
falls back to a tinted tile), and remove pawns, plants, AI systems,
population and save-restore for now. Delete the TerrainScene demo (its
tile approach now lives in WorldScene) along with its console command
and the hud.terrain/population localization strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 09:24:32 +03:00
co-authored by Claude Opus 4.8
parent 9433db6212
commit 923644dd19
6 changed files with 66 additions and 433 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
{ {
"hud.world": "LittleSim | seed {0} | population {1} | {2} FPS", "hud.world": "LittleSim | seed {0} | {2} FPS",
"hud.terrain": "LittleSim | terrain | seed {0} | animals: {1}",
"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",
+1 -2
View File
@@ -1,6 +1,5 @@
{ {
"hud.world": "LittleSim | сид {0} | население {1} | {2} FPS", "hud.world": "LittleSim | сид {0} | {2} FPS",
"hud.terrain": "LittleSim | террейн | сид {0} | животных: {1}",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль ('help' — список команд)", "hud.controls": "WASD — камера, колесо — зум, ` — консоль ('help' — список команд)",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
+1 -1
Submodule engine updated: ef1111bcb6...53659450a6
-180
View File
@@ -1,180 +0,0 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Assets;
using MrGameEng.Collisions;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.Tilemaps;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Демо-сцена тайлового террейна: карта-Tilemap по дефам рельефа (поверхность из атласа
/// или тонированный тайл), растительность по скаттеру дефов (деревья — с коллайдером
/// ствола), животные — дефы вида "animal" с расталкиванием. Команды: "regen", "world".
/// </summary>
public sealed class TerrainScene : Scene
{
private const int TerrainWidth = 44;
private const int TerrainHeight = 28;
private const int CellSize = 24;
private const int Population = 14;
private static readonly RectF TerrainBounds = new(
0f,
0f,
TerrainWidth * CellSize,
TerrainHeight * CellSize
);
private const uint AnimalLayer = 0b01;
private const uint ObstacleLayer = 0b10;
private readonly int _seed;
public TerrainScene(int seed) => _seed = seed;
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.GraphicsDevice;
var input = this.UseInput();
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
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);
}
// Террейн строится кодом: высоты по сиду → деф клетки → id тайла; поверх — скаттер.
var random = new Random(_seed);
var heights = WorldGenerator.GenerateHeights(TerrainWidth, TerrainHeight, _seed);
var grid = new TileGrid(TerrainWidth, TerrainHeight);
var landCells = new List<Point>();
for (var x = 0; x < TerrainWidth; x++)
{
for (var y = 0; y < TerrainHeight; y++)
{
var terrain = content.Terrains.Classify(heights[x, y]);
grid[x, y] = tileByDef[terrain];
var cell = new Point(x, y);
if (terrain.IsLand)
{
landCells.Add(cell);
}
ScatterSpawner.Spawn(
this,
content,
atlases,
device,
terrain,
cell,
CellSize,
random,
obstacleLayer: ObstacleLayer,
collidesWith: AnimalLayer
);
}
}
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
// Животные: дефы вида "animal", не наслаиваются (коллайдеры + расталкивание).
var animals = content.Defs.All<PawnDef>().Where(p => p.Kind == PawnDef.KindAnimal).ToList();
for (var i = 0; i < Population && landCells.Count > 0 && animals.Count > 0; i++)
{
var cell = landCells[random.Next(landCells.Count)];
var animal = animals[random.Next(animals.Count)];
var body = atlases.GetRegion(device, animal.Texture);
var sprite = new Sprite(body, GameLayers.Beings);
sprite.CenterOrigin();
var collider = Collider.Circle(animal.SizeCells * CellSize * 0.32f);
collider.Layer = AnimalLayer;
collider.CollidesWith = AnimalLayer | ObstacleLayer;
Store.CreateEntity(
new Transform2D(
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
scale: new Vector2(CellSize * animal.SizeCells / body.Width)
),
sprite,
new Wander(),
collider
);
}
var camera = Store.CreateEntity(
new Camera(TerrainBounds.Center, zoom: 1.6f, bounds: TerrainBounds)
);
var desktop = this.UseUI();
var hudLabel = new Label { Left = 10, Top = 8 };
desktop.Root = hudLabel;
var console = this.UseDevConsole();
ContentCommands.Register(console, content, atlases);
console.Register(
"regen",
"regen [seed] — regenerate the terrain",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating terrain, seed {seed}");
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
}
);
console.Register(
"world",
"world [seed] — switch to the world scene",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
Context.Scenes.Switch(
new WorldScene(new App.WorldConfig { Name = "World", Seed = seed }),
Transition.Fade(0.6f)
);
}
);
UpdateSystems.Add(new WanderSystem(_seed, TerrainBounds));
var collisions = this.UseCollisions(cellSize: CellSize * 2f); // после движения
UpdateSystems.Add(new SeparationSystem(collisions));
UpdateSystems.Add(new GodCameraSystem(camera, input, console));
UpdateSystems.Add(
new HudSystem(Context, content.Languages, hudLabel, "hud.terrain", _seed, Population)
);
Log.Info(
$"Terrain generated: seed {_seed}, {TerrainWidth}x{TerrainHeight} cells, {Population} animals"
);
}
}
+40 -181
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
@@ -13,6 +12,7 @@ using MrGameEng.Core;
using MrGameEng.DevConsole; using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Input;
using MrGameEng.Tilemaps;
using MrGameEng.UI; using MrGameEng.UI;
using Myra.Graphics2D; using Myra.Graphics2D;
using Myra.Graphics2D.UI; using Myra.Graphics2D.UI;
@@ -20,10 +20,11 @@ using Myra.Graphics2D.UI;
namespace LittleSim.Scenes; namespace LittleSim.Scenes;
/// <summary> /// <summary>
/// Мир LittleSim: рельеф, растения и жители целиком описаны дефами Core-мода. Сцена строится /// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
/// из <see cref="WorldConfig"/> (размер/сид/население/сглаживание) и опционально восстанавливает /// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
/// полное состояние симуляции из <see cref="WorldSave"/>. Поверх мира — HUD, полоса скорости /// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc) и дев-консоль. /// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
/// </summary> /// </summary>
public sealed class WorldScene : Scene public sealed class WorldScene : Scene
{ {
@@ -41,7 +42,7 @@ public sealed class WorldScene : Scene
public WorldScene(WorldConfig config) public WorldScene(WorldConfig config)
: this(config, null) { } : this(config, null) { }
/// <summary>Мир, восстановленный из сохранения (рельеф — из сида, жители — из снимка).</summary> /// <summary>Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).</summary>
public WorldScene(WorldConfig config, WorldSave? save) public WorldScene(WorldConfig config, WorldSave? save)
{ {
_config = config; _config = config;
@@ -63,47 +64,9 @@ public sealed class WorldScene : Scene
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) } new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
); );
GameLayers.EnsureRegistered(renderer); GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); BuildTerrain(content, atlases, device, assets);
// Рельеф детерминирован сидом и параметром сглаживания — и для нового мира, и для загрузки.
var random = new Random(_config.Seed);
var heights = WorldGenerator.GenerateHeights(
_config.Width,
_config.Height,
_config.Seed,
_config.SmoothPasses
);
for (var x = 0; x < _config.Width; x++)
{
for (var y = 0; y < _config.Height; y++)
{
var terrain = content.Terrains.Classify(heights[x, y]);
Store.CreateEntity(
Transform2D.At(new Vector2(x * CellSize, y * CellSize)),
new Sprite(white) { Color = terrain.Tint }
);
ScatterSpawner.Spawn(
this,
content,
atlases,
device,
terrain,
new Point(x, y),
CellSize,
random
);
}
}
if (_save is null)
{
SpawnPopulation(content, atlases, device, heights, random);
}
else
{
RestorePopulation(content, atlases, device);
}
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds)); var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
@@ -124,21 +87,9 @@ public sealed class WorldScene : Scene
var console = this.UseDevConsole(); var console = this.UseDevConsole();
RegisterCommands(console, content, atlases); RegisterCommands(console, content, atlases);
var decisions = new PawnDecisionSystem();
UpdateSystems.Add(decisions);
UpdateSystems.Add(new WanderSystem(_config.Seed, _bounds));
UpdateSystems.Add(new PawnNeedsSystem());
UpdateSystems.Add(new PawnAppearanceSystem());
UpdateSystems.Add(new GodCameraSystem(camera, input, console, () => _pause.IsOpen)); UpdateSystems.Add(new GodCameraSystem(camera, input, console, () => _pause.IsOpen));
UpdateSystems.Add( UpdateSystems.Add(
new HudSystem( new HudSystem(Context, content.Languages, hudLabel, "hud.world", _config.Seed, 0)
Context,
content.Languages,
hudLabel,
"hud.world",
_config.Seed,
_config.Population
)
); );
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input))); UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
@@ -148,95 +99,46 @@ public sealed class WorldScene : Scene
); );
} }
private void SpawnPopulation( /// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации.
/// </summary>
private void BuildTerrain(
GameContent content, GameContent content,
ModAtlases atlases, ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device, Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
float[,] heights, AssetManager assets
Random random
) )
{ {
var landCells = new List<Point>(); 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);
for (var x = 0; x < _config.Width; x++) for (var x = 0; x < _config.Width; x++)
{ {
for (var y = 0; y < _config.Height; y++) for (var y = 0; y < _config.Height; y++)
{ {
if (content.Terrains.Classify(heights[x, y]).IsLand) grid[x, y] = tileByDef[content.Terrains.Classify(heights[x, y])];
{
landCells.Add(new Point(x, y));
}
} }
} }
var beings = content.Defs.All<PawnDef>().Where(p => p.Kind == PawnDef.KindBeing).ToList(); Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
for (var i = 0; i < _config.Population && landCells.Count > 0 && beings.Count > 0; i++)
{
var cell = landCells[random.Next(landCells.Count)];
var being = beings[random.Next(beings.Count)];
CreatePawn(
atlases,
device,
being,
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
energy: 0.5f + random.NextSingle() * 0.5f,
action: PawnAction.Wander,
decideIn: random.NextSingle() * 0.75f,
direction: Vector2.Zero,
changeIn: 0f
);
}
}
private void RestorePopulation(
GameContent content,
ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device
)
{
foreach (var pawn in _save!.Pawns)
{
if (!content.Defs.TryGet<PawnDef>(pawn.DefName, out var being))
{
continue; // деф пропал (мод убрали) — пропускаем жителя
}
CreatePawn(
atlases,
device,
being,
new Vector2(pawn.X, pawn.Y),
energy: pawn.Energy,
action: (PawnAction)pawn.Action,
decideIn: pawn.DecideIn,
direction: new Vector2(pawn.DirX, pawn.DirY),
changeIn: pawn.ChangeIn
);
}
}
private void CreatePawn(
ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
PawnDef being,
Vector2 position,
float energy,
PawnAction action,
float decideIn,
Vector2 direction,
float changeIn
)
{
var body = atlases.GetRegion(device, being.Texture);
var sprite = new Sprite(body, GameLayers.Beings);
sprite.CenterOrigin();
Store.CreateEntity(
new Transform2D(position, scale: new Vector2(CellSize * being.SizeCells / body.Width)),
sprite,
new Wander { Direction = direction, ChangeIn = changeIn },
new PawnNeeds { Energy = energy },
new PawnBrain { Action = action, DecideIn = decideIn },
new PawnId { DefName = being.DefName }
);
} }
private HorizontalStackPanel BuildSpeedBar(GameContent content) private HorizontalStackPanel BuildSpeedBar(GameContent content)
@@ -312,6 +214,7 @@ public sealed class WorldScene : Scene
private string SaveWorld() private string SaveWorld()
{ {
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
var save = new WorldSave var save = new WorldSave
{ {
Name = _config.Name, Name = _config.Name,
@@ -324,38 +227,8 @@ public sealed class WorldScene : Scene
ElapsedSeconds = Context.Clock.TotalTime, ElapsedSeconds = Context.Clock.TotalTime,
}; };
Store
.Query<PawnId, Transform2D, Wander, PawnNeeds, PawnBrain>()
.ForEachEntity(
(
ref PawnId id,
ref Transform2D transform,
ref Wander wander,
ref PawnNeeds needs,
ref PawnBrain brain,
Entity _
) =>
{
save.Pawns.Add(
new PawnSave
{
DefName = id.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
Scale = transform.Scale.X,
Energy = needs.Energy,
Action = (int)brain.Action,
DecideIn = brain.DecideIn,
DirX = wander.Direction.X,
DirY = wander.Direction.Y,
ChangeIn = wander.ChangeIn,
}
);
}
);
new SaveStore().Write(save); new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved ({save.Pawns.Count} pawns)"); Log.Info($"World '{_config.Name}' saved");
return _config.Name; return _config.Name;
} }
@@ -389,20 +262,6 @@ public sealed class WorldScene : Scene
); );
} }
); );
console.Register(
"terrain",
"terrain [seed] — switch to the tile terrain demo scene",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : _config.Seed;
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
}
);
console.Register( console.Register(
"menu", "menu",
"menu — return to the main menu", "menu — return to the main menu",
+23 -67
View File
@@ -1,77 +1,33 @@
using MrGameEng.WorldGen;
namespace LittleSim.Sim; namespace LittleSim.Sim;
/// <summary> /// <summary>
/// Детерминированная генерация рельефа: случайное поле, сглаженное несколькими проходами. /// Игровой адаптер процедурной генерации: превращает параметры мира в настройки движкового
/// Один и тот же сид всегда даёт один и тот же мир — фундамент воспроизводимой симуляции. /// <see cref="HeightmapGenerator"/> (шум Перлина → fBm → island-маска) и отдаёт нормализованную
/// Классификация высот в типы клеток живёт в дефах (<see cref="Content.TerrainSet"/>). /// карту высот. Один и тот же сид всегда даёт один и тот же мир — фундамент воспроизводимой
/// симуляции. Классификация высот в типы клеток живёт в дефах (<see cref="Content.TerrainSet"/>).
/// </summary> /// </summary>
public static class WorldGenerator public static class WorldGenerator
{ {
public static float[,] GenerateHeights(int width, int height, int seed, int passes = 4) /// <summary>
/// Строит нормализованную карту высот <paramref name="width"/>×<paramref name="height"/> из
/// <paramref name="seed"/>. <paramref name="detail"/> (1..8, из меню «масштаб деталей»/сейва)
/// задаёт размер «континентов»: больше — крупнее и плавнее суша (ниже частота шума).
/// </summary>
public static Heightmap Generate(int width, int height, int seed, int detail = 4)
{ {
var random = new Random(seed); var scale = Math.Clamp(detail, 1, 8);
var heights = new float[width, height]; var settings = new HeightmapSettings
for (var x = 0; x < width; x++)
{ {
for (var y = 0; y < height; y++) Seed = seed,
{ Frequency = 6f / scale, // больше «масштаба деталей» → ниже частота → крупнее континенты
heights[x, y] = random.NextSingle(); Octaves = 5,
} Persistence = 0.5f,
} Lacunarity = 2f,
IslandStrength = 0.55f, // края мира уходят под воду — мир выглядит как континент
// Сглаживание усреднением окрестности 3×3 — дёшево и даёт связные "континенты". IslandPower = 3f,
// Больше проходов — крупнее и плавнее "континенты". };
for (var pass = 0; pass < passes; pass++) return new HeightmapGenerator(settings).Generate(width, height);
{
var smoothed = new float[width, height];
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
var sum = 0f;
var count = 0;
for (var dx = -1; dx <= 1; dx++)
{
for (var dy = -1; dy <= 1; dy++)
{
var nx = x + dx;
var ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height)
{
sum += heights[nx, ny];
count++;
}
}
}
smoothed[x, y] = sum / count;
}
}
heights = smoothed;
}
Normalize(heights, width, height);
return heights;
}
private static void Normalize(float[,] heights, int width, int height)
{
var min = float.MaxValue;
var max = float.MinValue;
foreach (var value in heights)
{
min = Math.Min(min, value);
max = Math.Max(max, value);
}
var range = Math.Max(0.0001f, max - min);
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
heights[x, y] = (heights[x, y] - min) / range;
}
}
} }
} }