Add pawn utility-AI brain; format code with CSharpier; enforce formatter
Wire the engine's new MrGameEng.AI module into a PawnBrain for world pawns, plus surrounding content/scene tweaks. Apply CSharpier across the game and document the convention in CLAUDE.md. Add .vscode/settings.json so the editor formats on save with the CSharpier extension.
This commit is contained in:
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.defaultFormatter": "csharpier.csharpier-vscode",
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
@@ -63,3 +63,7 @@ Never commit a pointer to an unpushed engine commit.
|
||||
- Content as data: numbers, textures and balance live in `Mods/Core/Defs`, user-facing
|
||||
strings in `Mods/Core/Languages` — code only defines systems and def classes.
|
||||
- Every new mechanic gets a dev-console command for testing (`regen`, `timescale`, …).
|
||||
- Formatting: all C# code is formatted with **CSharpier** (`editor.defaultFormatter`
|
||||
is `csharpier.csharpier-vscode`, format-on-save is on). Match CSharpier's output —
|
||||
run `csharpier format .` (or let format-on-save handle it) before committing; never
|
||||
hand-format against it.
|
||||
|
||||
@@ -51,6 +51,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods", "engine\sr
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods.Tests", "engine\tests\MrGameEng.Mods.Tests\MrGameEng.Mods.Tests.csproj", "{07986629-FE7C-42BE-868B-0FCA1915D2A2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AI", "engine\src\MrGameEng.AI\MrGameEng.AI.csproj", "{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AI.Tests", "engine\tests\MrGameEng.AI.Tests\MrGameEng.AI.Tests.csproj", "{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -301,6 +305,30 @@ Global
|
||||
{07986629-FE7C-42BE-868B-0FCA1915D2A2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{07986629-FE7C-42BE-868B-0FCA1915D2A2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{07986629-FE7C-42BE-868B-0FCA1915D2A2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -328,5 +356,7 @@ Global
|
||||
{B9161776-32FE-415E-9401-DC5BB87FC225} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{F91AA8C8-FD6B-4B92-BB3A-844DCA932D82} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
|
||||
{07986629-FE7C-42BE-868B-0FCA1915D2A2} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{EBD0B6A4-B8F3-45D6-88EF-D5D17DEDEFA5} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
|
||||
{6731DC3C-0807-4791-AF8E-C56F7F7D1F85} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
- [x] Жители-заглушки (блуждание), камера бога, HUD, консоль (`regen`)
|
||||
|
||||
## Этап 1 — Жители как агенты
|
||||
- [ ] Потребности: голод, усталость (тикают со временем)
|
||||
- [ ] Utility-выбор действия: искать еду / отдыхать / бродить
|
||||
- [~] Потребности: голод, усталость (тикают со временем) — усталость/энергия готова (`PawnNeeds`), голод впереди
|
||||
- [~] Utility-выбор действия: искать еду / отдыхать / бродить — каркас на движке (`MrGameEng.AI`), выбор «отдыхать/бродить» работает; «искать еду» впереди
|
||||
- [ ] Еда на траве/в лесу: растёт, собирается, истощается
|
||||
- [ ] Смерть от голода; визуальное состояние жителя (цвет/прозрачность)
|
||||
- [ ] Консоль: `spawn <n>`, `feed`, `starve` для тестов
|
||||
|
||||
@@ -16,7 +16,8 @@ public sealed class GameContent
|
||||
DefDatabase defs,
|
||||
LanguageManager languages,
|
||||
TerrainSet terrains,
|
||||
string atlasCacheDirectory)
|
||||
string atlasCacheDirectory
|
||||
)
|
||||
{
|
||||
Mods = mods;
|
||||
Defs = defs;
|
||||
@@ -46,9 +47,11 @@ public sealed class GameContent
|
||||
/// </summary>
|
||||
public static GameContent Load()
|
||||
{
|
||||
var modsRoot = ModLoader.FindModsRoot(AppContext.BaseDirectory)
|
||||
var modsRoot =
|
||||
ModLoader.FindModsRoot(AppContext.BaseDirectory)
|
||||
?? throw new DirectoryNotFoundException(
|
||||
"Папка Mods не найдена ни рядом с игрой, ни выше по дереву каталогов.");
|
||||
"Папка Mods не найдена ни рядом с игрой, ни выше по дереву каталогов."
|
||||
);
|
||||
var mods = ModLoader.Load(modsRoot);
|
||||
|
||||
var defs = new DefDatabase();
|
||||
@@ -62,14 +65,31 @@ public sealed class GameContent
|
||||
|
||||
// Текстуры поздних модов переопределяют ранние по относительному пути;
|
||||
// сборка инкрементальная — без изменений старт мгновенный.
|
||||
var atlasCacheDirectory = Path.Combine(Path.GetDirectoryName(modsRoot)!, "Cache", "Atlases");
|
||||
var atlasCacheDirectory = Path.Combine(
|
||||
Path.GetDirectoryName(modsRoot)!,
|
||||
"Cache",
|
||||
"Atlases"
|
||||
);
|
||||
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
|
||||
var result = AtlasBuilder.Build(
|
||||
new AtlasBuildOptions { OutputDirectory = atlasCacheDirectory, GroupDepth = ModAtlases.GroupDepth },
|
||||
textures.Files.Select(f => (f.FullPath, f.RelativePath)));
|
||||
new AtlasBuildOptions
|
||||
{
|
||||
OutputDirectory = atlasCacheDirectory,
|
||||
GroupDepth = ModAtlases.GroupDepth,
|
||||
},
|
||||
textures.Files.Select(f => (f.FullPath, f.RelativePath))
|
||||
);
|
||||
var built = result.Groups.Count(g => !g.Skipped);
|
||||
Log.Info($"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)");
|
||||
Log.Info(
|
||||
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
|
||||
);
|
||||
|
||||
return new GameContent(mods, defs, languages, new TerrainSet(defs.All<TerrainDef>()), atlasCacheDirectory);
|
||||
return new GameContent(
|
||||
mods,
|
||||
defs,
|
||||
languages,
|
||||
new TerrainSet(defs.All<TerrainDef>()),
|
||||
atlasCacheDirectory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ public sealed class ModAtlases : IDisposable
|
||||
public const int GroupDepth = 2;
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly Dictionary<string, TextureAtlas> _loaded = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TextureAtlas> _loaded = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
/// <summary>Создаёт реестр над кэш-директорией атласов.</summary>
|
||||
public ModAtlases(string directory) => _directory = directory;
|
||||
|
||||
@@ -10,7 +10,9 @@ public sealed class TerrainSet
|
||||
{
|
||||
if (defs.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("Не загружен ни один TerrainDef — отсутствует Core-мод?");
|
||||
throw new InvalidDataException(
|
||||
"Не загружен ни один TerrainDef — отсутствует Core-мод?"
|
||||
);
|
||||
}
|
||||
|
||||
_byHeight = defs.OrderBy(d => d.MaxHeight).ToArray();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -14,17 +13,20 @@
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Pathfinding\MrGameEng.Pathfinding.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.AI\MrGameEng.AI.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Collisions\MrGameEng.Collisions.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Mods\MrGameEng.Mods.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
<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>
|
||||
|
||||
@@ -14,7 +14,8 @@ using var host = new GameHost(
|
||||
Height = 720,
|
||||
ClearColor = new Color(12, 16, 24),
|
||||
},
|
||||
new WorldScene(seed: 1337));
|
||||
new WorldScene(seed: 1337)
|
||||
);
|
||||
|
||||
host.Context.Services.Add(content);
|
||||
host.Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
|
||||
|
||||
@@ -17,9 +17,17 @@ internal static class ScatterSpawner
|
||||
/// когда заданы слои коллизий.
|
||||
/// </summary>
|
||||
public static void Spawn(
|
||||
Scene scene, GameContent content, ModAtlases atlases, GraphicsDevice device,
|
||||
TerrainDef terrain, Point cell, int cellSize, Random random,
|
||||
uint obstacleLayer = 0, uint collidesWith = 0)
|
||||
Scene scene,
|
||||
GameContent content,
|
||||
ModAtlases atlases,
|
||||
GraphicsDevice device,
|
||||
TerrainDef terrain,
|
||||
Point cell,
|
||||
int cellSize,
|
||||
Random random,
|
||||
uint obstacleLayer = 0,
|
||||
uint collidesWith = 0
|
||||
)
|
||||
{
|
||||
foreach (var entry in terrain.Scatter)
|
||||
{
|
||||
@@ -36,9 +44,12 @@ internal static class ScatterSpawner
|
||||
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);
|
||||
(cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize
|
||||
),
|
||||
scale: new Vector2(cellSize * plant.SizeCells / region.Width)
|
||||
),
|
||||
sprite
|
||||
);
|
||||
|
||||
if (obstacleLayer != 0 && plant.TrunkRadiusCells > 0f)
|
||||
{
|
||||
@@ -57,7 +68,10 @@ internal static class ContentCommands
|
||||
/// <summary>Регистрирует (или перерегистрирует) команды контента на общей консоли.</summary>
|
||||
public static void Register(DevConsole console, GameContent content, ModAtlases atlases)
|
||||
{
|
||||
console.Register("mods", "mods — list active mods in load order", (c, _) =>
|
||||
console.Register(
|
||||
"mods",
|
||||
"mods — list active mods in load order",
|
||||
(c, _) =>
|
||||
{
|
||||
foreach (var mod in content.Mods)
|
||||
{
|
||||
@@ -65,24 +79,35 @@ internal static class ContentCommands
|
||||
}
|
||||
|
||||
c.WriteLine($"{content.Mods.Count} mod(s)");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
console.Register("lang", "lang [code] — show or switch the UI language", (c, args) =>
|
||||
console.Register(
|
||||
"lang",
|
||||
"lang [code] — show or switch the UI language",
|
||||
(c, args) =>
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
c.WriteLine(
|
||||
$"language: {content.Languages.CurrentLanguage} " +
|
||||
$"(available: {string.Join(", ", content.Languages.AvailableLanguages)})");
|
||||
$"language: {content.Languages.CurrentLanguage} "
|
||||
+ $"(available: {string.Join(", ", content.Languages.AvailableLanguages)})"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
c.WriteLine(content.Languages.SetLanguage(args[0])
|
||||
c.WriteLine(
|
||||
content.Languages.SetLanguage(args[0])
|
||||
? $"language set to {content.Languages.CurrentLanguage}"
|
||||
: $"unknown language '{args[0]}'; available: {string.Join(", ", content.Languages.AvailableLanguages)}");
|
||||
});
|
||||
: $"unknown language '{args[0]}'; available: {string.Join(", ", content.Languages.AvailableLanguages)}"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
console.Register("defs", "defs [type] — list def types or defs of one type", (c, args) =>
|
||||
console.Register(
|
||||
"defs",
|
||||
"defs [type] — list def types or defs of one type",
|
||||
(c, args) =>
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -97,7 +122,9 @@ internal static class ContentCommands
|
||||
var names = content.Defs.NamesOf(args[0]);
|
||||
if (names.Count == 0)
|
||||
{
|
||||
c.WriteLine($"no defs of type '{args[0]}'; types: {string.Join(", ", content.Defs.TypeKeys)}");
|
||||
c.WriteLine(
|
||||
$"no defs of type '{args[0]}'; types: {string.Join(", ", content.Defs.TypeKeys)}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,15 +134,26 @@ internal static class ContentCommands
|
||||
}
|
||||
|
||||
c.WriteLine($"{names.Count} def(s)");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
console.Register("atlas", "atlas [name] [filter] — inspect loaded texture atlases", (c, args) =>
|
||||
console.Register(
|
||||
"atlas",
|
||||
"atlas [name] [filter] — inspect loaded texture atlases",
|
||||
(c, args) =>
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
foreach (var (name, atlas) in atlases.Loaded.OrderBy(a => a.Key, StringComparer.Ordinal))
|
||||
foreach (
|
||||
var (name, atlas) in atlases.Loaded.OrderBy(
|
||||
a => a.Key,
|
||||
StringComparer.Ordinal
|
||||
)
|
||||
)
|
||||
{
|
||||
c.WriteLine($" {name}: {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)");
|
||||
c.WriteLine(
|
||||
$" {name}: {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)"
|
||||
);
|
||||
}
|
||||
|
||||
c.WriteLine($"{atlases.Loaded.Count} atlas(es) loaded");
|
||||
@@ -124,13 +162,17 @@ internal static class ContentCommands
|
||||
|
||||
if (!atlases.Loaded.TryGetValue(args[0], out var found))
|
||||
{
|
||||
c.WriteLine($"atlas '{args[0]}' is not loaded; loaded: {string.Join(", ", atlases.Loaded.Keys)}");
|
||||
c.WriteLine(
|
||||
$"atlas '{args[0]}' is not loaded; loaded: {string.Join(", ", atlases.Loaded.Keys)}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var filter = args.Length > 1 ? args[1] : null;
|
||||
var keys = found.Regions.Keys
|
||||
.Where(k => filter is null || k.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
var keys = found
|
||||
.Regions.Keys.Where(k =>
|
||||
filter is null || k.Contains(filter, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
foreach (var key in keys.Take(25))
|
||||
@@ -138,7 +180,10 @@ internal static class ContentCommands
|
||||
c.WriteLine($" {key}");
|
||||
}
|
||||
|
||||
c.WriteLine($"{keys.Count} region(s){(keys.Count > 25 ? ", showing first 25" : "")}");
|
||||
});
|
||||
c.WriteLine(
|
||||
$"{keys.Count} region(s){(keys.Count > 25 ? ", showing first 25" : "")}"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ public sealed class TerrainScene : Scene
|
||||
private const int CellSize = 24;
|
||||
private const int Population = 14;
|
||||
|
||||
private static readonly RectF TerrainBounds = new(0f, 0f, TerrainWidth * CellSize, TerrainHeight * CellSize);
|
||||
private static readonly RectF TerrainBounds = new(
|
||||
0f,
|
||||
0f,
|
||||
TerrainWidth * CellSize,
|
||||
TerrainHeight * CellSize
|
||||
);
|
||||
|
||||
private const uint AnimalLayer = 0b01;
|
||||
private const uint ObstacleLayer = 0b10;
|
||||
@@ -42,7 +47,9 @@ public sealed class TerrainScene : Scene
|
||||
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) });
|
||||
var renderer = this.UseRenderer2D(
|
||||
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
|
||||
);
|
||||
GameLayers.EnsureRegistered(renderer);
|
||||
this.UseTilemaps();
|
||||
|
||||
@@ -76,8 +83,17 @@ public sealed class TerrainScene : Scene
|
||||
}
|
||||
|
||||
ScatterSpawner.Spawn(
|
||||
this, content, atlases, device, terrain, cell, CellSize, random,
|
||||
obstacleLayer: ObstacleLayer, collidesWith: AnimalLayer);
|
||||
this,
|
||||
content,
|
||||
atlases,
|
||||
device,
|
||||
terrain,
|
||||
cell,
|
||||
CellSize,
|
||||
random,
|
||||
obstacleLayer: ObstacleLayer,
|
||||
collidesWith: AnimalLayer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +114,17 @@ public sealed class TerrainScene : Scene
|
||||
Store.CreateEntity(
|
||||
new Transform2D(
|
||||
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
|
||||
scale: new Vector2(CellSize * animal.SizeCells / body.Width)),
|
||||
scale: new Vector2(CellSize * animal.SizeCells / body.Width)
|
||||
),
|
||||
sprite,
|
||||
new Wander(),
|
||||
collider);
|
||||
collider
|
||||
);
|
||||
}
|
||||
|
||||
var camera = Store.CreateEntity(new Camera(TerrainBounds.Center, zoom: 1.6f, bounds: TerrainBounds));
|
||||
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 };
|
||||
@@ -112,7 +132,10 @@ public sealed class TerrainScene : Scene
|
||||
|
||||
var console = this.UseDevConsole();
|
||||
ContentCommands.Register(console, content, atlases);
|
||||
console.Register("regen", "regen [seed] — regenerate the terrain", (c, args) =>
|
||||
console.Register(
|
||||
"regen",
|
||||
"regen [seed] — regenerate the terrain",
|
||||
(c, args) =>
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
@@ -122,8 +145,12 @@ public sealed class TerrainScene : Scene
|
||||
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) =>
|
||||
}
|
||||
);
|
||||
console.Register(
|
||||
"world",
|
||||
"world [seed] — switch to the world scene",
|
||||
(c, args) =>
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
@@ -132,14 +159,19 @@ public sealed class TerrainScene : Scene
|
||||
|
||||
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
|
||||
Context.Scenes.Switch(new WorldScene(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));
|
||||
UpdateSystems.Add(
|
||||
new HudSystem(Context, content.Languages, hudLabel, "hud.terrain", _seed, Population)
|
||||
);
|
||||
|
||||
Log.Info($"Terrain generated: seed {_seed}, {TerrainWidth}x{TerrainHeight} cells, {Population} animals");
|
||||
Log.Info(
|
||||
$"Terrain generated: seed {_seed}, {TerrainWidth}x{TerrainHeight} cells, {Population} animals"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ public sealed class WorldScene : Scene
|
||||
public const int CellSize = 16;
|
||||
private const int Population = 80;
|
||||
|
||||
private static readonly RectF WorldBounds = new(0f, 0f, WorldWidth * CellSize, WorldHeight * CellSize);
|
||||
private static readonly RectF WorldBounds = new(
|
||||
0f,
|
||||
0f,
|
||||
WorldWidth * CellSize,
|
||||
WorldHeight * CellSize
|
||||
);
|
||||
|
||||
private readonly int _seed;
|
||||
|
||||
@@ -37,7 +42,9 @@ public sealed class WorldScene : Scene
|
||||
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) });
|
||||
var renderer = this.UseRenderer2D(
|
||||
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
|
||||
);
|
||||
GameLayers.EnsureRegistered(renderer);
|
||||
|
||||
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
|
||||
@@ -54,7 +61,8 @@ public sealed class WorldScene : Scene
|
||||
var terrain = content.Terrains.Classify(heights[x, y]);
|
||||
Store.CreateEntity(
|
||||
Transform2D.At(new Vector2(x * CellSize, y * CellSize)),
|
||||
new Sprite(white) { Color = terrain.Tint });
|
||||
new Sprite(white) { Color = terrain.Tint }
|
||||
);
|
||||
|
||||
var cell = new Point(x, y);
|
||||
if (terrain.IsLand)
|
||||
@@ -62,7 +70,16 @@ public sealed class WorldScene : Scene
|
||||
landCells.Add(cell);
|
||||
}
|
||||
|
||||
ScatterSpawner.Spawn(this, content, atlases, device, terrain, cell, CellSize, random);
|
||||
ScatterSpawner.Spawn(
|
||||
this,
|
||||
content,
|
||||
atlases,
|
||||
device,
|
||||
terrain,
|
||||
cell,
|
||||
CellSize,
|
||||
random
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +95,19 @@ public sealed class WorldScene : Scene
|
||||
Store.CreateEntity(
|
||||
new Transform2D(
|
||||
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
|
||||
scale: new Vector2(CellSize * being.SizeCells / body.Width)),
|
||||
scale: new Vector2(CellSize * being.SizeCells / body.Width)
|
||||
),
|
||||
sprite,
|
||||
new Wander());
|
||||
new Wander(),
|
||||
// Начальная энергия и фаза решения разбросаны сидом — жители не отдыхают синхронно.
|
||||
new PawnNeeds { Energy = 0.5f + random.NextSingle() * 0.5f },
|
||||
new PawnBrain { Action = PawnAction.Wander, DecideIn = random.NextSingle() * 0.75f }
|
||||
);
|
||||
}
|
||||
|
||||
var camera = Store.CreateEntity(new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds));
|
||||
var camera = Store.CreateEntity(
|
||||
new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds)
|
||||
);
|
||||
|
||||
// HUD и консоль.
|
||||
var desktop = this.UseUI();
|
||||
@@ -92,7 +116,10 @@ public sealed class WorldScene : Scene
|
||||
|
||||
var console = this.UseDevConsole();
|
||||
ContentCommands.Register(console, content, atlases);
|
||||
console.Register("regen", "regen [seed] — regenerate the world", (c, args) =>
|
||||
console.Register(
|
||||
"regen",
|
||||
"regen [seed] — regenerate the world",
|
||||
(c, args) =>
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
@@ -102,9 +129,13 @@ public sealed class WorldScene : Scene
|
||||
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));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
console.Register("terrain", "terrain [seed] — switch to the tile terrain scene", (c, args) =>
|
||||
console.Register(
|
||||
"terrain",
|
||||
"terrain [seed] — switch to the tile terrain scene",
|
||||
(c, args) =>
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
@@ -114,12 +145,54 @@ public sealed class WorldScene : Scene
|
||||
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
|
||||
c.WriteLine($"switching to terrain scene, seed {seed}");
|
||||
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
var decisions = new PawnDecisionSystem();
|
||||
UpdateSystems.Add(decisions);
|
||||
UpdateSystems.Add(new WanderSystem(_seed, WorldBounds));
|
||||
UpdateSystems.Add(new PawnNeedsSystem());
|
||||
UpdateSystems.Add(new PawnAppearanceSystem());
|
||||
UpdateSystems.Add(new GodCameraSystem(camera, input, console));
|
||||
UpdateSystems.Add(new HudSystem(Context, content.Languages, hudLabel, "hud.world", _seed, Population));
|
||||
UpdateSystems.Add(
|
||||
new HudSystem(Context, content.Languages, hudLabel, "hud.world", _seed, Population)
|
||||
);
|
||||
|
||||
Log.Info($"World generated: seed {_seed}, {WorldWidth}x{WorldHeight} cells, population {Population}");
|
||||
console.Register(
|
||||
"ai",
|
||||
"ai [energy] — utility scores at the given energy (0..1), plus the rest/wander split",
|
||||
(c, args) =>
|
||||
{
|
||||
var energy =
|
||||
args.Length > 0 && float.TryParse(args[0], out var e)
|
||||
? Math.Clamp(e, 0f, 1f)
|
||||
: 0.3f;
|
||||
c.WriteLine($"utility scores at energy {energy:0.00}:");
|
||||
foreach (var (name, score) in decisions.Inspect(energy))
|
||||
{
|
||||
c.WriteLine($" {name, -8} {score:0.000}");
|
||||
}
|
||||
|
||||
var resting = 0;
|
||||
var total = 0;
|
||||
Store
|
||||
.Query<PawnBrain>()
|
||||
.ForEachEntity(
|
||||
(ref PawnBrain brain, Entity _) =>
|
||||
{
|
||||
total++;
|
||||
if (brain.Action == PawnAction.Rest)
|
||||
{
|
||||
resting++;
|
||||
}
|
||||
}
|
||||
);
|
||||
c.WriteLine($"population: {resting} resting / {total - resting} wandering");
|
||||
}
|
||||
);
|
||||
|
||||
Log.Info(
|
||||
$"World generated: seed {_seed}, {WorldWidth}x{WorldHeight} cells, population {Population}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.AI;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace LittleSim.Sim;
|
||||
|
||||
/// <summary>Что житель делает прямо сейчас — результат utility-выбора.</summary>
|
||||
public enum PawnAction
|
||||
{
|
||||
/// <summary>Бродит по миру, тратя энергию.</summary>
|
||||
Wander,
|
||||
|
||||
/// <summary>Стоит и восстанавливает энергию.</summary>
|
||||
Rest,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Потребности жителя. Пока одна — энергия (0 — без сил, 1 — полон сил); со временем
|
||||
/// расходуется при блуждании и восстанавливается на отдыхе. Голод/жажда добавятся так же.
|
||||
/// </summary>
|
||||
public struct PawnNeeds : IComponent
|
||||
{
|
||||
/// <summary>Запас сил в диапазоне [0, 1].</summary>
|
||||
public float Energy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Мозг» жителя: выбранное действие и таймер до следующего пересмотра решения.
|
||||
/// Решение принимает <see cref="PawnDecisionSystem"/> через движковый <see cref="UtilityAi{TContext}"/>.
|
||||
/// </summary>
|
||||
public struct PawnBrain : IComponent
|
||||
{
|
||||
/// <summary>Текущее действие.</summary>
|
||||
public PawnAction Action;
|
||||
|
||||
/// <summary>Секунды до следующего пересмотра решения — не пересчитываем выбор каждый кадр.</summary>
|
||||
public float DecideIn;
|
||||
}
|
||||
|
||||
/// <summary>Снимок состояния жителя, который читают соображения utility-выбора.</summary>
|
||||
public readonly record struct PawnContext(float Energy);
|
||||
|
||||
/// <summary>
|
||||
/// Utility-выбор действия жителя на движковом <see cref="UtilityAi{TContext}"/>. Reasoner строится
|
||||
/// один раз; каждый житель пересматривает решение раз в <see cref="ReevaluateInterval"/> секунд, а
|
||||
/// не каждый кадр (энергия меняется медленно — частить незачем, и решение не дёргается на границе).
|
||||
/// Выбор детерминирован: при равенстве очков побеждает первое действие.
|
||||
/// </summary>
|
||||
public sealed class PawnDecisionSystem : QuerySystem<PawnNeeds, PawnBrain>
|
||||
{
|
||||
private const float ReevaluateInterval = 0.75f;
|
||||
|
||||
private readonly UtilityAi<PawnContext> _brain = new(
|
||||
// Отдых тянет к себе только когда сил мало: (1 − energy)^3 быстро спадает к нулю выше ~0.3.
|
||||
new UtilityAction<PawnContext>(
|
||||
"rest",
|
||||
new Consideration<PawnContext>(
|
||||
"tired",
|
||||
c => c.Energy,
|
||||
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
|
||||
)
|
||||
),
|
||||
// Блуждание — поведение по умолчанию: тем привлекательнее, чем больше сил.
|
||||
new UtilityAction<PawnContext>(
|
||||
"wander",
|
||||
new Consideration<PawnContext>("rested", c => c.Energy)
|
||||
)
|
||||
);
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var delta = Tick.deltaTime;
|
||||
foreach (var (needs, brains, _) in Query.Chunks)
|
||||
{
|
||||
var n = needs.Span;
|
||||
var b = brains.Span;
|
||||
for (var i = 0; i < n.Length; i++)
|
||||
{
|
||||
ref var brain = ref b[i];
|
||||
brain.DecideIn -= delta;
|
||||
if (brain.DecideIn > 0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
brain.DecideIn = ReevaluateInterval;
|
||||
var choice = _brain.Select(new PawnContext(n[i].Energy));
|
||||
brain.Action = choice?.Name == "rest" ? PawnAction.Rest : PawnAction.Wander;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Очки последнего вычисленного выбора, по порядку действий — для дебаг-команды.</summary>
|
||||
public (string Name, float Score)[] Inspect(float energy)
|
||||
{
|
||||
_brain.Select(new PawnContext(energy));
|
||||
var scores = _brain.LastScores;
|
||||
var result = new (string, float)[_brain.Actions.Count];
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
{
|
||||
result[i] = (_brain.Actions[i].Name, scores[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Презентация состояния ИИ: уставший житель темнеет (альфа падает с энергией). Система читает
|
||||
/// симуляцию и пишет только в <see cref="Sprite"/> — никаких draw-вызовов, граница sim/presentation цела.
|
||||
/// </summary>
|
||||
public sealed class PawnAppearanceSystem : QuerySystem<PawnNeeds, Sprite>
|
||||
{
|
||||
private const float MinBrightness = 0.45f;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
foreach (var (needs, sprites, _) in Query.Chunks)
|
||||
{
|
||||
var n = needs.Span;
|
||||
var s = sprites.Span;
|
||||
for (var i = 0; i < n.Length; i++)
|
||||
{
|
||||
var brightness =
|
||||
MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Energy, 0f, 1f);
|
||||
s[i].Color = Color.White * brightness;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Расход и восстановление энергии по текущему действию: блуждающий устаёт, отдыхающий
|
||||
/// восстанавливается. Скорости подобраны так, что отдых короткий, а блуждание долгое.
|
||||
/// </summary>
|
||||
public sealed class PawnNeedsSystem : QuerySystem<PawnNeeds, PawnBrain>
|
||||
{
|
||||
private const float DrainPerSecond = 0.04f;
|
||||
private const float RegenPerSecond = 0.2f;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var delta = Tick.deltaTime;
|
||||
foreach (var (needs, brains, _) in Query.Chunks)
|
||||
{
|
||||
var n = needs.Span;
|
||||
var b = brains.Span;
|
||||
for (var i = 0; i < n.Length; i++)
|
||||
{
|
||||
ref var energy = ref n[i].Energy;
|
||||
energy +=
|
||||
b[i].Action == PawnAction.Rest
|
||||
? RegenPerSecond * delta
|
||||
: -DrainPerSecond * delta;
|
||||
energy = Math.Clamp(energy, 0f, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,11 @@ public struct Wander : IComponent
|
||||
|
||||
/// <summary>
|
||||
/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде
|
||||
/// и порядке обновления симуляция воспроизводима.
|
||||
/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см.
|
||||
/// <see cref="PawnDecisionSystem"/>) стоят на месте — таймер смены направления тоже замирает.
|
||||
/// </summary>
|
||||
public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem<Transform2D, Wander>
|
||||
public sealed class WanderSystem(int seed, RectF worldBounds)
|
||||
: QuerySystem<Transform2D, Wander, PawnBrain>
|
||||
{
|
||||
private const float Speed = 22f;
|
||||
|
||||
@@ -27,12 +29,18 @@ public sealed class WanderSystem(int seed, RectF worldBounds) : QuerySystem<Tran
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var delta = Tick.deltaTime;
|
||||
foreach (var (transforms, wanders, _) in Query.Chunks)
|
||||
foreach (var (transforms, wanders, brains, _) in Query.Chunks)
|
||||
{
|
||||
var t = transforms.Span;
|
||||
var w = wanders.Span;
|
||||
var b = brains.Span;
|
||||
for (var i = 0; i < t.Length; i++)
|
||||
{
|
||||
if (b[i].Action == PawnAction.Rest)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ref var wander = ref w[i];
|
||||
wander.ChangeIn -= delta;
|
||||
if (wander.ChangeIn <= 0f)
|
||||
@@ -65,8 +73,9 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world)
|
||||
{
|
||||
ref var ta = ref pair.A.GetComponent<Transform2D>();
|
||||
ref var tb = ref pair.B.GetComponent<Transform2D>();
|
||||
var sum = pair.A.GetComponent<MrGameEng.Collisions.Collider>().Radius +
|
||||
pair.B.GetComponent<MrGameEng.Collisions.Collider>().Radius;
|
||||
var sum =
|
||||
pair.A.GetComponent<MrGameEng.Collisions.Collider>().Radius
|
||||
+ pair.B.GetComponent<MrGameEng.Collisions.Collider>().Radius;
|
||||
|
||||
var delta = tb.Position - ta.Position;
|
||||
var distance = delta.Length();
|
||||
@@ -103,7 +112,10 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world)
|
||||
|
||||
/// <summary>Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль.</summary>
|
||||
public sealed class GodCameraSystem(
|
||||
Entity cameraEntity, InputManager input, MrGameEng.DevConsole.DevConsole console) : BaseSystem
|
||||
Entity cameraEntity,
|
||||
InputManager input,
|
||||
MrGameEng.DevConsole.DevConsole console
|
||||
) : BaseSystem
|
||||
{
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
@@ -116,7 +128,8 @@ public sealed class GodCameraSystem(
|
||||
|
||||
var pan = new Vector2(
|
||||
Axis(input, Keys.A, Keys.Left, Keys.D, Keys.Right),
|
||||
Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down));
|
||||
Axis(input, Keys.W, Keys.Up, Keys.S, Keys.Down)
|
||||
);
|
||||
if (pan != Vector2.Zero)
|
||||
{
|
||||
pan.Normalize();
|
||||
@@ -126,9 +139,15 @@ public sealed class GodCameraSystem(
|
||||
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);
|
||||
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>
|
||||
@@ -141,7 +160,8 @@ public sealed class HudSystem(
|
||||
Myra.Graphics2D.UI.Label label,
|
||||
string titleKey,
|
||||
int seed,
|
||||
int population) : BaseSystem
|
||||
int population
|
||||
) : BaseSystem
|
||||
{
|
||||
private float _accumulated;
|
||||
private int _frames;
|
||||
@@ -158,6 +178,9 @@ public sealed class HudSystem(
|
||||
var fps = (int)MathF.Round(_frames / _accumulated);
|
||||
_accumulated = 0f;
|
||||
_frames = 0;
|
||||
label.Text = languages.Format(titleKey, seed, population, fps) + "\n" + languages.Get("hud.controls");
|
||||
label.Text =
|
||||
languages.Format(titleKey, seed, population, fps)
|
||||
+ "\n"
|
||||
+ languages.Get("hud.controls");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user