diff --git a/scenes/CampusController.cs b/scenes/CampusController.cs index 704fefe..9e7b5f1 100644 --- a/scenes/CampusController.cs +++ b/scenes/CampusController.cs @@ -20,11 +20,17 @@ public partial class CampusController : Node2D [Export] public int AssignedTaskChancePercent { get; set; } = 60; [Export] public float RoundDurationSeconds { get; set; } = 30.0f; [Export] public float AgentMoveSpeed { get; set; } = 90.0f; + [Export] public float GridCellSize { get; set; } = 8.0f; + [Export] public float GridWalkableTolerance { get; set; } = 2.0f; + [Export] public bool DebugGridEnabled { get; set; } + [Export] public Key DebugGridToggleKey { get; set; } = Key.G; + [Export] public bool DebugLogGrid { get; set; } private NavigationRegion2D _navigationRegion; private Node2D _studentsRoot; private TopBar _topBar; private RichTextLabel _logLabel; + private DebugGridOverlay _debugGridOverlay; private readonly List _coveragePoints = new(); private readonly List _behaviorAgents = new(); private readonly CampusBehaviorWorld _behaviorWorld = new(); @@ -44,10 +50,16 @@ public partial class CampusController : Node2D private bool _navBakeReady = false; private Rid _navBakeMap = new(); private uint _navBakeIterationId = 0; + private AStarGrid2D _astarGrid; + private Rect2I _astarRegion; + private int _astarMapIteration; + private Rid _astarMap; + private const int GridSearchRadius = 6; // Called when the node enters the scene tree for the first time. public override void _Ready() { + SetProcessUnhandledInput(true); _taskContainer = GetNode("Task"); _logContainer = GetNode("Log"); @@ -84,6 +96,7 @@ public partial class CampusController : Node2D _studentsRoot = new Node2D { Name = "Students" }; AddChild(_studentsRoot); } + EnsureDebugGridOverlay(); // 使用可视化轮廓重新烘焙导航多边形,确保洞被正确识别 RebakeNavigationPolygonFromOutlines(); @@ -98,6 +111,26 @@ public partial class CampusController : Node2D TrySpawnStudents(); UpdateRoundTimer((float)delta); UpdateBehaviorAgents((float)delta); + if (DebugGridEnabled) + { + RequestDebugGridRedraw(); + } + } + + public override void _UnhandledInput(InputEvent @event) + { + if (@event is not InputEventKey keyEvent) + { + return; + } + + if (!keyEvent.Pressed || keyEvent.Echo || keyEvent.Keycode != DebugGridToggleKey) + { + return; + } + + DebugGridEnabled = !DebugGridEnabled; + RequestDebugGridRedraw(); } private void InitializeBehaviorAssets() @@ -359,6 +392,7 @@ public partial class CampusController : Node2D _studentsRoot.AddChild(student); student.SetNavigationMap(map); student.MoveSpeed = AgentMoveSpeed; + student.GridWalkableTolerance = GridWalkableTolerance; // 随机放置在可行走区域,并交给行为系统控制 var randomIndex = _random != null @@ -590,6 +624,11 @@ public partial class CampusController : Node2D private Rid GetNavigationMap() { + if (_navigationRegion == null) + { + return new Rid(); + } + var map = NavigationServer2D.RegionGetMap(_navigationRegion.GetRid()); if (!map.IsValid) { @@ -602,6 +641,292 @@ public partial class CampusController : Node2D return map; } + public List RequestGridPath(Vector2 start, Vector2 target) + { + if (!EnsureAStarGrid()) + { + return null; + } + + var startCell = WorldToGrid(start); + var targetCell = WorldToGrid(target); + if (!IsCellInBounds(startCell) || !IsCellInBounds(targetCell)) + { + if (DebugLogGrid) + { + GD.Print($"[AStarGrid] out_of_bounds start={startCell} target={targetCell} region={_astarRegion}"); + } + return null; + } + + startCell = FindNearestOpenCell(startCell, GridSearchRadius); + targetCell = FindNearestOpenCell(targetCell, GridSearchRadius); + if (!IsCellInBounds(startCell) || !IsCellInBounds(targetCell)) + { + if (DebugLogGrid) + { + GD.Print($"[AStarGrid] no_open_cell start={startCell} target={targetCell}"); + } + return null; + } + + if (_astarGrid.IsPointSolid(startCell) || _astarGrid.IsPointSolid(targetCell)) + { + if (DebugLogGrid) + { + GD.Print($"[AStarGrid] solid start={startCell} target={targetCell}"); + } + return null; + } + + var path = _astarGrid.GetIdPath(startCell, targetCell); + if (path.Count == 0) + { + if (DebugLogGrid) + { + GD.Print($"[AStarGrid] empty_path start={startCell} target={targetCell}"); + } + return null; + } + + var result = new List(path.Count); + foreach (Vector2I cell in path) + { + result.Add(GridToWorld(cell)); + } + + if (result.Count > 0) + { + result.RemoveAt(0); + } + + return result; + } + + private bool EnsureAStarGrid() + { + if (!IsNavigationMapReady(out var map)) + { + return false; + } + + var region = BuildGridRegion(); + if (region.Size.X <= 0 || region.Size.Y <= 0) + { + return false; + } + + var cellSize = new Vector2(Mathf.Max(1.0f, GridCellSize), Mathf.Max(1.0f, GridCellSize)); + var mapIteration = (int)NavigationServer2D.MapGetIterationId(map); + var needsRebuild = _astarGrid == null + || _astarMapIteration != mapIteration + || _astarMap != map + || _astarRegion.Position != region.Position + || _astarRegion.Size != region.Size + || _astarGrid.CellSize != cellSize; + + if (!needsRebuild) + { + return _astarGrid != null; + } + + _astarGrid ??= new AStarGrid2D(); + _astarGrid.Region = region; + _astarGrid.CellSize = cellSize; + _astarGrid.Offset = Vector2.Zero; + _astarGrid.DiagonalMode = AStarGrid2D.DiagonalModeEnum.Never; + _astarGrid.Update(); + + for (var x = region.Position.X; x < region.Position.X + region.Size.X; x++) + { + for (var y = region.Position.Y; y < region.Position.Y + region.Size.Y; y++) + { + var cell = new Vector2I(x, y); + var center = GridToWorld(cell); + var walkable = IsCellWalkable(center, map); + _astarGrid.SetPointSolid(cell, !walkable); + } + } + + _astarRegion = region; + _astarMapIteration = mapIteration; + _astarMap = map; + if (DebugLogGrid) + { + GD.Print($"[AStarGrid] region={region} cell={cellSize}"); + } + + return true; + } + + private bool IsNavigationMapReady(out Rid map) + { + map = GetNavigationMap(); + return map.IsValid && NavigationServer2D.MapGetIterationId(map) > 0; + } + + private Rect2I BuildGridRegion() + { + var bounds = BuildWorldBounds(); + var cell = Mathf.Max(1.0f, GridCellSize); + var minX = Mathf.FloorToInt(bounds.Position.X / cell); + var minY = Mathf.FloorToInt(bounds.Position.Y / cell); + var maxX = Mathf.CeilToInt((bounds.Position.X + bounds.Size.X) / cell); + var maxY = Mathf.CeilToInt((bounds.Position.Y + bounds.Size.Y) / cell); + var sizeX = Math.Max(1, maxX - minX); + var sizeY = Math.Max(1, maxY - minY); + return new Rect2I(new Vector2I(minX, minY), new Vector2I(sizeX, sizeY)); + } + + private Rect2 BuildWorldBounds() + { + var viewport = GetViewportRect(); + var bounds = new Rect2(viewport.Position, viewport.Size); + + if (_navigationRegion == null || _navigationRegion.NavigationPolygon == null) + { + return bounds; + } + + var polygon = _navigationRegion.NavigationPolygon; + var outlineCount = polygon.GetOutlineCount(); + if (outlineCount == 0) + { + return bounds; + } + + var min = Vector2.Zero; + var max = Vector2.Zero; + var hasPoint = false; + + for (var i = 0; i < outlineCount; i++) + { + var outline = polygon.GetOutline(i); + for (var j = 0; j < outline.Length; j++) + { + var world = _navigationRegion.ToGlobal(outline[j]); + if (!hasPoint) + { + min = world; + max = world; + hasPoint = true; + continue; + } + + min = new Vector2(Mathf.Min(min.X, world.X), Mathf.Min(min.Y, world.Y)); + max = new Vector2(Mathf.Max(max.X, world.X), Mathf.Max(max.Y, world.Y)); + } + } + + if (!hasPoint) + { + return bounds; + } + + var navBounds = new Rect2(min, max - min); + return bounds.Merge(navBounds); + } + + private bool IsCellInRegion(Vector2I cell) + { + return cell.X >= _astarRegion.Position.X + && cell.X < _astarRegion.Position.X + _astarRegion.Size.X + && cell.Y >= _astarRegion.Position.Y + && cell.Y < _astarRegion.Position.Y + _astarRegion.Size.Y; + } + + private bool IsCellInBounds(Vector2I cell) + { + if (_astarGrid != null) + { + return _astarGrid.IsInBounds(cell.X, cell.Y); + } + + return IsCellInRegion(cell); + } + + private Vector2I WorldToGrid(Vector2 world) + { + var size = Mathf.Max(1.0f, GridCellSize); + return new Vector2I( + Mathf.FloorToInt(world.X / size), + Mathf.FloorToInt(world.Y / size)); + } + + private Vector2 GridToWorld(Vector2I grid) + { + var size = Mathf.Max(1.0f, GridCellSize); + return new Vector2((grid.X + 0.5f) * size, (grid.Y + 0.5f) * size); + } + + private Vector2I FindNearestOpenCell(Vector2I origin, int radius) + { + if (_astarGrid == null) return origin; + if (IsCellInBounds(origin) && !_astarGrid.IsPointSolid(origin)) return origin; + + for (var r = 1; r <= radius; r++) + { + for (var dx = -r; dx <= r; dx++) + { + var dy = r - Math.Abs(dx); + var candidateA = new Vector2I(origin.X + dx, origin.Y + dy); + if (IsCellInBounds(candidateA) && !_astarGrid.IsPointSolid(candidateA)) + { + return candidateA; + } + + if (dy == 0) continue; + var candidateB = new Vector2I(origin.X + dx, origin.Y - dy); + if (IsCellInBounds(candidateB) && !_astarGrid.IsPointSolid(candidateB)) + { + return candidateB; + } + } + } + + return origin; + } + + private bool IsWorldWalkable(Vector2 world, Rid map) + { + if (!map.IsValid) return false; + var closest = NavigationServer2D.MapGetClosestPoint(map, world); + return closest.DistanceTo(world) <= GetGridTolerance(); + } + + private bool IsCellWalkable(Vector2 center, Rid map) + { + if (!IsWorldWalkable(center, map)) return false; + + var half = Mathf.Max(1.0f, GridCellSize * 0.45f); + var offsets = new[] + { + new Vector2(half, 0), + new Vector2(-half, 0), + new Vector2(0, half), + new Vector2(0, -half), + new Vector2(half, half), + new Vector2(half, -half), + new Vector2(-half, half), + new Vector2(-half, -half) + }; + + for (var i = 0; i < offsets.Length; i++) + { + if (!IsWorldWalkable(center + offsets[i], map)) + { + return false; + } + } + + return true; + } + + private float GetGridTolerance() + { + return Mathf.Max(1.0f, GridWalkableTolerance); + } + private static bool HasNearbyPoint(List points, Vector2 candidate, float minDistance) { for (int i = 0; i < points.Count; i++) @@ -645,9 +970,94 @@ public partial class CampusController : Node2D { // 烘焙完成后再刷新导航区域,避免用旧的多边形生成地图 _navigationRegion.NavigationPolygon = navPolygon; + _astarGrid = null; + _astarMap = new Rid(); + _astarMapIteration = 0; _navBakePending = false; _navBakeReady = true; + RequestDebugGridRedraw(); })); } + private void EnsureDebugGridOverlay() + { + if (_debugGridOverlay != null) + { + return; + } + + var sprite = GetNodeOrNull("Sprite2D"); + _debugGridOverlay = new DebugGridOverlay + { + Name = "DebugGridOverlay", + ZIndex = 1 + }; + (sprite ?? this).AddChild(_debugGridOverlay); + _debugGridOverlay.CampusOwner = this; + _debugGridOverlay.Visible = DebugGridEnabled; + } + + private void RequestDebugGridRedraw() + { + if (_debugGridOverlay == null) + { + return; + } + + _debugGridOverlay.Visible = DebugGridEnabled; + if (DebugGridEnabled) + { + _debugGridOverlay.QueueRedraw(); + } + } + + private void DrawDebugGrid(Node2D canvas) + { + if (!DebugGridEnabled || canvas == null) + { + return; + } + + if (!EnsureAStarGrid()) + { + return; + } + + var cellSize = Mathf.Max(1.0f, GridCellSize); + var half = new Vector2(cellSize * 0.5f, cellSize * 0.5f); + var solidFill = new Color(1.0f, 0.3f, 0.3f, 0.25f); + var solidOutline = new Color(1.0f, 0.3f, 0.3f, 0.6f); + var walkFill = new Color(0.2f, 0.8f, 0.3f, 0.08f); + var walkOutline = new Color(0.2f, 0.8f, 0.3f, 0.2f); + + var minX = _astarRegion.Position.X; + var maxX = _astarRegion.Position.X + _astarRegion.Size.X - 1; + var minY = _astarRegion.Position.Y; + var maxY = _astarRegion.Position.Y + _astarRegion.Size.Y - 1; + + for (var x = minX; x <= maxX; x++) + { + for (var y = minY; y <= maxY; y++) + { + var cell = new Vector2I(x, y); + var isSolid = _astarGrid.IsPointSolid(cell); + var worldCenter = new Vector2((cell.X + 0.5f) * cellSize, (cell.Y + 0.5f) * cellSize); + var localTopLeft = canvas.ToLocal(worldCenter) - half; + var rect = new Rect2(localTopLeft, new Vector2(cellSize, cellSize)); + canvas.DrawRect(rect, isSolid ? solidFill : walkFill); + canvas.DrawRect(rect, isSolid ? solidOutline : walkOutline, false, 1.0f); + } + } + } + + private sealed partial class DebugGridOverlay : Node2D + { + public CampusController CampusOwner { get; set; } + + public override void _Draw() + { + CampusOwner?.DrawDebugGrid(this); + } + } + } diff --git a/scenes/campus.tscn b/scenes/campus.tscn index 608f2f7..7960558 100644 --- a/scenes/campus.tscn +++ b/scenes/campus.tscn @@ -7,9 +7,9 @@ [ext_resource type="PackedScene" uid="uid://drmjsqoy8htc8" path="res://scenes/ui-elements/task_list.tscn" id="3_4gjr3"] [sub_resource type="NavigationPolygon" id="NavigationPolygon_8u8vn"] -vertices = PackedVector2Array(956, 540, 4, 540, 420, 516, 460, 516, 380, 516, 204, 356, 204, 260, 228, 284, 228, 468, 4, 500, 100, 500, 380, 468, 300, 468, 148, 212, 148, 196, 156, 196, 156, 212, 244, 212, 244, 20, 268, 20, 268, 236, 436, 236, 436, 196, 444, 196, 444, 236, 612, 236, 612, 20, 636, 20, 636, 244, 708, 244, 708, 196, 732, 196, 732, 172, 756, 172, 756, 196, 772, 196, 772, 20, 796, 20, 796, 228, 844, 228, 844, 196, 852, 196, 852, 228, 860, 228, 860, 284, 828, 284, 804, 284, 932, 308, 932, 180, 956, 180, 956, 332, 828, 332, 828, 308, 828, 516, 804, 516, 572, 284, 572, 300, 548, 300, 548, 284, 956, 516, 116, 364, 124, 364, 124, 396, 196, 396, 196, 356, 180, 436, 180, 412, 140, 412, 292, 468, 292, 452, 300, 452, 140, 436, 4, 284, 4, 4, 28, 4, 28, 212, 100, 284, 116, 260, 460, 284, 420, 284) -polygons = Array[PackedInt32Array]([PackedInt32Array(0, 1, 2, 3), PackedInt32Array(4, 2, 1), PackedInt32Array(5, 6, 7, 8), PackedInt32Array(4, 1, 9, 10), PackedInt32Array(11, 4, 10, 12), PackedInt32Array(13, 14, 15, 16), PackedInt32Array(17, 18, 19, 20), PackedInt32Array(21, 22, 23, 24), PackedInt32Array(25, 26, 27, 28), PackedInt32Array(29, 30, 31), PackedInt32Array(31, 32, 33, 34), PackedInt32Array(35, 36, 37, 38), PackedInt32Array(39, 40, 41, 42), PackedInt32Array(42, 43, 44, 45), PackedInt32Array(39, 42, 45), PackedInt32Array(38, 39, 45, 46), PackedInt32Array(47, 48, 49, 50), PackedInt32Array(47, 50, 51, 52), PackedInt32Array(51, 53, 54, 46), PackedInt32Array(52, 51, 46), PackedInt32Array(45, 52, 46), PackedInt32Array(38, 46, 55, 29), PackedInt32Array(55, 56, 57, 58), PackedInt32Array(53, 59, 0), PackedInt32Array(60, 61, 62, 10), PackedInt32Array(63, 64, 5, 8), PackedInt32Array(63, 8, 65, 66), PackedInt32Array(62, 63, 66, 67), PackedInt32Array(68, 69, 70, 12), PackedInt32Array(68, 12, 10), PackedInt32Array(8, 68, 10), PackedInt32Array(65, 8, 10, 71), PackedInt32Array(72, 73, 74, 75), PackedInt32Array(76, 72, 75, 77), PackedInt32Array(29, 31, 34, 38), PackedInt32Array(34, 35, 38), PackedInt32Array(28, 29, 55), PackedInt32Array(24, 25, 28, 55, 58), PackedInt32Array(24, 58, 78, 21), PackedInt32Array(20, 21, 78, 79, 6), PackedInt32Array(62, 67, 71, 10), PackedInt32Array(16, 17, 20, 6), PackedInt32Array(79, 7, 6), PackedInt32Array(60, 10, 76, 77), PackedInt32Array(77, 75, 13, 6), PackedInt32Array(6, 13, 16), PackedInt32Array(79, 78, 3, 2), PackedInt32Array(0, 3, 54), PackedInt32Array(0, 54, 53)]) -outlines = Array[PackedVector2Array]([PackedVector2Array(0, 0, 32, 0, 32, 208, 144, 208, 144, 192, 160, 192, 160, 208, 240, 208, 240, 16, 272, 16, 272, 232, 432, 232, 432, 192, 448, 192, 448, 232, 608, 232, 608, 16, 640, 16, 640, 240, 704, 240, 704, 192, 728, 192, 728, 168, 760, 168, 760, 192, 768, 192, 768, 16, 800, 16, 800, 224, 832, 224, 840, 224, 840, 192, 856, 192, 856, 224, 864, 224, 864, 288, 832, 288, 832, 304, 912, 304, 928, 304, 928, 288, 928, 176, 960, 176, 960, 336, 832, 336, 832, 512, 960, 512, 960, 544, 0, 544, 0, 496, 96, 496, 96, 288, 0, 288), PackedVector2Array(144, 432, 176, 432, 176, 416, 144, 416), PackedVector2Array(128, 392, 192, 392, 192, 352, 200, 352, 200, 264, 120, 264, 120, 360, 128, 360), PackedVector2Array(232, 304, 232, 464, 288, 464, 288, 448, 304, 448, 304, 464, 384, 464, 384, 512, 416, 512, 416, 288, 232, 288), PackedVector2Array(464, 288, 464, 512, 800, 512, 800, 288, 576, 288, 576, 304, 544, 304, 544, 288), PackedVector2Array(144, 464, 176, 464, 176, 480, 144, 480)]) +vertices = PackedVector2Array(956, 540, 4, 540, 420, 516, 460, 516, 380, 516, 204, 356, 204, 260, 228, 284, 228, 468, 4, 500, 100, 500, 380, 468, 308, 468, 132, 212, 132, 180, 172, 180, 172, 212, 244, 212, 244, 60, 268, 60, 268, 236, 428, 236, 428, 172, 452, 172, 452, 236, 612, 236, 612, 60, 636, 60, 636, 244, 708, 244, 708, 196, 732, 196, 732, 140, 756, 140, 756, 196, 772, 196, 772, 60, 796, 60, 796, 228, 836, 228, 836, 180, 860, 180, 860, 284, 828, 284, 932, 308, 932, 180, 956, 180, 956, 332, 828, 332, 828, 308, 828, 516, 804, 516, 804, 284, 572, 284, 572, 300, 548, 300, 548, 284, 956, 516, 116, 364, 124, 364, 124, 396, 196, 396, 196, 356, 180, 436, 180, 412, 140, 412, 284, 468, 284, 436, 308, 436, 140, 436, 4, 284, 4, 60, 28, 60, 28, 212, 100, 284, 116, 260, 460, 284, 420, 284) +polygons = Array[PackedInt32Array]([PackedInt32Array(0, 1, 2, 3), PackedInt32Array(4, 2, 1), PackedInt32Array(5, 6, 7, 8), PackedInt32Array(4, 1, 9, 10), PackedInt32Array(11, 4, 10, 12), PackedInt32Array(13, 14, 15, 16), PackedInt32Array(17, 18, 19, 20), PackedInt32Array(21, 22, 23, 24), PackedInt32Array(25, 26, 27, 28), PackedInt32Array(29, 30, 31), PackedInt32Array(31, 32, 33, 34), PackedInt32Array(35, 36, 37, 38), PackedInt32Array(39, 40, 41, 42), PackedInt32Array(39, 42, 43, 38), PackedInt32Array(44, 45, 46, 47), PackedInt32Array(44, 47, 48, 49), PackedInt32Array(48, 50, 51, 52), PackedInt32Array(49, 48, 52), PackedInt32Array(43, 49, 52, 38), PackedInt32Array(38, 52, 53, 29), PackedInt32Array(53, 54, 55, 56), PackedInt32Array(50, 57, 0), PackedInt32Array(58, 59, 60, 10), PackedInt32Array(61, 62, 5, 8), PackedInt32Array(61, 8, 63, 64), PackedInt32Array(60, 61, 64, 65), PackedInt32Array(66, 67, 68, 12), PackedInt32Array(66, 12, 10), PackedInt32Array(8, 66, 10), PackedInt32Array(63, 8, 10, 69), PackedInt32Array(70, 71, 72, 73), PackedInt32Array(74, 70, 73, 13, 75), PackedInt32Array(29, 31, 34, 38), PackedInt32Array(34, 35, 38), PackedInt32Array(28, 29, 53), PackedInt32Array(24, 25, 28, 53, 56), PackedInt32Array(24, 56, 76, 21), PackedInt32Array(20, 21, 76, 77, 6), PackedInt32Array(60, 65, 69, 10), PackedInt32Array(16, 17, 20, 6), PackedInt32Array(77, 7, 6), PackedInt32Array(58, 10, 74, 75), PackedInt32Array(6, 75, 13, 16), PackedInt32Array(77, 76, 3, 2), PackedInt32Array(0, 3, 51), PackedInt32Array(0, 51, 50)]) +outlines = Array[PackedVector2Array]([PackedVector2Array(0, 56, 32, 56, 32, 208, 128, 208, 128, 176, 176, 176, 176, 208, 240, 208, 240, 56, 272, 56, 272, 232, 424, 232, 424, 168, 456, 168, 456, 232, 608, 232, 608, 56, 640, 56, 640, 240, 704, 240, 704, 192, 728, 192, 728, 136, 760, 136, 760, 192, 768, 192, 768, 56, 800, 56, 800, 224, 832, 224, 832, 224, 832, 176, 864, 176, 864, 224, 864, 224, 864, 288, 832, 288, 832, 304, 912, 304, 928, 304, 928, 288, 928, 176, 960, 176, 960, 336, 832, 336, 832, 512, 960, 512, 960, 544, 0, 544, 0, 496, 96, 496, 96, 288, 0, 288), PackedVector2Array(144, 432, 176, 432, 176, 416, 144, 416), PackedVector2Array(128, 392, 192, 392, 192, 352, 200, 352, 200, 264, 120, 264, 120, 360, 128, 360), PackedVector2Array(232, 304, 232, 464, 280, 464, 280, 432, 312, 432, 312, 464, 384, 464, 384, 512, 416, 512, 416, 288, 232, 288), PackedVector2Array(464, 288, 464, 512, 800, 512, 800, 288, 576, 288, 576, 304, 544, 304, 544, 288), PackedVector2Array(144, 464, 176, 464, 176, 480, 144, 480)]) parsed_collision_mask = 4294967294 agent_radius = 4.0 diff --git a/scripts/CampusStudent.cs b/scripts/CampusStudent.cs index 0a4d557..0509220 100644 --- a/scripts/CampusStudent.cs +++ b/scripts/CampusStudent.cs @@ -135,25 +135,13 @@ public partial class CampusStudent : CharacterBody2D /// private bool _gridPathPending; /// - /// AStar网格 - /// - private AStarGrid2D _astarGrid; - /// - /// AStar区域 - /// - private Rect2I _astarRegion; - /// - /// AStar地图迭代版本 - /// - private int _astarMapIteration; - /// /// 网格重新寻路重试计时器 /// private float _gridPathRetryTimer; /// - /// 导航区域引用 + /// 校园控制器 /// - private NavigationRegion2D _navigationRegion; + private CampusController _campusController; /// /// 移动速度 @@ -188,42 +176,18 @@ public partial class CampusStudent : CharacterBody2D /// [Export] public bool UseGridPathfinding { get; set; } = true; /// - /// 网格单元大小 - /// - [Export] public float GridCellSize { get; set; } = 8.0f; - /// /// 网格可行走容差 /// [Export] public float GridWalkableTolerance { get; set; } = 2.0f; /// - /// 网格搜索节点限制 - /// - [Export] public int GridSearchNodeLimit { get; set; } = 8000; - /// /// 网格重新寻路间隔 /// [Export] public float GridRepathInterval { get; set; } = 0.25f; /// - /// 调试绘制网格 - /// - [Export] public bool DebugDrawGrid { get; set; } - /// - /// 调试仅绘制实心点 - /// - [Export] public bool DebugDrawSolidOnly { get; set; } = true; - /// /// 调试绘制路径 /// [Export] public bool DebugDrawPath { get; set; } /// - /// 调试绘制半径单元数 - /// - [Export] public int DebugDrawRadiusCells { get; set; } = 20; - /// - /// 调试日志网格 - /// - [Export] public bool DebugLogGrid { get; set; } - /// /// 环境碰撞掩码 /// [Export] public uint EnvironmentCollisionMask { get; set; } = 1u; @@ -239,7 +203,7 @@ public partial class CampusStudent : CharacterBody2D { _navigationAgent = GetNodeOrNull("NavigationAgent2D"); _animationPlayer = GetNodeOrNull("AnimationPlayer"); - _navigationRegion = FindNavigationRegion(); + _campusController = GetTree()?.CurrentScene as CampusController; CacheSprites(); ConfigureCollision(); @@ -276,14 +240,10 @@ public partial class CampusStudent : CharacterBody2D public override void _PhysicsProcess(double delta) { _lastDelta = delta; - if (DebugDrawGrid || DebugDrawPath) + if (DebugDrawPath) { QueueRedraw(); } - if ((DebugDrawGrid || DebugDrawPath) && _astarGrid == null) - { - EnsureAStarGrid(); - } if (_gridPathRetryTimer > 0.0f) { _gridPathRetryTimer = Mathf.Max(0.0f, _gridPathRetryTimer - (float)delta); @@ -396,48 +356,9 @@ public partial class CampusStudent : CharacterBody2D /// public override void _Draw() { - if (!DebugDrawGrid && !DebugDrawPath) return; - if (_astarGrid == null || GridCellSize <= 0.0f) return; + if (!DebugDrawPath) return; - var cellSize = Mathf.Max(1.0f, GridCellSize); - var half = new Vector2(cellSize * 0.5f, cellSize * 0.5f); - - if (DebugDrawGrid) - { - var radius = Mathf.Max(1, DebugDrawRadiusCells); - var originCell = WorldToGrid(GlobalPosition); - var minX = Math.Max(_astarRegion.Position.X, originCell.X - radius); - var maxX = Math.Min(_astarRegion.Position.X + _astarRegion.Size.X - 1, originCell.X + radius); - var minY = Math.Max(_astarRegion.Position.Y, originCell.Y - radius); - var maxY = Math.Min(_astarRegion.Position.Y + _astarRegion.Size.Y - 1, originCell.Y + radius); - - var solidFill = new Color(1.0f, 0.3f, 0.3f, 0.25f); - var solidOutline = new Color(1.0f, 0.3f, 0.3f, 0.6f); - var walkFill = new Color(0.2f, 0.8f, 0.3f, 0.08f); - var walkOutline = new Color(0.2f, 0.8f, 0.3f, 0.2f); - - for (var x = minX; x <= maxX; x++) - { - for (var y = minY; y <= maxY; y++) - { - var cell = new Vector2I(x, y); - var isSolid = _astarGrid.IsPointSolid(cell); - if (DebugDrawSolidOnly && !isSolid) - { - continue; - } - - var worldCenter = GridToWorld(cell); - // Draw around the cell center to verify path alignment. - var localTopLeft = ToLocal(worldCenter) - half; - var rect = new Rect2(localTopLeft, new Vector2(cellSize, cellSize)); - DrawRect(rect, isSolid ? solidFill : walkFill); - DrawRect(rect, isSolid ? solidOutline : walkOutline, false, 1.0f); - } - } - } - - if (DebugDrawPath && _gridPath.Count > 0 && _gridPathIndex < _gridPath.Count) + if (_gridPath.Count > 0 && _gridPathIndex < _gridPath.Count) { var pathColor = new Color(0.2f, 0.7f, 1.0f, 0.9f); var current = ToLocal(GlobalPosition); @@ -602,10 +523,6 @@ public partial class CampusStudent : CharacterBody2D { // 由校园控制器传入导航地图,供本地边界夹紧使用 _navigationMap = map; - _astarGrid = null; - _astarRegion = new Rect2I(); - _astarMapIteration = 0; - _navigationRegion = FindNavigationRegion(); } /// @@ -772,7 +689,7 @@ public partial class CampusStudent : CharacterBody2D _gridPathIndex = 0; _gridPathActive = true; _gridPathPending = false; - if (DebugDrawGrid || DebugDrawPath) + if (DebugDrawPath) { QueueRedraw(); } @@ -1029,71 +946,19 @@ public partial class CampusStudent : CharacterBody2D /// 路径点列表 private List BuildGridPath(Vector2 start, Vector2 target) { - if (!IsNavigationMapReady() || GridCellSize <= 0.0f) + if (_campusController == null) + { + _campusController = GetTree()?.CurrentScene as CampusController; + } + + var path = _campusController?.RequestGridPath(start, target); + if (path == null || path.Count == 0) { return null; } - EnsureAStarGrid(); - if (_astarGrid == null) - { - return null; - } - - var startCell = WorldToGrid(start); - var targetCell = WorldToGrid(target); - if (!IsCellInBounds(startCell) || !IsCellInBounds(targetCell)) - { - if (DebugLogGrid) - { - GD.Print($"[AStarGrid] out_of_bounds start={startCell} target={targetCell} region={_astarRegion}"); - } - return null; - } - - startCell = FindNearestOpenCell(startCell, 6); - targetCell = FindNearestOpenCell(targetCell, 6); - if (!IsCellInBounds(startCell) || !IsCellInBounds(targetCell)) - { - if (DebugLogGrid) - { - GD.Print($"[AStarGrid] no_open_cell start={startCell} target={targetCell}"); - } - return null; - } - - if (_astarGrid.IsPointSolid(startCell) || _astarGrid.IsPointSolid(targetCell)) - { - if (DebugLogGrid) - { - GD.Print($"[AStarGrid] solid start={startCell} target={targetCell}"); - } - return null; - } - - var path = _astarGrid.GetIdPath(startCell, targetCell); - if (path.Count == 0) - { - if (DebugLogGrid) - { - GD.Print($"[AStarGrid] empty_path start={startCell} target={targetCell}"); - } - return null; - } - - var result = new List(path.Count); - foreach (Vector2I cell in path) - { - result.Add(GridToWorld(cell)); - } - - if (result.Count > 0) - { - result.RemoveAt(0); - } - - RemoveImmediateBacktracks(result); - return SimplifyGridPath(result); + RemoveImmediateBacktracks(path); + return SimplifyGridPath(path); } /// @@ -1159,219 +1024,6 @@ public partial class CampusStudent : CharacterBody2D return simplified; } - /// - /// 确保AStar网格已建立 - /// - private void EnsureAStarGrid() - { - if (!IsNavigationMapReady()) return; - - var region = BuildGridRegion(); - if (region.Size.X <= 0 || region.Size.Y <= 0) - { - return; - } - - var cellSize = new Vector2(Mathf.Max(1.0f, GridCellSize), Mathf.Max(1.0f, GridCellSize)); - var mapIteration = (int)NavigationServer2D.MapGetIterationId(_navigationMap); - var needsRebuild = _astarGrid == null - || _astarMapIteration != mapIteration - || _astarRegion.Position != region.Position - || _astarRegion.Size != region.Size - || _astarGrid.CellSize != cellSize; - - if (!needsRebuild) - { - return; - } - - _astarGrid ??= new AStarGrid2D(); - _astarGrid.Region = region; - _astarGrid.CellSize = cellSize; - _astarGrid.Offset = Vector2.Zero; - _astarGrid.DiagonalMode = AStarGrid2D.DiagonalModeEnum.Never; - _astarGrid.Update(); - - for (var x = region.Position.X; x < region.Position.X + region.Size.X; x++) - { - for (var y = region.Position.Y; y < region.Position.Y + region.Size.Y; y++) - { - var cell = new Vector2I(x, y); - var center = GridToWorld(cell); - var walkable = IsCellWalkable(center); - _astarGrid.SetPointSolid(cell, !walkable); - } - } - - _astarRegion = region; - _astarMapIteration = mapIteration; - if (DebugLogGrid) - { - GD.Print($"[AStarGrid] region={region} cell={cellSize}"); - } - if (DebugDrawGrid || DebugDrawPath) - { - QueueRedraw(); - } - } - - /// - /// 构建网格区域 - /// - /// 网格区域 - private Rect2I BuildGridRegion() - { - var bounds = BuildWorldBounds(); - var cell = Mathf.Max(1.0f, GridCellSize); - var minX = Mathf.FloorToInt(bounds.Position.X / cell); - var minY = Mathf.FloorToInt(bounds.Position.Y / cell); - var maxX = Mathf.CeilToInt((bounds.Position.X + bounds.Size.X) / cell); - var maxY = Mathf.CeilToInt((bounds.Position.Y + bounds.Size.Y) / cell); - var sizeX = Math.Max(1, maxX - minX); - var sizeY = Math.Max(1, maxY - minY); - return new Rect2I(new Vector2I(minX, minY), new Vector2I(sizeX, sizeY)); - } - - /// - /// 构建世界边界 - /// - /// 世界边界 - private Rect2 BuildWorldBounds() - { - var viewport = GetViewportRect(); - var bounds = new Rect2(viewport.Position, viewport.Size); - - var navRegion = _navigationRegion ?? FindNavigationRegion(); - if (navRegion == null || navRegion.NavigationPolygon == null) - { - return bounds; - } - - var polygon = navRegion.NavigationPolygon; - var outlineCount = polygon.GetOutlineCount(); - if (outlineCount == 0) - { - return bounds; - } - - var min = Vector2.Zero; - var max = Vector2.Zero; - var hasPoint = false; - - for (var i = 0; i < outlineCount; i++) - { - var outline = polygon.GetOutline(i); - for (var j = 0; j < outline.Length; j++) - { - var world = navRegion.ToGlobal(outline[j]); - if (!hasPoint) - { - min = world; - max = world; - hasPoint = true; - continue; - } - - min = new Vector2(Mathf.Min(min.X, world.X), Mathf.Min(min.Y, world.Y)); - max = new Vector2(Mathf.Max(max.X, world.X), Mathf.Max(max.Y, world.Y)); - } - } - - if (!hasPoint) - { - return bounds; - } - - var navBounds = new Rect2(min, max - min); - return bounds.Merge(navBounds); - } - - /// - /// 单元格是否在区域内 - /// - /// 单元格 - /// 如果在区域内返回true - private bool IsCellInRegion(Vector2I cell) - { - return cell.X >= _astarRegion.Position.X - && cell.X < _astarRegion.Position.X + _astarRegion.Size.X - && cell.Y >= _astarRegion.Position.Y - && cell.Y < _astarRegion.Position.Y + _astarRegion.Size.Y; - } - - /// - /// 单元格是否在边界内 - /// - /// 单元格 - /// 如果在边界内返回true - private bool IsCellInBounds(Vector2I cell) - { - if (_astarGrid != null) - { - return _astarGrid.IsInBounds(cell.X, cell.Y); - } - - return IsCellInRegion(cell); - } - - /// - /// 世界坐标转网格坐标 - /// - /// 世界坐标 - /// 网格坐标 - private Vector2I WorldToGrid(Vector2 world) - { - var size = Mathf.Max(1.0f, GridCellSize); - return new Vector2I( - Mathf.FloorToInt(world.X / size), - Mathf.FloorToInt(world.Y / size)); - } - - /// - /// 网格坐标转世界坐标 - /// - /// 网格坐标 - /// 世界坐标 - private Vector2 GridToWorld(Vector2I grid) - { - var size = Mathf.Max(1.0f, GridCellSize); - return new Vector2((grid.X + 0.5f) * size, (grid.Y + 0.5f) * size); - } - - /// - /// 查找最近的开放单元格 - /// - /// 起点 - /// 搜索半径 - /// 最近的开放单元格 - private Vector2I FindNearestOpenCell(Vector2I origin, int radius) - { - if (_astarGrid == null) return origin; - if (IsCellInBounds(origin) && !_astarGrid.IsPointSolid(origin)) return origin; - - for (var r = 1; r <= radius; r++) - { - for (var dx = -r; dx <= r; dx++) - { - var dy = r - Math.Abs(dx); - var candidateA = new Vector2I(origin.X + dx, origin.Y + dy); - if (IsCellInBounds(candidateA) && !_astarGrid.IsPointSolid(candidateA)) - { - return candidateA; - } - - if (dy == 0) continue; - var candidateB = new Vector2I(origin.X + dx, origin.Y - dy); - if (IsCellInBounds(candidateB) && !_astarGrid.IsPointSolid(candidateB)) - { - return candidateB; - } - } - } - - return origin; - } - /// /// 世界坐标是否可行走 /// @@ -1384,48 +1036,6 @@ public partial class CampusStudent : CharacterBody2D return closest.DistanceTo(world) <= GetGridTolerance(); } - /// - /// 查找导航区域节点 - /// - /// 导航区域 - private NavigationRegion2D FindNavigationRegion() - { - return GetTree()?.CurrentScene?.FindChild("NavigationRegion2D", true, false) as NavigationRegion2D; - } - - /// - /// 单元格中心是否可行走 - /// - /// 中心点 - /// 如果可行走返回true - private bool IsCellWalkable(Vector2 center) - { - if (!IsWorldWalkable(center)) return false; - - var half = Mathf.Max(1.0f, GridCellSize * 0.45f); - var offsets = new[] - { - new Vector2(half, 0), - new Vector2(-half, 0), - new Vector2(0, half), - new Vector2(0, -half), - new Vector2(half, half), - new Vector2(half, -half), - new Vector2(-half, half), - new Vector2(-half, -half) - }; - - for (var i = 0; i < offsets.Length; i++) - { - if (!IsWorldWalkable(center + offsets[i])) - { - return false; - } - } - - return true; - } - /// /// 获取网格容差 /// @@ -1434,4 +1044,4 @@ public partial class CampusStudent : CharacterBody2D { return Mathf.Max(1.0f, GridWalkableTolerance); } -} \ No newline at end of file +} diff --git a/specs/numerical_design_system.md b/specs/numerical_design_system.md new file mode 100644 index 0000000..6e3cb0d --- /dev/null +++ b/specs/numerical_design_system.md @@ -0,0 +1,163 @@ +# Numerical Design & Balancing System (NDBS) - Implementation Guide + +**Version:** 2.0 (AI-Actionable) +**Target Stack:** Python (FastAPI), Vue.js (Vite), C# (Godot .NET) +**Role:** AI Developer Guide + +## 1. System Overview +The NDBS is a local web application enabling game designers to: +1. **Edit Game Data:** Modify JSON files in `resources/definitions/` (Traits, Archetypes, Items) via a GUI. +2. **Script Logic:** Write C# code for `RuleIds` (special game logic) in a browser-based IDE. The system auto-generates the corresponding `.cs` files in `scripts/Rules/Generated/`. + +This guide is structured as a sequence of tasks for an AI agent to implement the system incrementally. + +--- + +## 2. Directory Structure Plan +The tool will reside in a new `tools/ndbs/` directory, keeping it separate from the game assets but with access to them. + +```text +D:\code\super-mentor\ +├── resources\definitions\ <-- Target Data Source +├── scripts\Rules\Generated\ <-- Target Script Output +├── tools\ +│ └── ndbs\ +│ ├── server\ <-- Python Backend +│ │ ├── main.py +│ │ └── ... +│ └── client\ <-- Vue Frontend +│ ├── src\ +│ └── ... +``` + +--- + +## 3. Implementation Phases (AI Prompts) + +### Phase 1: Python Backend Foundation +**Goal:** Establish a FastAPI server that can read/write the game's JSON files. + +**Step 1.1: Environment Setup** +* **Instruction:** Create `tools/ndbs/server/`. Initialize a Python environment. Install `fastapi`, `uvicorn`, `pydantic`. +* **Code Requirement:** Create `tools/ndbs/server/main.py`. +* **Key Functionality:** + * Enable CORS (allow all origins for dev). + * Define a constant `PROJECT_ROOT` pointing to `../../../../`. + +**Step 1.2: File System API** +* **Instruction:** Implement endpoints to list and read definitions. +* **Endpoints:** + * `GET /api/files/definitions`: Scans `resources/definitions/` and returns a list of `.json` files. + * `GET /api/files/content`: Takes a `filename` query param; returns the parsed JSON content. + * `POST /api/files/content`: Takes `filename` and `content` (JSON body); writes it back to `resources/definitions/{filename}` with 2-space indentation. + +**Step 1.3: Schema Inference (Dynamic Models)** +* **Instruction:** Since we don't have hardcoded Pydantic models for every file, create a utility that reads a JSON file and generates a generic "Schema" description (listing keys and value types) to help the frontend build forms dynamically. + +--- + + +### Phase 2: Vue.js Frontend Foundation +**Goal:** A clean UI to browse files and edit JSON data. + +**Step 2.1: Project Scaffolding** +* **Instruction:** Create `tools/ndbs/client` using Vite + Vue 3 (TypeScript). Install `axios`, `pinia` (state management), and `naive-ui` (or `element-plus`) for UI components. + +**Step 2.2: File Explorer & Layout** +* **Instruction:** Create a standard "Sidebar + Main Content" layout. + * **Sidebar:** Fetches the list of files from `GET /api/files/definitions` and displays them as a menu. + * **Main Content:** A RouterView to show the editor. + +**Step 2.3: Generic JSON Editor** +* **Instruction:** Create a component `JsonEditor.vue`. + * Fetch content using `GET /api/files/content`. + * Display the raw JSON in a textarea (Monaco Editor preferred later, start simple). + * Add a "Save" button that calls `POST /api/files/content`. + * **Enhancement:** Use a library like `jsoneditor` or `v-jsoneditor` to provide a tree view/form view. + +--- + + +### Phase 3: C# Rule Engine Integration (Godot Side) +**Goal:** Prepare the game code to dynamically load the scripts we will generate later. + +**Step 3.1: Define the Interface** +* **Instruction:** Create `scripts/Core/Interfaces/IGameRule.cs`. + ```csharp + namespace Core.Interfaces; + using Models; + public interface IGameRule { + string Id { get; } + // Example hooks - adjust based on GameSystems.cs analysis + void OnEvent(GameSession session, object gameEvent); + void ModifyStats(UnitModel unit, StatRequest request); + } + ``` + +**Step 3.2: Rule Manager (The Loader)** +* **Instruction:** Create `scripts/Core/Systems/RuleManager.cs`. + * Use **Reflection** to find all classes implementing `IGameRule`. + * Store them in a `Dictionary`. + * Provide a method `ExecuteRule(string ruleId, ...)` that looks up the rule and calls its methods. + +**Step 3.3: Hook into Game Systems** +* **Instruction:** Modify `scripts/Core/GameSystems.cs` (specifically `SynergySystem` or `TaskSystem`). + * Find where `RuleIds` are iterated. + * Inject a call to `RuleManager.Instance.ExecuteRule(ruleId, ...)` inside those loops. + +--- + + +### Phase 4: Scriptable Rule Editor (Full Stack) +**Goal:** Allow creating new C# rules from the web UI. + +**Step 4.1: Backend - Template Generator** +* **Instruction:** Add endpoint `POST /api/rules/create`. + * **Params:** `rule_id` (e.g., "grinder_stress_effect"). + * **Action:** + 1. Validate naming (alphanumeric). + 2. Load a C# template string (Class name = `Rule_{CamelCaseId}`). + 3. Write file to `scripts/Rules/Generated/Rule_{CamelCaseId}.cs`. + 4. Return success. + +**Step 4.2: Frontend - C# Editor** +* **Instruction:** Add a "Rules" section to the sidebar. + * List `.cs` files in `scripts/Rules/Generated/` (add backend endpoint for this). + * Integrate **Monaco Editor** (Vue wrapper) for syntax highlighting. + * Save button writes the C# content back to disk. + +**Step 4.3: Compilation Trigger (Optional but Recommended)** +* **Instruction:** Add a "Verify" button. + * Backend endpoint `POST /api/build`. + * Runs `dotnet build` shell command. + * Returns stdout/stderr to the frontend console. + +--- + + +## 4. Technical Constraints & Conventions + +### 4.1 File Paths +* **Server Root:** `tools/ndbs/server` +* **Client Root:** `tools/ndbs/client` +* **Game Root:** `../../` (Relative to server) + +### 4.2 Coding Style +* **Python:** Type hints (Python 3.10+), Pydantic models for request/response bodies. +* **Vue:** Composition API (`