Add MrGameEng.Pathfinding and MrGameEng.Collisions modules
CI / build-test (push) Failing after 1m5s
CI / build-test (push) Failing after 1m5s
Pathfinding (depends on Core only): GridPathfinder with A* (octile/
Manhattan heuristic), Dijkstra and BFS over a game-implemented
IPathGrid; 4/8 connectivity, diagonals never cut corners. FlowField +
FlowFieldBuilder (multi-source Dijkstra) give crowds O(1) steering per
agent per frame. All buffers are grid-sized once and invalidated by a
generation stamp - repeated queries allocate nothing and clear nothing.
Collisions (Graphics exception: Transform2D, RectF): Collider component
(circle/AABB, offset, two-way layer masks), CollisionWorld - a uniform
spatial hash on flat arrays rebuilt from scratch each tick (O(n) for
movers, zero alloc after warm-up, deterministic pair order), pair
collection, QueryAabb and closest-hit Raycast. scene.UseCollisions()
registers CollisionSystem after movement systems.
30 new tests (string-map mazes, cost weighting, corner cutting, flow
descent; pair/mask/query/raycast). Sample gains a PathfindingScene
('path' console command): click to set the goal, 250 agents follow the
flow field, the A* path is highlighted, colliding agents flash red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b3415120c3
commit
a395e58458
@@ -0,0 +1,284 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Assets;
|
||||
using MrGameEng.Collisions;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.DevConsole;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.Pathfinding;
|
||||
using MrGameEng.UI;
|
||||
|
||||
namespace MrGameEng.Sample.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Демо поиска пути и коллизий: ЛКМ ставит цель — толпа агентов стекается к ней по
|
||||
/// flow field, жёлтым подсвечен A*-путь из левого верхнего угла, столкнувшиеся агенты
|
||||
/// вспыхивают красным. Tab/'main' — назад.
|
||||
/// </summary>
|
||||
public sealed class PathfindingScene : Scene
|
||||
{
|
||||
private const int CellsX = 64;
|
||||
private const int CellsY = 36;
|
||||
private const int CellSize = 20;
|
||||
private const int AgentCount = 250;
|
||||
private static readonly Point AStarStart = new(1, 1);
|
||||
|
||||
private readonly WallGrid _grid = new(CellsX, CellsY, seed: 99);
|
||||
private readonly FlowField _field = new();
|
||||
private readonly List<Point> _path = [];
|
||||
private readonly List<Entity> _pathMarkers = [];
|
||||
private FlowFieldBuilder _builder = null!;
|
||||
private GridPathfinder _pathfinder = null!;
|
||||
private Texture2DRegion _white = null!;
|
||||
private Entity _goalMarker;
|
||||
|
||||
/// <summary>Состояние агента: время красной вспышки после столкновения.</summary>
|
||||
private struct AgentState : IComponent
|
||||
{
|
||||
public float Flash;
|
||||
}
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
|
||||
var input = this.UseInput();
|
||||
var actions = SampleInput.CreateActions(input);
|
||||
var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) });
|
||||
SampleLayers.EnsureRegistered(renderer);
|
||||
|
||||
var shapesTexture = assets.Load(GameAssets.Textures.Shapes);
|
||||
_white = new Texture2DRegion(shapesTexture, new Rectangle(40, 41, 14, 14)); // жёлтый квадрат как "белый" регион
|
||||
_builder = new FlowFieldBuilder(_grid);
|
||||
_pathfinder = new GridPathfinder(_grid);
|
||||
|
||||
// Стены.
|
||||
for (var y = 0; y < CellsY; y++)
|
||||
{
|
||||
for (var x = 0; x < CellsX; x++)
|
||||
{
|
||||
if (!_grid.IsPassable(x, y))
|
||||
{
|
||||
var sprite = new Sprite(_white) { Color = new Color(70, 74, 84), Depth = 0f };
|
||||
Store.CreateEntity(CellTransform(x, y), sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Агенты на проходимых клетках.
|
||||
var random = new Random(7);
|
||||
for (var i = 0; i < AgentCount; i++)
|
||||
{
|
||||
var cell = RandomPassableCell(random);
|
||||
var sprite = new Sprite(_white) { Color = new Color(120, 180, 255), Depth = 1f };
|
||||
sprite.CenterOrigin();
|
||||
Store.CreateEntity(
|
||||
new Transform2D(CellCenter(cell), scale: new Vector2(0.45f)),
|
||||
sprite,
|
||||
new AgentState(),
|
||||
Collider.Circle(4f));
|
||||
}
|
||||
|
||||
// Маркер цели.
|
||||
var goalSprite = new Sprite(_white) { Color = new Color(80, 220, 120), Depth = 0.6f };
|
||||
_goalMarker = Store.CreateEntity(CellTransform(0, 0), goalSprite);
|
||||
|
||||
var world = this.UseCollisions(cellSize: 16f);
|
||||
|
||||
UpdateSystems.Add(new ClickTargetSystem(this, input, renderer));
|
||||
UpdateSystems.Add(new AgentSteerSystem(this));
|
||||
UpdateSystems.Add(new CollisionFlashSystem(world));
|
||||
UpdateSystems.Add(new FlashDecaySystem());
|
||||
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Fade(0.6f)));
|
||||
|
||||
var desktop = this.UseUI();
|
||||
var label = new Myra.Graphics2D.UI.Label
|
||||
{
|
||||
Text = "ЛКМ — цель: толпа идёт по flow field, жёлтое — путь A*.\nСтолкновения агентов подсвечиваются красным. Tab — назад.",
|
||||
};
|
||||
desktop.Root = new Myra.Graphics2D.UI.VerticalStackPanel { Left = 12, Top = 12 };
|
||||
((Myra.Graphics2D.UI.VerticalStackPanel)desktop.Root).Widgets.Add(label);
|
||||
|
||||
this.UseDevConsole();
|
||||
|
||||
SetGoal(new Point(CellsX / 2, CellsY / 2));
|
||||
}
|
||||
|
||||
private void SetGoal(Point goal)
|
||||
{
|
||||
if (!_grid.IsPassable(goal.X, goal.Y))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_builder.Build([goal], _field);
|
||||
_goalMarker.GetComponent<Transform2D>().Position = CellTopLeft(goal);
|
||||
|
||||
foreach (var marker in _pathMarkers)
|
||||
{
|
||||
marker.DeleteEntity();
|
||||
}
|
||||
|
||||
_pathMarkers.Clear();
|
||||
if (_pathfinder.FindPath(AStarStart, goal, _path))
|
||||
{
|
||||
foreach (var cell in _path)
|
||||
{
|
||||
var sprite = new Sprite(_white) { Color = new Color(240, 210, 60) * 0.55f, Depth = 0.5f };
|
||||
_pathMarkers.Add(Store.CreateEntity(CellTransform(cell.X, cell.Y), sprite));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Transform2D CellTransform(int x, int y) =>
|
||||
new(CellTopLeft(new Point(x, y)), scale: new Vector2(CellSize / 14f));
|
||||
|
||||
private static Vector2 CellTopLeft(Point cell) => new(cell.X * CellSize, cell.Y * CellSize);
|
||||
|
||||
private static Vector2 CellCenter(Point cell) =>
|
||||
new(cell.X * CellSize + CellSize / 2f, cell.Y * CellSize + CellSize / 2f);
|
||||
|
||||
private Point RandomPassableCell(Random random)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var cell = new Point(random.Next(CellsX), random.Next(CellsY));
|
||||
if (_grid.IsPassable(cell.X, cell.Y))
|
||||
{
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Случайные прямоугольные стены; старт A* и центр всегда свободны.</summary>
|
||||
private sealed class WallGrid : IPathGrid
|
||||
{
|
||||
private readonly bool[,] _walls;
|
||||
|
||||
public WallGrid(int width, int height, int seed)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
_walls = new bool[width, height];
|
||||
var random = new Random(seed);
|
||||
for (var i = 0; i < 70; i++)
|
||||
{
|
||||
var w = random.Next(1, 7);
|
||||
var h = random.Next(1, 7);
|
||||
var x0 = random.Next(width - w);
|
||||
var y0 = random.Next(height - h);
|
||||
for (var y = y0; y < y0 + h; y++)
|
||||
{
|
||||
for (var x = x0; x < x0 + w; x++)
|
||||
{
|
||||
_walls[x, y] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var y = 0; y < 3; y++)
|
||||
{
|
||||
for (var x = 0; x < 3; x++)
|
||||
{
|
||||
_walls[AStarStart.X + x, AStarStart.Y + y] = false;
|
||||
_walls[width / 2 + x - 1, height / 2 + y - 1] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Width { get; }
|
||||
|
||||
public int Height { get; }
|
||||
|
||||
public bool IsPassable(int x, int y) => !_walls[x, y];
|
||||
|
||||
public float Cost(int x, int y) => 1f;
|
||||
}
|
||||
|
||||
private sealed class ClickTargetSystem(PathfindingScene scene, InputManager input, Renderer2D renderer) : BaseSystem
|
||||
{
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
if (!input.IsMousePressed(MouseButton.Left))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var world = renderer.ScreenToWorld(input.MousePosition.ToVector2());
|
||||
var cell = new Point((int)(world.X / CellSize), (int)(world.Y / CellSize));
|
||||
if (cell.X >= 0 && cell.X < CellsX && cell.Y >= 0 && cell.Y < CellsY)
|
||||
{
|
||||
scene.SetGoal(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AgentSteerSystem(PathfindingScene scene) : QuerySystem<Transform2D, AgentState>
|
||||
{
|
||||
private const float Speed = 70f;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var delta = Tick.deltaTime;
|
||||
var field = scene._field;
|
||||
foreach (var (transforms, _, _) in Query.Chunks)
|
||||
{
|
||||
var t = transforms.Span;
|
||||
for (var i = 0; i < t.Length; i++)
|
||||
{
|
||||
ref var position = ref t[i].Position;
|
||||
var cx = Math.Clamp((int)(position.X / CellSize), 0, CellsX - 1);
|
||||
var cy = Math.Clamp((int)(position.Y / CellSize), 0, CellsY - 1);
|
||||
if (!field.IsReachable(cx, cy) || field.DistanceAt(cx, cy) <= 0.6f)
|
||||
{
|
||||
continue; // недостижимо или уже у цели
|
||||
}
|
||||
|
||||
position += field.DirectionAt(cx, cy) * (Speed * delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CollisionFlashSystem(CollisionWorld world) : BaseSystem
|
||||
{
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
foreach (var pair in world.Pairs)
|
||||
{
|
||||
Flash(pair.A);
|
||||
Flash(pair.B);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Flash(Entity entity)
|
||||
{
|
||||
if (entity.HasComponent<AgentState>())
|
||||
{
|
||||
entity.GetComponent<AgentState>().Flash = 0.25f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FlashDecaySystem : QuerySystem<AgentState, Sprite>
|
||||
{
|
||||
private static readonly Color Calm = new(120, 180, 255);
|
||||
private static readonly Color Hit = new(235, 70, 60);
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var delta = Tick.deltaTime;
|
||||
foreach (var (states, sprites, _) in Query.Chunks)
|
||||
{
|
||||
var a = states.Span;
|
||||
var s = sprites.Span;
|
||||
for (var i = 0; i < a.Length; i++)
|
||||
{
|
||||
a[i].Flash = Math.Max(0f, a[i].Flash - delta);
|
||||
s[i].Color = a[i].Flash > 0f ? Hit : Calm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user