Compare commits

...
4 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 0d7ea55824 Merge branch 'animal-facing' into main
Animals turn to face their direction of travel (east/north/south sprites + west
flip), via AnimalFacingSystem and directional sprite sets in AnimalSet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:12:32 +03:00
Leonid PershinandClaude Opus 4.8 edfbb15251 Animals face their movement direction (4-way: east/west-flip/north/south)
Animals had a single east-facing sprite and never turned. AnimalSet now loads a
directional set per look (female/male/baby): east from the def texture, north/south
by the _east->_north/_south naming convention (falls back to east if absent), with
west rendered as a horizontal flip of east. New AnimalFacingSystem orients each
animal toward its brain Target (dominant axis of travel), updating sprite region +
flip and recomputing origin/scale when the region changes; a standing animal keeps
its facing. ModAtlases gained TryGetRegion for the optional directional lookups.
Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:12:32 +03:00
Leonid PershinandClaude Opus 4.8 d9beac9331 Merge branch 'world-loading-scene' into main
Add a loading screen (WorldLoadingScene) between world setup/load and WorldScene so
the blocking world generation shows a "Generating world" indicator instead of a
blank fade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:02:19 +03:00
Leonid PershinandClaude Opus 4.8 6ec70cab91 Loading screen between world setup and the game scene
World generation (WorldScene.OnLoad: terrain, atlases, spawn) runs synchronously
and blocks the frame; previously only a blank fade covered it, with no feedback.
New WorldLoadingScene shows a "Generating world" label, lets it draw, then switches
to WorldScene WITHOUT a transition so its last drawn frame (the label) stays on
screen during the blocking generation. Both New World and Load Game route through
it. Localized loading.world ru/en. Build + --check-content clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:02:19 +03:00
10 changed files with 197 additions and 22 deletions
+1
View File
@@ -92,6 +92,7 @@
"menu.settings": "Settings", "menu.settings": "Settings",
"menu.credits": "Credits", "menu.credits": "Credits",
"menu.quit": "Quit", "menu.quit": "Quit",
"loading.world": "Generating world",
"newworld.title": "New World", "newworld.title": "New World",
"newworld.name": "Name", "newworld.name": "Name",
"newworld.defaultname": "New World", "newworld.defaultname": "New World",
+1
View File
@@ -92,6 +92,7 @@
"menu.settings": "Настройки", "menu.settings": "Настройки",
"menu.credits": "Авторы", "menu.credits": "Авторы",
"menu.quit": "Выход", "menu.quit": "Выход",
"loading.world": "Создание мира",
"newworld.title": "Новый мир", "newworld.title": "Новый мир",
"newworld.name": "Название", "newworld.name": "Название",
"newworld.defaultname": "Новый мир", "newworld.defaultname": "Новый мир",
+58 -17
View File
@@ -12,22 +12,30 @@ namespace LittleSim.Content;
/// </summary> /// </summary>
public sealed class AnimalSet public sealed class AnimalSet
{ {
/// <summary>Направленные спрайты одной «внешности» (пол/стадия): восток, север, юг. Запад = зеркало
/// востока (флип по X). Север/юг берутся по конвенции имени (_east→_north/_south), иначе = восток.</summary>
public readonly record struct Directional(
Texture2DRegion East,
Texture2DRegion North,
Texture2DRegion South
);
/// <summary>Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи.</summary> /// <summary>Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи.</summary>
public sealed class Species public sealed class Species
{ {
/// <summary>Исходный деф животного.</summary> /// <summary>Исходный деф животного.</summary>
public required AnimalDef Def { get; init; } public required AnimalDef Def { get; init; }
/// <summary>Спрайт взрослой самки (он же общий/fallback).</summary> /// <summary>Направленные спрайты взрослой самки (она же общий/fallback).</summary>
public required Texture2DRegion Female { get; init; } public required Directional FemaleDirs { get; init; }
/// <summary>Спрайт взрослого самца (fallback — самка).</summary> /// <summary>Направленные спрайты взрослого самца (fallback — самка).</summary>
public required Texture2DRegion Male { get; init; } public required Directional MaleDirs { get; init; }
/// <summary>Спрайт детёныша/подростка (fallback — самка).</summary> /// <summary>Направленные спрайты детёныша/подростка (fallback — самка).</summary>
public required Texture2DRegion Baby { get; init; } public required Directional BabyDirs { get; init; }
/// <summary>Спрайт трупа (fallback — самка).</summary> /// <summary>Спрайт трупа (fallback — самка, восток).</summary>
public required Texture2DRegion Corpse { get; init; } public required Texture2DRegion Corpse { get; init; }
/// <summary>Спрайт яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт-точка.</summary> /// <summary>Спрайт яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт-точка.</summary>
@@ -45,6 +53,12 @@ public sealed class AnimalSet
/// <summary>Эталонный размер тела вида (база GeneMaxBodySize) — предпосчитан для горячих путей /// <summary>Эталонный размер тела вида (база GeneMaxBodySize) — предпосчитан для горячих путей
/// (масса/метаболизм Клайбера каждый кадр), чтобы не делать строковый словарный лукап в цикле.</summary> /// (масса/метаболизм Клайбера каждый кадр), чтобы не делать строковый словарный лукап в цикле.</summary>
public required float RefBodySize { get; init; } public required float RefBodySize { get; init; }
/// <summary>Направленные спрайты для стадии/пола: детёныш → BabyDirs; иначе по полу (самец/самка).</summary>
public Directional LookDirs(int stage, bool isMale) =>
string.Equals(Stages[stage].Texture, "baby", StringComparison.Ordinal) ? BabyDirs
: isMale ? MaleDirs
: FemaleDirs;
} }
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные. // Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
@@ -105,22 +119,20 @@ public sealed class AnimalSet
for (var i = 0; i < defs.Length; i++) for (var i = 0; i < defs.Length; i++)
{ {
var def = defs[i]; var def = defs[i];
var female = atlases.GetRegion(device, def.Texture); var femaleKey = def.Texture;
var maleKey = string.IsNullOrEmpty(def.MaleTexture) ? femaleKey : def.MaleTexture;
var babyKey = string.IsNullOrEmpty(def.BabyTexture) ? femaleKey : def.BabyTexture;
_species[i] = new Species _species[i] = new Species
{ {
Def = def, Def = def,
Female = female, FemaleDirs = LoadDirs(atlases, device, femaleKey),
Male = string.IsNullOrEmpty(def.MaleTexture) MaleDirs = LoadDirs(atlases, device, maleKey),
? female BabyDirs = LoadDirs(atlases, device, babyKey),
: atlases.GetRegion(device, def.MaleTexture),
Baby = string.IsNullOrEmpty(def.BabyTexture)
? female
: atlases.GetRegion(device, def.BabyTexture),
Corpse = string.IsNullOrEmpty(def.CorpseTexture) Corpse = string.IsNullOrEmpty(def.CorpseTexture)
? female ? atlases.GetRegion(device, femaleKey)
: atlases.GetRegion(device, def.CorpseTexture), : atlases.GetRegion(device, def.CorpseTexture),
Egg = string.IsNullOrEmpty(def.EggTexture) Egg = string.IsNullOrEmpty(def.EggTexture)
? female ? atlases.GetRegion(device, femaleKey)
: atlases.GetRegion(device, def.EggTexture), : atlases.GetRegion(device, def.EggTexture),
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages, Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null, Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
@@ -134,6 +146,35 @@ public sealed class AnimalSet
} }
} }
// Грузит направленный набор по восточному ключу: север/юг — по конвенции имени (_east→_north/_south),
// если такой регион есть в атласе; иначе откат на восток (вид с одним спрайтом просто не разворачивается
// вверх/вниз, но влево/вправо — флипом востока). Запад всегда = зеркало востока.
private static Directional LoadDirs(ModAtlases atlases, GraphicsDevice device, string eastKey)
{
var east = atlases.GetRegion(device, eastKey);
return new Directional(
east,
TryDir(atlases, device, eastKey, "_north", east),
TryDir(atlases, device, eastKey, "_south", east)
);
}
private static Texture2DRegion TryDir(
ModAtlases atlases,
GraphicsDevice device,
string eastKey,
string suffix,
Texture2DRegion fallback
) =>
eastKey.Contains("_east", StringComparison.Ordinal)
&& atlases.TryGetRegion(
device,
eastKey.Replace("_east", suffix, StringComparison.Ordinal),
out var region
)
? region
: fallback;
/// <summary>Число видов в таблице.</summary> /// <summary>Число видов в таблице.</summary>
public int Count => _species.Length; public int Count => _species.Length;
+9
View File
@@ -48,6 +48,15 @@ public sealed class ModAtlases : IDisposable
return Get(device, name).GetRegion(textureKey); return Get(device, name).GetRegion(textureKey);
} }
/// <summary>Регион по ключу, если он есть (для опциональных вариантов, напр. направленных спрайтов).</summary>
public bool TryGetRegion(GraphicsDevice device, string textureKey, out Texture2DRegion region)
{
var segments = textureKey.Split('/');
var depth = Math.Min(GroupDepth, segments.Length - 1);
var name = depth == 0 ? "Atlas" : string.Join('.', segments[..depth]);
return Get(device, name).TryGetRegion(textureKey, out region);
}
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
+1 -1
View File
@@ -73,7 +73,7 @@ public sealed class LoadGameScene : Scene
if (!Context.Scenes.IsTransitioning) if (!Context.Scenes.IsTransitioning)
{ {
Context.Scenes.Switch( Context.Scenes.Switch(
new WorldScene(save.ToConfig(), save), new WorldLoadingScene(save.ToConfig(), save),
Transitions.Fade(0.6f) Transitions.Fade(0.6f)
); );
} }
+1 -1
View File
@@ -154,7 +154,7 @@ public sealed class NewWorldScene : Scene
Seed = seed, Seed = seed,
SmoothPasses = _smoothing, SmoothPasses = _smoothing,
}; };
Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f)); Context.Scenes.Switch(new WorldLoadingScene(config), Transitions.Fade(0.6f));
} }
private void Back() private void Back()
+63
View File
@@ -0,0 +1,63 @@
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using MrGameEng.Core;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Экран загрузки между окном создания/выбора мира и игровой сценой. Генерация мира
/// (<see cref="WorldScene.OnLoad"/>: рельеф, атласы, спавн) идёт синхронно и блокирует кадр; раньше под
/// ней был лишь сплошной фейд без индикатора. Эта сцена рисует надпись «Создание мира…», даёт ей
/// прорисоваться, затем переключается на <see cref="WorldScene"/> БЕЗ перехода — её последний кадр
/// (надпись) и остаётся на экране, пока идёт генерация, давая игроку понятную обратную связь.
/// </summary>
public sealed class WorldLoadingScene : Scene
{
private readonly WorldConfig _config;
private readonly WorldSave? _save;
private GameSettings _settings = new();
private Label _label = null!;
private int _frames;
/// <summary>Новый мир (<paramref name="save"/> = null) или загрузка сохранения.</summary>
public WorldLoadingScene(WorldConfig config, WorldSave? save = null)
{
_config = config;
_save = save;
}
protected override void OnLoad()
{
_settings = GameSettingsStore.Load();
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label
{
TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.Screen(_label);
UpdateSystems.Add(new CallbackSystem(Tick));
}
private void Tick()
{
var content = Context.Services.Get<GameContent>();
var word = content.Languages.Get("loading.world");
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots;
// Дождаться, пока надпись точно прорисована (переход въезда завершён + пара кадров), затем
// мгновенно переключиться: блокирующий WorldScene.OnLoad оставит на экране кадр с надписью.
_frames++;
if (Context.Scenes.IsTransitioning || _frames < 2)
{
return;
}
Context.Scenes.Switch(new WorldScene(_config, _save));
}
}
+1
View File
@@ -322,6 +322,7 @@ public sealed class WorldScene : Scene
); );
UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize));
UpdateSystems.Add(new AnimalFacingSystem(_animals, CellSize));
UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize)); UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize));
UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate)); UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate));
UpdateSystems.Add( UpdateSystems.Add(
+1 -3
View File
@@ -269,9 +269,7 @@ public static class AnimalFactory
/// <summary>Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка).</summary> /// <summary>Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка).</summary>
public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) => public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) =>
string.Equals(sp.Stages[stage].Texture, "baby", StringComparison.Ordinal) ? sp.Baby sp.LookDirs(stage, isMale).East; // восток — базовое направление; разворот делает AnimalFacingSystem
: isMale ? sp.Male
: sp.Female;
/// <summary>Взрослая ли стадия (способна размножаться) — флаг из данных стадии.</summary> /// <summary>Взрослая ли стадия (способна размножаться) — флаг из данных стадии.</summary>
public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult; public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult;
+61
View File
@@ -1627,6 +1627,67 @@ public sealed class AnimalAppearanceSystem(NeedSet needs)
} }
} }
/// <summary>
/// Разворот спрайта по направлению движения (как в RimWorld): зверь смотрит туда, куда идёт. Восток —
/// базовая текстура, запад — её зеркало (флип по X), север/юг — отдельные текстуры (если есть у вида).
/// Направление берётся из цели «мозга» (<see cref="AnimalBrain.Target"/>) относительно позиции; стоящий
/// зверь сохраняет прежний разворот. При смене региона пересчитывает центр-origin и масштаб (текстуры
/// направлений могут отличаться шириной). Презентация (читает sim, пишет только Sprite/Transform-scale).
/// </summary>
public sealed class AnimalFacingSystem(AnimalSet animals, int cellSize)
: QuerySystem<AnimalBrain, AnimalOrganism, AnimalGrowth, Transform2D, Sprite>
{
private const float MinMove = 0.5f; // короче — считаем стоящим, разворот не трогаем
protected override void OnUpdate()
{
foreach (var (brains, organisms, growths, transforms, sprites, _) in Query.Chunks)
{
var b = brains.Span;
var o = organisms.Span;
var g = growths.Span;
var t = transforms.Span;
var s = sprites.Span;
for (var i = 0; i < b.Length; i++)
{
var dir = b[i].Target - t[i].Position;
if (dir.LengthSquared() < MinMove * MinMove)
{
continue; // стоит/у цели — сохраняем текущий разворот
}
var sp = animals[o[i].Species];
var dirs = sp.LookDirs(g[i].Stage, o[i].IsMale);
Texture2DRegion region;
SpriteFlip flip;
if (MathF.Abs(dir.X) >= MathF.Abs(dir.Y))
{
region = dirs.East;
flip = dir.X < 0f ? SpriteFlip.X : SpriteFlip.None; // запад = зеркало востока
}
else
{
region = dir.Y < 0f ? dirs.North : dirs.South; // экранный Y вниз: вверх = север
flip = SpriteFlip.None;
}
ref var sprite = ref s[i];
if (sprite.Region == region && sprite.Flip == flip)
{
continue; // разворот не изменился
}
sprite.Region = region;
sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f);
sprite.Flip = flip;
t[i].Scale = new Vector2(
AnimalFactory.Scale(sp, o[i].Traits, g[i].Stage, region, cellSize)
);
}
}
}
}
/// <summary> /// <summary>
/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ /// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→
/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. /// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер.