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>
This commit is contained in:
Leonid Pershin
2026-06-14 23:12:32 +03:00
co-authored by Claude Opus 4.8
parent d9beac9331
commit edfbb15251
5 changed files with 130 additions and 20 deletions
+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
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) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер.