Initial LittleSim scaffold on the mrgameeng engine submodule

God-game: minimal graphics, deep simulation. The engine lives in the
engine/ git submodule and its sources plus test suites are part of
LittleSim.sln, so engine and game are developed in one editor window.

Playable skeleton: seed-deterministic terrain (smoothed height field ->
water/sand/grass/forest/mountain tinted cells), 80 wandering villagers
on a Y-sorted layer, god camera (WASD pan, wheel zoom, world-bounds
clamp), HUD and the dev console with a 'regen [seed]' command.

CLAUDE.md sets the game rules: simulation-first, one-seed determinism,
simulation/presentation split, a console command for every mechanic.
Design docs in Russian under docs/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 05:33:10 +03:00
co-authored by Claude Fable 5
commit beed114508
15 changed files with 1168 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Input\MrGameEng.Input.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Assets\**\*.*" />
<None Include="Assets\**\*.*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using var host = new GameHost(
new GameHostOptions
{
Title = "LittleSim",
Width = 1280,
Height = 720,
ClearColor = new Color(12, 16, 24),
},
new WorldScene(seed: 1337));
host.Run();
+105
View File
@@ -0,0 +1,105 @@
using Friflo.Engine.ECS;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Мир LittleSim: сгенерированный по сиду рельеф (клетки-спрайты) и жители, бродящие по нему.
/// Камера бога, HUD и консоль с командой "regen [seed]".
/// </summary>
public sealed class WorldScene : Scene
{
public const int WorldWidth = 120;
public const int WorldHeight = 70;
public const int CellSize = 16;
private const int Population = 80;
private static readonly RectF WorldBounds = new(0f, 0f, WorldWidth * CellSize, WorldHeight * CellSize);
private static LayerId _beingsLayer;
private static bool _layersRegistered;
private readonly int _seed;
public WorldScene(int seed) => _seed = seed;
protected override void OnLoad()
{
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var input = this.UseInput();
var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) });
if (!_layersRegistered)
{
_beingsLayer = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort);
_layersRegistered = true;
}
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
// Рельеф: одна текстура на все клетки → один draw call на слой.
var heights = WorldGenerator.GenerateHeights(WorldWidth, WorldHeight, _seed);
var landCells = new List<Point>();
for (var x = 0; x < WorldWidth; x++)
{
for (var y = 0; y < WorldHeight; y++)
{
var kind = WorldGenerator.Classify(heights[x, y]);
Store.CreateEntity(
Transform2D.At(new Vector2(x * CellSize, y * CellSize)),
new Sprite(white) { Color = WorldGenerator.ColorOf(kind) });
if (WorldGenerator.IsLand(kind))
{
landCells.Add(new Point(x, y));
}
}
}
// Жители: появляются на суше, бродят (Y-sort слой — кто ниже, тот ближе).
var random = new Random(_seed);
for (var i = 0; i < Population && landCells.Count > 0; i++)
{
var cell = landCells[random.Next(landCells.Count)];
var sprite = new Sprite(white, _beingsLayer) { Color = new Color(214, 81, 72) };
sprite.CenterOrigin();
Store.CreateEntity(
new Transform2D(
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
scale: new Vector2(0.45f)),
sprite,
new Wander());
}
var camera = Store.CreateEntity(new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds));
// HUD и консоль.
var desktop = this.UseUI();
var hudLabel = new Label { Left = 10, Top = 8 };
desktop.Root = hudLabel;
var console = this.UseDevConsole();
console.Register("regen", "regen [seed] — regenerate the world", (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(seed), Transition.Fade(0.6f));
});
UpdateSystems.Add(new WanderSystem(_seed, WorldBounds));
UpdateSystems.Add(new GodCameraSystem(camera, input, console));
UpdateSystems.Add(new HudSystem(Context, hudLabel, _seed, Population));
Log.Info($"World generated: seed {_seed}, {WorldWidth}x{WorldHeight} cells, population {Population}");
}
}
+105
View File
@@ -0,0 +1,105 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Graphics;
using MrGameEng.Input;
namespace LittleSim.Sim;
/// <summary>Житель бродит: направление меняется по таймеру, движение детерминировано сидом.</summary>
public struct Wander : IComponent
{
public Vector2 Direction;
public float ChangeIn;
}
/// <summary>
/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде
/// и порядке обновления симуляция воспроизводима.
/// </summary>
public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem<Transform2D, Wander>
{
private const float Speed = 22f;
private readonly Random _random = new(seed);
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
foreach (var (transforms, wanders, _) in Query.Chunks)
{
var t = transforms.Span;
var w = wanders.Span;
for (var i = 0; i < t.Length; i++)
{
ref var wander = ref w[i];
wander.ChangeIn -= delta;
if (wander.ChangeIn <= 0f)
{
var angle = _random.NextSingle() * MathF.Tau;
wander.Direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
wander.ChangeIn = 1f + _random.NextSingle() * 3f;
}
ref var position = ref t[i].Position;
position += wander.Direction * (Speed * delta);
position.X = Math.Clamp(position.X, worldBounds.Left, worldBounds.Right);
position.Y = Math.Clamp(position.Y, worldBounds.Top, worldBounds.Bottom);
}
}
}
}
/// <summary>Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль.</summary>
public sealed class GodCameraSystem(
Entity cameraEntity, InputManager input, MrGameEng.DevConsole.DevConsole console) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (console.IsOpen)
{
return;
}
ref var camera = ref cameraEntity.GetComponent<Camera>();
var pan = new Vector2(
Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right),
Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down));
if (pan != Vector2.Zero)
{
pan.Normalize();
camera.Position += pan * (500f / camera.Zoom) * Tick.deltaTime;
}
camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.4f, 8f);
}
private static float Axis(InputManager input, Keys negativeA, Keys negativeB, Keys positiveA, Keys positiveB) =>
(input.IsKeyDown(positiveA) || input.IsKeyDown(positiveB) ? 1f : 0f) -
(input.IsKeyDown(negativeA) || input.IsKeyDown(negativeB) ? 1f : 0f);
}
/// <summary>Строка статуса в HUD: сид, население, FPS (обновляется 4 раза в секунду).</summary>
public sealed class HudSystem(
MrGameEng.Core.EngineContext context, Myra.Graphics2D.UI.Label label, int seed, int population) : BaseSystem
{
private float _accumulated;
private int _frames;
protected override void OnUpdateGroup()
{
_accumulated += context.Clock.UnscaledDeltaTime;
_frames++;
if (_accumulated < 0.25f)
{
return;
}
var fps = _frames / _accumulated;
_accumulated = 0f;
_frames = 0;
label.Text = $"LittleSim | seed {seed} | население {population} | {fps:F0} FPS\nWASD — камера, колесо — зум, ` — консоль ('regen', 'timescale')";
}
}
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.Xna.Framework;
namespace LittleSim.Sim;
/// <summary>Тип клетки мира по высоте рельефа.</summary>
public enum TerrainKind
{
DeepWater,
Water,
Sand,
Grass,
Forest,
Mountain,
}
/// <summary>
/// Детерминированная генерация рельефа: случайное поле, сглаженное несколькими проходами.
/// Один и тот же сид всегда даёт один и тот же мир — фундамент воспроизводимой симуляции.
/// </summary>
public static class WorldGenerator
{
public static float[,] GenerateHeights(int width, int height, int seed)
{
var random = new Random(seed);
var heights = new float[width, height];
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
heights[x, y] = random.NextSingle();
}
}
// Сглаживание усреднением окрестности 3×3 — дёшево и даёт связные "континенты".
for (var pass = 0; pass < 4; pass++)
{
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;
}
public static TerrainKind Classify(float height) => height switch
{
< 0.30f => TerrainKind.DeepWater,
< 0.42f => TerrainKind.Water,
< 0.48f => TerrainKind.Sand,
< 0.68f => TerrainKind.Grass,
< 0.85f => TerrainKind.Forest,
_ => TerrainKind.Mountain,
};
public static Color ColorOf(TerrainKind kind) => kind switch
{
TerrainKind.DeepWater => new Color(23, 63, 95),
TerrainKind.Water => new Color(32, 99, 155),
TerrainKind.Sand => new Color(237, 201, 120),
TerrainKind.Grass => new Color(90, 160, 70),
TerrainKind.Forest => new Color(44, 110, 50),
TerrainKind.Mountain => new Color(136, 132, 128),
_ => Color.Magenta,
};
public static bool IsLand(TerrainKind kind) =>
kind is TerrainKind.Sand or TerrainKind.Grass or TerrainKind.Forest;
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;
}
}
}
}