1038 lines
40 KiB
C#
1038 lines
40 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Core;
|
|
using Godot;
|
|
using Models;
|
|
|
|
public partial class CampusController : Node2D {
|
|
private const int GridSearchRadius = 6;
|
|
private const string PortraitScenePath = "res://scenes/student_portrait_16_native.tscn";
|
|
|
|
private static readonly CampusTaskType[] TaskTypePool = {
|
|
CampusTaskType.Experiment,
|
|
CampusTaskType.Writing,
|
|
CampusTaskType.Administration,
|
|
CampusTaskType.Exercise,
|
|
CampusTaskType.Coding,
|
|
CampusTaskType.Social
|
|
};
|
|
|
|
private readonly List<Vector2I> _astarWalkableCells = new();
|
|
private readonly List<CampusBehaviorAgent> _behaviorAgents = new();
|
|
private readonly CampusBehaviorWorld _behaviorWorld = new();
|
|
|
|
private readonly Dictionary<CampusLocationId, CampusBuilding> _buildings = new() {
|
|
{
|
|
CampusLocationId.Laboratory,
|
|
new CampusBuilding("Laboratory", new Rect2I(48, 72, 192, 160), new Vector2I(150, 196))
|
|
}, {
|
|
CampusLocationId.Library,
|
|
new CampusBuilding("Library", new Rect2I(312, 64, 256, 160), new Vector2I(440, 196))
|
|
}, {
|
|
CampusLocationId.Canteen,
|
|
new CampusBuilding("Canteen", new Rect2I(640, 64, 128, 96), new Vector2I(745, 166))
|
|
}, {
|
|
CampusLocationId.Dormitory,
|
|
new CampusBuilding("Dormitory", new Rect2I(800, 64, 96, 96), new Vector2I(848, 192))
|
|
},
|
|
{ CampusLocationId.ArtificialLake, new CampusBuilding("ArtificialLake", new Rect2I(), new Vector2I(943, 179)) },
|
|
{ CampusLocationId.CoffeeShop, new CampusBuilding("CoffeeShop", new Rect2I(), new Vector2I(160, 395)) }, {
|
|
CampusLocationId.AdminBuilding,
|
|
new CampusBuilding("AdminBuilding", new Rect2I(234, 312, 128, 96), new Vector2I(296, 452))
|
|
}, {
|
|
CampusLocationId.FootballField,
|
|
new CampusBuilding("FootballField", new Rect2I(568, 320, 160, 160), new Vector2I(560, 300))
|
|
}
|
|
};
|
|
|
|
private readonly List<Vector2> _coveragePoints = new();
|
|
private readonly CampusLocationRegistry _locationRegistry = new();
|
|
private List<string> _archetypeIds = new();
|
|
private AStarGrid2D _astarGrid;
|
|
private Rid _astarMap;
|
|
private int _astarMapIteration;
|
|
private Rect2I _astarRegion;
|
|
private CampusBehaviorConfig _behaviorConfig;
|
|
private GameContentDatabase _contentDatabase;
|
|
private DebugGridOverlay _debugGridOverlay;
|
|
private List<string> _disciplineIds = new();
|
|
private Control _logContainer;
|
|
private RichTextLabel _logLabel;
|
|
private Button _logToggle;
|
|
private uint _navBakeIterationId;
|
|
private Rid _navBakeMap;
|
|
private bool _navBakePending;
|
|
private bool _navBakeReady;
|
|
|
|
private NavigationRegion2D _navigationRegion;
|
|
private Random _random;
|
|
private List<string> _roleIds = new();
|
|
private bool _roundActive;
|
|
private float _roundElapsed;
|
|
private int _roundIndex;
|
|
private bool _spawnPending = true;
|
|
private Node2D _studentsRoot;
|
|
private Node2D _portraitRoot;
|
|
private PackedScene _studentPortraitScene;
|
|
private readonly Vector2 _portraitCellSize = new(32, 32);
|
|
private readonly Dictionary<CampusStudent, StudentPortrait16Native> _studentPortraits = new();
|
|
private readonly Dictionary<CampusStudent, CampusLocationId> _studentPortraitLocations = new();
|
|
private Control _taskContainer;
|
|
private Button _taskToggle;
|
|
private TopBar _topBar;
|
|
private List<string> _traitIds = new();
|
|
|
|
[Export] public PackedScene StudentScene { get; set; }
|
|
[Export] public int StudentCount { get; set; } = 100;
|
|
[Export] public float CoverageStep { get; set; } = 48.0f;
|
|
[Export] public int MaxCoveragePoints { get; set; } = 200;
|
|
[Export] public string BehaviorConfigPath { get; set; } = "res://resources/definitions/campus_behavior.json";
|
|
[Export] public int RandomSeed { get; set; }
|
|
[Export] public int AssignedTaskChancePercent { get; set; } = 60;
|
|
[Export] public float RoundDurationSeconds { get; set; } = 30.0f;
|
|
[Export] public float AgentMoveSpeed { get; set; } = 180.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; }
|
|
|
|
// Called when the node enters the scene tree for the first time.
|
|
public override void _Ready() {
|
|
SetProcessUnhandledInput(true);
|
|
_taskContainer = GetNode<Control>("Task");
|
|
_logContainer = GetNode<Control>("Log");
|
|
|
|
// Path to buttons based on scene structure
|
|
_taskToggle = GetNode<Button>("TopBar/HBox/CC1/TaskToggle");
|
|
_logToggle = GetNode<Button>("TopBar/HBox/CC2/LogToggle");
|
|
|
|
// Sync initial state
|
|
_taskToggle.ButtonPressed = _taskContainer.Visible;
|
|
_logToggle.ButtonPressed = _logContainer.Visible;
|
|
|
|
_taskToggle.Toggled += OnTaskToggled;
|
|
_logToggle.Toggled += OnLogToggled;
|
|
|
|
_topBar = GetNodeOrNull<TopBar>("TopBar");
|
|
if (_topBar != null) _topBar.ResetRound(RoundDurationSeconds);
|
|
|
|
_logLabel = GetNodeOrNull<RichTextLabel>("Log/VBoxContainer/RichTextLabel");
|
|
if (_logLabel != null) _logLabel.Text = string.Empty;
|
|
|
|
InitializeBehaviorAssets();
|
|
|
|
// 导航区域与学生容器初始化
|
|
_navigationRegion = GetNodeOrNull<NavigationRegion2D>("Sprite2D/NavigationRegion2D");
|
|
_studentsRoot = GetNodeOrNull<Node2D>("Students");
|
|
if (_studentsRoot == null) {
|
|
_studentsRoot = new Node2D { Name = "Students" };
|
|
AddChild(_studentsRoot);
|
|
}
|
|
EnsurePortraitRoot();
|
|
|
|
EnsureDebugGridOverlay();
|
|
|
|
// 使用可视化轮廓重新烘焙导航多边形,确保洞被正确识别
|
|
RebakeNavigationPolygonFromOutlines();
|
|
|
|
// 等待导航地图同步完成后再生成学生
|
|
_spawnPending = true;
|
|
}
|
|
|
|
// Called every frame. 'delta' is the elapsed time since the previous frame.
|
|
public override void _Process(double delta) {
|
|
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() {
|
|
_behaviorConfig = CampusBehaviorConfig.Load(BehaviorConfigPath);
|
|
_random = RandomSeed == 0 ? new Random() : new Random(RandomSeed);
|
|
LoadContentDatabase();
|
|
CacheLocations();
|
|
}
|
|
|
|
private void LoadContentDatabase() {
|
|
var registry = new ContentRegistry();
|
|
var jsonSource = new JsonContentSource(0);
|
|
jsonSource.DataPaths.Add("res://resources/definitions/disciplines.json");
|
|
jsonSource.DataPaths.Add("res://resources/definitions/archetypes.json");
|
|
jsonSource.DataPaths.Add("res://resources/definitions/roles.json");
|
|
jsonSource.DataPaths.Add("res://resources/definitions/traits.json");
|
|
registry.RegisterSource(jsonSource);
|
|
|
|
_contentDatabase = registry.BuildDatabase();
|
|
_archetypeIds = new List<string>(_contentDatabase.Archetypes.Keys);
|
|
_roleIds = new List<string>(_contentDatabase.Roles.Keys);
|
|
_traitIds = new List<string>(_contentDatabase.Traits.Keys);
|
|
_disciplineIds = new List<string>(_contentDatabase.Disciplines.Keys);
|
|
|
|
if (_archetypeIds.Count == 0 || _roleIds.Count == 0 || _traitIds.Count == 0)
|
|
GD.PushWarning("Behavior content definitions are missing; random tags will be limited.");
|
|
}
|
|
|
|
private void CacheLocations() {
|
|
var locationsRoot = GetNodeOrNull<Node2D>("Locations");
|
|
if (locationsRoot == null) {
|
|
GD.PushWarning("Campus scene is missing Locations root; agents will wander only.");
|
|
return;
|
|
}
|
|
|
|
RegisterLocation(locationsRoot, "Location_Lab", CampusLocationId.Laboratory);
|
|
RegisterLocation(locationsRoot, "Location_Library", CampusLocationId.Library);
|
|
RegisterLocation(locationsRoot, "Location_Canteen", CampusLocationId.Canteen);
|
|
RegisterLocation(locationsRoot, "Location_Dorm", CampusLocationId.Dormitory);
|
|
RegisterLocation(locationsRoot, "Location_Lake", CampusLocationId.ArtificialLake);
|
|
RegisterLocation(locationsRoot, "Location_Coffee", CampusLocationId.CoffeeShop);
|
|
RegisterLocation(locationsRoot, "Location_Admin", CampusLocationId.AdminBuilding);
|
|
RegisterLocation(locationsRoot, "Location_Field", CampusLocationId.FootballField);
|
|
}
|
|
|
|
private void RegisterLocation(Node2D root, string nodeName, CampusLocationId id) {
|
|
var node = root.GetNodeOrNull<Node2D>(nodeName);
|
|
if (node == null) {
|
|
GD.PushWarning($"Campus location marker not found: {nodeName}");
|
|
return;
|
|
}
|
|
|
|
_locationRegistry.Register(id, node.GlobalPosition);
|
|
}
|
|
|
|
public void HandleStudentEnterBuilding(CampusStudent student, CampusLocationId locationId) {
|
|
if (student == null) return;
|
|
if (!TryGetPortraitBuilding(locationId, out var building)) return;
|
|
|
|
EnsurePortraitRoot();
|
|
var portraitScene = EnsurePortraitScene();
|
|
if (portraitScene == null) return;
|
|
|
|
student.Visible = false;
|
|
student.GlobalPosition = new Vector2(building.EntrancePoint.X, building.EntrancePoint.Y);
|
|
|
|
if (_studentPortraits.TryGetValue(student, out var existingPortrait)) {
|
|
if (!IsPortraitValid(existingPortrait)) {
|
|
_studentPortraits.Remove(student);
|
|
_studentPortraitLocations.Remove(student);
|
|
RemovePortraitFromBuildings(existingPortrait);
|
|
}
|
|
else {
|
|
_studentPortraitLocations[student] = locationId;
|
|
existingPortrait.ThemeId = student.GetSuitThemeId();
|
|
if (!building.Portraits.Contains(existingPortrait)) building.Portraits.Add(existingPortrait);
|
|
ArrangeBuildingPortraits(locationId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
var instance = portraitScene.Instantiate();
|
|
if (instance is not StudentPortrait16Native portrait) {
|
|
GD.PushWarning("student_portrait_16_native.tscn 需要挂载 StudentPortrait16Native 脚本。");
|
|
instance.QueueFree();
|
|
student.Visible = true;
|
|
return;
|
|
}
|
|
|
|
_portraitRoot.AddChild(portrait);
|
|
portrait.Name = $"{student.Name}_Portrait";
|
|
portrait.ThemeId = student.GetSuitThemeId();
|
|
|
|
_studentPortraits[student] = portrait;
|
|
_studentPortraitLocations[student] = locationId;
|
|
building.Portraits.Add(portrait);
|
|
|
|
ArrangeBuildingPortraits(locationId);
|
|
}
|
|
|
|
public void HandleStudentExitBuilding(CampusStudent student, CampusLocationId locationId) {
|
|
if (student == null) return;
|
|
|
|
if (!_studentPortraitLocations.TryGetValue(student, out var recordedLocation)) recordedLocation = locationId;
|
|
if (!TryGetPortraitBuilding(recordedLocation, out var building)) {
|
|
student.Visible = true;
|
|
return;
|
|
}
|
|
|
|
if (_studentPortraits.TryGetValue(student, out var portrait)) {
|
|
building.Portraits.Remove(portrait);
|
|
_studentPortraits.Remove(student);
|
|
if (IsPortraitValid(portrait)) portrait.QueueFree();
|
|
}
|
|
|
|
_studentPortraitLocations.Remove(student);
|
|
ArrangeBuildingPortraits(recordedLocation);
|
|
student.Visible = true;
|
|
student.GlobalPosition = new Vector2(building.EntrancePoint.X, building.EntrancePoint.Y);
|
|
}
|
|
|
|
private void EnsurePortraitRoot() {
|
|
if (_portraitRoot != null) return;
|
|
|
|
_portraitRoot = GetNodeOrNull<Node2D>("Portraits");
|
|
if (_portraitRoot == null) {
|
|
_portraitRoot = new Node2D { Name = "Portraits", ZIndex = 2 };
|
|
AddChild(_portraitRoot);
|
|
}
|
|
}
|
|
|
|
private PackedScene EnsurePortraitScene() {
|
|
if (_studentPortraitScene == null)
|
|
_studentPortraitScene = ResourceLoader.Load<PackedScene>(PortraitScenePath);
|
|
|
|
if (_studentPortraitScene == null)
|
|
GD.PushWarning("student_portrait_16_native.tscn 未能加载。");
|
|
|
|
return _studentPortraitScene;
|
|
}
|
|
|
|
private bool TryGetPortraitBuilding(CampusLocationId locationId, out CampusBuilding building) {
|
|
if (_buildings.TryGetValue(locationId, out building)) {
|
|
return building.PortraitRegion.Size.X > 0 && building.PortraitRegion.Size.Y > 0;
|
|
}
|
|
|
|
building = null;
|
|
return false;
|
|
}
|
|
|
|
private void ArrangeBuildingPortraits(CampusLocationId locationId) {
|
|
if (!TryGetPortraitBuilding(locationId, out var building)) return;
|
|
ArrangeBuildingPortraits(building);
|
|
}
|
|
|
|
private void ArrangeBuildingPortraits(CampusBuilding building) {
|
|
var portraits = building.Portraits;
|
|
CleanBuildingPortraits(portraits);
|
|
if (portraits.Count == 0) return;
|
|
|
|
var region = building.PortraitRegion;
|
|
var cellWidth = Mathf.Max(1.0f, _portraitCellSize.X);
|
|
var cellHeight = Mathf.Max(1.0f, _portraitCellSize.Y);
|
|
var columns = Math.Max(1, Mathf.FloorToInt(region.Size.X / cellWidth));
|
|
var rows = Math.Max(1, Mathf.FloorToInt(region.Size.Y / cellHeight));
|
|
var capacity = columns * rows;
|
|
if (capacity <= 0) return;
|
|
|
|
var regionPos = new Vector2(region.Position.X, region.Position.Y);
|
|
var regionSize = new Vector2(region.Size.X, region.Size.Y);
|
|
var count = portraits.Count;
|
|
|
|
if (count > capacity) {
|
|
var gridWidth = columns * cellWidth;
|
|
var gridHeight = rows * cellHeight;
|
|
var startX = regionPos.X + (regionSize.X - gridWidth) * 0.5f + cellWidth * 0.5f;
|
|
var startY = regionPos.Y + (regionSize.Y - gridHeight) * 0.5f + cellHeight * 0.5f;
|
|
|
|
for (var i = 0; i < count; i++) {
|
|
var slot = i % capacity;
|
|
var row = slot / columns;
|
|
var col = slot % columns;
|
|
portraits[i].Position = new Vector2(
|
|
startX + col * cellWidth,
|
|
startY + row * cellHeight);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var columnsUsed = Math.Min(columns, count);
|
|
var rowCount = Mathf.CeilToInt(count / (float)columnsUsed);
|
|
var totalHeight = rowCount * cellHeight;
|
|
var baseY = regionPos.Y + (regionSize.Y - totalHeight) * 0.5f + cellHeight * 0.5f;
|
|
|
|
for (var i = 0; i < count; i++) {
|
|
var row = i / columnsUsed;
|
|
var col = i % columnsUsed;
|
|
var itemsInRow = Math.Min(columnsUsed, count - row * columnsUsed);
|
|
var rowWidth = itemsInRow * cellWidth;
|
|
var startX = regionPos.X + (regionSize.X - rowWidth) * 0.5f + cellWidth * 0.5f;
|
|
portraits[i].Position = new Vector2(
|
|
startX + col * cellWidth,
|
|
baseY + row * cellHeight);
|
|
}
|
|
}
|
|
|
|
private static bool IsPortraitValid(StudentPortrait16Native portrait) {
|
|
if (portrait == null) return false;
|
|
try {
|
|
if (!GodotObject.IsInstanceValid(portrait)) return false;
|
|
return !portrait.IsQueuedForDeletion();
|
|
}
|
|
catch (ObjectDisposedException) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void CleanBuildingPortraits(List<StudentPortrait16Native> portraits) {
|
|
for (var i = portraits.Count - 1; i >= 0; i--) {
|
|
if (!IsPortraitValid(portraits[i])) portraits.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
private void RemovePortraitFromBuildings(StudentPortrait16Native portrait) {
|
|
if (portrait == null) return;
|
|
foreach (var building in _buildings.Values) building.Portraits.Remove(portrait);
|
|
}
|
|
|
|
private void UpdateBehaviorAgents(float delta) {
|
|
if (_behaviorAgents.Count == 0) return;
|
|
|
|
_behaviorWorld.Clear();
|
|
foreach (var agent in _behaviorAgents) _behaviorWorld.AddOccupant(agent.Runtime.CurrentLocationId);
|
|
|
|
foreach (var agent in _behaviorAgents) agent.Tick(delta);
|
|
}
|
|
|
|
private void UpdateRoundTimer(float delta) {
|
|
if (_behaviorAgents.Count == 0 || _spawnPending) return;
|
|
|
|
if (!_roundActive) StartRound();
|
|
|
|
if (!_roundActive) return;
|
|
|
|
_roundElapsed += delta;
|
|
_topBar?.UpdateRoundProgress(_roundElapsed, RoundDurationSeconds);
|
|
|
|
if (_roundElapsed >= RoundDurationSeconds) EndRound();
|
|
}
|
|
|
|
private void StartRound() {
|
|
_roundActive = true;
|
|
_roundElapsed = 0.0f;
|
|
_roundIndex += 1;
|
|
_topBar?.ResetRound(RoundDurationSeconds);
|
|
AppendLog($"第{_roundIndex}轮开始");
|
|
|
|
foreach (var agent in _behaviorAgents) {
|
|
EnsureRoundTask(agent.Runtime);
|
|
agent.StartRound(RoundDurationSeconds);
|
|
}
|
|
}
|
|
|
|
private void EndRound() {
|
|
_roundActive = false;
|
|
foreach (var agent in _behaviorAgents) agent.EndRound();
|
|
|
|
AppendLog($"第{_roundIndex}轮结束");
|
|
}
|
|
|
|
private void EnsureRoundTask(CampusAgentRuntime runtime) {
|
|
if (runtime.AssignedTask != null) return;
|
|
if (TaskTypePool.Length == 0) return;
|
|
|
|
var rng = _random ?? Random.Shared;
|
|
if (rng.Next(0, 100) >= AssignedTaskChancePercent) return;
|
|
|
|
var taskType = TaskTypePool[rng.Next(0, TaskTypePool.Length)];
|
|
var maxDuration = Math.Max(10, (int)RoundDurationSeconds - 5);
|
|
var duration = rng.Next(8, Math.Max(9, maxDuration));
|
|
runtime.AssignedTask = new CampusTask(taskType, duration);
|
|
}
|
|
|
|
private void AppendLog(string message) {
|
|
if (_logLabel == null || string.IsNullOrWhiteSpace(message)) return;
|
|
_logLabel.AppendText(message + "\n");
|
|
}
|
|
|
|
private void OnTaskToggled(bool pressed) {
|
|
AnimateVisibility(_taskContainer, pressed);
|
|
}
|
|
|
|
private void OnLogToggled(bool pressed) {
|
|
AnimateVisibility(_logContainer, pressed);
|
|
}
|
|
|
|
private void AnimateVisibility(Control container, bool visible) {
|
|
var tween = CreateTween();
|
|
if (visible) {
|
|
if (!container.Visible) {
|
|
var col = container.Modulate;
|
|
col.A = 0;
|
|
container.Modulate = col;
|
|
container.Visible = true;
|
|
}
|
|
|
|
tween.TweenProperty(container, "modulate:a", 1.0f, 0.2f);
|
|
}
|
|
else {
|
|
tween.TweenProperty(container, "modulate:a", 0.0f, 0.2f);
|
|
tween.TweenCallback(Callable.From(() => container.Visible = false));
|
|
}
|
|
}
|
|
|
|
private void TrySpawnStudents() {
|
|
if (!_spawnPending) return;
|
|
|
|
if (_navBakePending || !_navBakeReady) return;
|
|
|
|
// 已生成过学生则不重复生成
|
|
if (_studentsRoot != null && _studentsRoot.GetChildCount() > 0) {
|
|
_spawnPending = false;
|
|
return;
|
|
}
|
|
|
|
if (StudentScene == null)
|
|
StudentScene = ResourceLoader.Load<PackedScene>("res://scenes/student_16_native.tscn");
|
|
|
|
if (_navigationRegion == null || _navigationRegion.NavigationPolygon == null) {
|
|
GD.PushWarning("校园导航区域未配置或缺失导航多边形,无法生成巡游学生。");
|
|
_spawnPending = false;
|
|
return;
|
|
}
|
|
|
|
// 导航地图可能还未就绪,需要等待同步完成后再采样
|
|
var map = GetNavigationMap();
|
|
if (!map.IsValid) return;
|
|
|
|
var iterationId = NavigationServer2D.MapGetIterationId(map);
|
|
if (iterationId == 0) return;
|
|
|
|
// 等待导航图完成新一轮同步,避免采样到旧的/空的地图
|
|
if (_navBakeMap.IsValid) {
|
|
if (map == _navBakeMap && iterationId == _navBakeIterationId) return;
|
|
|
|
if (NavigationServer2D.MapGetRegions(map).Count == 0) return;
|
|
}
|
|
|
|
if (!EnsureAStarGrid() || _astarWalkableCells.Count == 0) return;
|
|
|
|
if (SpawnStudents()) _spawnPending = false;
|
|
}
|
|
|
|
private bool SpawnStudents() {
|
|
_coveragePoints.Clear();
|
|
_coveragePoints.AddRange(BuildCoveragePoints());
|
|
if (_coveragePoints.Count == 0) {
|
|
GD.PushWarning("未采样到可行走区域,跳过学生生成。");
|
|
return false;
|
|
}
|
|
|
|
for (var i = 0; i < StudentCount; i++) {
|
|
var instance = StudentScene.Instantiate();
|
|
if (instance is not CampusStudent student) {
|
|
GD.PushWarning("student_16_native.tscn 需要挂载 CampusStudent 脚本。");
|
|
instance.QueueFree();
|
|
continue;
|
|
}
|
|
|
|
_studentsRoot.AddChild(student);
|
|
student.MoveSpeed = AgentMoveSpeed;
|
|
|
|
// 随机放置在可行走区域,并交给行为系统控制
|
|
if (!TryGetRandomWalkableGridPoint(out var gridPoint)) {
|
|
GD.PushWarning("AStarGrid2D 未准备好,无法放置学生。");
|
|
return false;
|
|
}
|
|
|
|
student.GlobalPosition = gridPoint;
|
|
student.ApplyRandomTheme();
|
|
|
|
var runtime = BuildRandomAgentRuntime(i);
|
|
student.Name = runtime.Name;
|
|
|
|
var agent = new CampusBehaviorAgent(
|
|
student,
|
|
runtime,
|
|
_behaviorConfig,
|
|
_locationRegistry,
|
|
_behaviorWorld,
|
|
_random,
|
|
_coveragePoints,
|
|
AppendLog);
|
|
|
|
_behaviorAgents.Add(agent);
|
|
LogSpawn(runtime);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private CampusAgentRuntime BuildRandomAgentRuntime(int index) {
|
|
var rng = _random ?? Random.Shared;
|
|
var name = $"Agent_{index + 1}";
|
|
var student = new StudentModel(name, rng);
|
|
AssignRandomTags(student.Core, rng);
|
|
ApplyRandomStatus(student, rng);
|
|
|
|
var needs = new CampusAgentNeeds(
|
|
rng.Next(20, 100),
|
|
rng.Next(20, 100),
|
|
rng.Next(20, 100),
|
|
rng.Next(60, 100));
|
|
|
|
var runtime = new CampusAgentRuntime(student, needs);
|
|
|
|
if (rng.Next(0, 100) < AssignedTaskChancePercent && TaskTypePool.Length > 0) {
|
|
var taskType = TaskTypePool[rng.Next(0, TaskTypePool.Length)];
|
|
var duration = rng.Next(8, 16);
|
|
runtime.AssignedTask = new CampusTask(taskType, duration);
|
|
}
|
|
|
|
return runtime;
|
|
}
|
|
|
|
private void AssignRandomTags(UnitModel unit, Random rng) {
|
|
unit.Tags.ArchetypeIds.Clear();
|
|
unit.Tags.RoleIds.Clear();
|
|
unit.Tags.TraitIds.Clear();
|
|
|
|
var archetypeId = PickRandomId(_archetypeIds, rng);
|
|
if (!string.IsNullOrWhiteSpace(archetypeId)) unit.Tags.ArchetypeIds.Add(archetypeId);
|
|
|
|
var roleId = PickRandomId(_roleIds, rng);
|
|
if (!string.IsNullOrWhiteSpace(roleId)) unit.Tags.RoleIds.Add(roleId);
|
|
|
|
var traitCount = _traitIds.Count == 0 ? 0 : rng.Next(1, Math.Min(3, _traitIds.Count + 1));
|
|
for (var i = 0; i < traitCount; i++) {
|
|
var traitId = PickRandomId(_traitIds, rng);
|
|
if (!string.IsNullOrWhiteSpace(traitId) && !unit.Tags.TraitIds.Contains(traitId))
|
|
unit.Tags.TraitIds.Add(traitId);
|
|
}
|
|
|
|
var disciplineId = PickRandomId(_disciplineIds, rng);
|
|
if (!string.IsNullOrWhiteSpace(disciplineId)) unit.Tags.DisciplineId = disciplineId;
|
|
}
|
|
|
|
private void ApplyRandomStatus(StudentModel student, Random rng) {
|
|
var unit = student.Core;
|
|
unit.Statuses.Stress.Current.Value = rng.Next(0, 60);
|
|
unit.Statuses.Sanity.Current.Value = rng.Next(50, 100);
|
|
unit.Statuses.Mood.Value = rng.Next(30, 90);
|
|
student.Progress.Stamina.Current.Value = rng.Next(25, 100);
|
|
}
|
|
|
|
private static string PickRandomId(List<string> ids, Random rng) {
|
|
if (ids == null || ids.Count == 0) return null;
|
|
return ids[rng.Next(0, ids.Count)];
|
|
}
|
|
|
|
private void LogSpawn(CampusAgentRuntime runtime) {
|
|
var unit = runtime.Unit;
|
|
var archetypes = unit.Tags.ArchetypeIds.Count == 0 ? "none" : string.Join(",", unit.Tags.ArchetypeIds);
|
|
var roles = unit.Tags.RoleIds.Count == 0 ? "none" : string.Join(",", unit.Tags.RoleIds);
|
|
var traits = unit.Tags.TraitIds.Count == 0 ? "none" : string.Join(",", unit.Tags.TraitIds);
|
|
var taskInfo = runtime.AssignedTask != null
|
|
? $"{runtime.AssignedTask.Type} ({runtime.AssignedTask.RemainingSeconds:0}s)"
|
|
: "none";
|
|
var attributes =
|
|
$"A:{unit.Attributes.Academic.DisplayInt()} " +
|
|
$"E:{unit.Attributes.Engineering.DisplayInt()} " +
|
|
$"W:{unit.Attributes.Writing.DisplayInt()} " +
|
|
$"F:{unit.Attributes.Financial.DisplayInt()} " +
|
|
$"S:{unit.Attributes.Social.DisplayInt()} " +
|
|
$"Act:{unit.Attributes.Activation.DisplayInt()}";
|
|
|
|
GD.Print(
|
|
$"[CampusAI] Spawned {runtime.Name} archetype={archetypes} role={roles} traits={traits} task={taskInfo} attrs={attributes}");
|
|
}
|
|
|
|
private List<Vector2> BuildCoveragePoints() {
|
|
var points = new List<Vector2>();
|
|
var polygon = _navigationRegion.NavigationPolygon;
|
|
if (polygon == null || polygon.Vertices.Length == 0) return points;
|
|
|
|
var outlineCount = polygon.GetOutlineCount();
|
|
var outer = polygon.GetOutline(0);
|
|
if (outer.Length == 0) return points;
|
|
|
|
var holes = new List<Vector2[]>();
|
|
for (var i = 1; i < outlineCount; i++) {
|
|
var hole = polygon.GetOutline(i);
|
|
if (hole.Length > 0) holes.Add(hole);
|
|
}
|
|
|
|
// 根据外轮廓计算采样范围
|
|
var min = outer[0];
|
|
var max = outer[0];
|
|
foreach (var v in outer) {
|
|
min = new Vector2(Mathf.Min(min.X, v.X), Mathf.Min(min.Y, v.Y));
|
|
max = new Vector2(Mathf.Max(max.X, v.X), Mathf.Max(max.Y, v.Y));
|
|
}
|
|
|
|
var step = Mathf.Max(8.0f, CoverageStep);
|
|
var minDistance = step * 0.45f;
|
|
for (var x = min.X; x <= max.X; x += step)
|
|
for (var y = min.Y; y <= max.Y; y += step) {
|
|
var local = new Vector2(x, y);
|
|
if (!Geometry2D.IsPointInPolygon(local, outer)) continue;
|
|
|
|
var insideHole = false;
|
|
for (var i = 0; i < holes.Count; i++)
|
|
if (Geometry2D.IsPointInPolygon(local, holes[i])) {
|
|
insideHole = true;
|
|
break;
|
|
}
|
|
|
|
if (insideHole) continue;
|
|
|
|
var global = _navigationRegion.ToGlobal(local);
|
|
if (!HasNearbyPoint(points, global, minDistance)) {
|
|
points.Add(global);
|
|
if (points.Count >= MaxCoveragePoints) return points;
|
|
}
|
|
}
|
|
|
|
// 补充采样轮廓顶点,避免狭窄区域缺少巡游点
|
|
foreach (var vertex in outer) {
|
|
var global = _navigationRegion.ToGlobal(vertex);
|
|
if (!HasNearbyPoint(points, global, minDistance)) {
|
|
points.Add(global);
|
|
if (points.Count >= MaxCoveragePoints) return points;
|
|
}
|
|
}
|
|
|
|
// 兜底:至少给一个可行走点
|
|
if (points.Count == 0) {
|
|
var centerLocal = (min + max) * 0.5f;
|
|
points.Add(_navigationRegion.ToGlobal(centerLocal));
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
private Rid GetNavigationMap() {
|
|
if (_navigationRegion == null) return new Rid();
|
|
|
|
var map = NavigationServer2D.RegionGetMap(_navigationRegion.GetRid());
|
|
if (!map.IsValid) {
|
|
var world = GetWorld2D();
|
|
if (world != null) map = world.NavigationMap;
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
public List<Vector2> 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<Vector2>(path.Count);
|
|
foreach (var cell in path) result.Add(GridToWorld(cell));
|
|
|
|
if (result.Count > 0) result.RemoveAt(0);
|
|
|
|
return result;
|
|
}
|
|
|
|
private bool TryGetRandomWalkableGridPoint(out Vector2 point) {
|
|
point = Vector2.Zero;
|
|
if (!EnsureAStarGrid() || _astarWalkableCells.Count == 0) return false;
|
|
|
|
var index = _random != null
|
|
? _random.Next(0, _astarWalkableCells.Count)
|
|
: GD.RandRange(0, _astarWalkableCells.Count - 1);
|
|
point = GridToWorld(_astarWalkableCells[index]);
|
|
return true;
|
|
}
|
|
|
|
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.DefaultComputeHeuristic = AStarGrid2D.Heuristic.Manhattan;
|
|
_astarGrid.DefaultEstimateHeuristic = AStarGrid2D.Heuristic.Manhattan;
|
|
_astarGrid.Region = region;
|
|
_astarGrid.CellSize = cellSize;
|
|
_astarGrid.Offset = Vector2.Zero;
|
|
_astarGrid.DiagonalMode = AStarGrid2D.DiagonalModeEnum.Never;
|
|
_astarGrid.Update();
|
|
|
|
_astarWalkableCells.Clear();
|
|
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);
|
|
if (walkable) _astarWalkableCells.Add(cell);
|
|
}
|
|
|
|
_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<Vector2> points, Vector2 candidate, float minDistance) {
|
|
for (var i = 0; i < points.Count; i++)
|
|
if (points[i].DistanceTo(candidate) <= minDistance)
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
private void RebakeNavigationPolygonFromOutlines() {
|
|
if (_navigationRegion == null || _navigationRegion.NavigationPolygon == null) return;
|
|
|
|
var navPolygon = _navigationRegion.NavigationPolygon;
|
|
var outlineCount = navPolygon.GetOutlineCount();
|
|
if (outlineCount == 0) return;
|
|
|
|
_navBakePending = true;
|
|
_navBakeReady = false;
|
|
_navBakeMap = GetNavigationMap();
|
|
_navBakeIterationId = _navBakeMap.IsValid ? NavigationServer2D.MapGetIterationId(_navBakeMap) : 0;
|
|
|
|
// 将第一个轮廓作为可通行区域,其余轮廓作为障碍洞
|
|
var sourceGeometry = new NavigationMeshSourceGeometryData2D();
|
|
sourceGeometry.AddTraversableOutline(navPolygon.GetOutline(0));
|
|
for (var i = 1; i < outlineCount; i++) sourceGeometry.AddObstructionOutline(navPolygon.GetOutline(i));
|
|
|
|
NavigationServer2D.BakeFromSourceGeometryData(navPolygon, sourceGeometry, Callable.From(() => {
|
|
// 烘焙完成后再刷新导航区域,避免用旧的多边形生成地图
|
|
_navigationRegion.NavigationPolygon = navPolygon;
|
|
_astarGrid = null;
|
|
_astarMap = new Rid();
|
|
_astarMapIteration = 0;
|
|
_astarWalkableCells.Clear();
|
|
_navBakePending = false;
|
|
_navBakeReady = true;
|
|
RequestDebugGridRedraw();
|
|
}));
|
|
}
|
|
|
|
private void EnsureDebugGridOverlay() {
|
|
if (_debugGridOverlay != null) return;
|
|
|
|
var sprite = GetNodeOrNull<Node2D>("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 class CampusBuilding(string locationId, Rect2I pRegion, Vector2I entrance) {
|
|
public string LocationId { get; } = locationId;
|
|
public HashSet<string> MemberIds { get; set; } = [];
|
|
public List<StudentPortrait16Native> Portraits { get; } = new();
|
|
public Rect2I PortraitRegion { get; } = pRegion;
|
|
public Vector2I EntrancePoint { get; } = entrance;
|
|
}
|
|
|
|
private sealed partial class DebugGridOverlay : Node2D {
|
|
public CampusController CampusOwner { get; set; }
|
|
|
|
public override void _Draw() {
|
|
CampusOwner?.DrawDebugGrid(this);
|
|
}
|
|
}
|
|
}
|