zc
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -336,6 +336,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
|
||||
_tools.Add(new Tool_GetMapPawns());
|
||||
_tools.Add(new Tool_GetRecentNotifications());
|
||||
_tools.Add(new Tool_CallBombardment());
|
||||
_tools.Add(new Tool_GetAvailableBombardments());
|
||||
_tools.Add(new Tool_SearchThingDef());
|
||||
_tools.Add(new Tool_SearchPawnKind());
|
||||
_tools.Add(new Tool_CallPrefabAirdrop());
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// 自主 Agent 循环 - 持续观察游戏并做出决策
|
||||
/// 用户只需给出开放式指令如"帮我挖铁"或"帮我玩殖民地"
|
||||
/// </summary>
|
||||
public class AutonomousAgentLoop : GameComponent
|
||||
{
|
||||
public static AutonomousAgentLoop Instance { get; private set; }
|
||||
|
||||
// Agent 状态
|
||||
private bool _isRunning;
|
||||
private string _currentObjective;
|
||||
private float _lastDecisionTime;
|
||||
private int _decisionCount;
|
||||
private readonly List<string> _actionHistory = new List<string>();
|
||||
|
||||
// 配置
|
||||
private const float DecisionIntervalSeconds = 3f; // 每 3 秒决策一次
|
||||
private const int MaxActionsPerObjective = 100;
|
||||
|
||||
// 事件
|
||||
public event Action<string> OnDecisionMade;
|
||||
public event Action<string> OnObjectiveComplete;
|
||||
public event Action<string> OnError;
|
||||
|
||||
public bool IsRunning => _isRunning;
|
||||
public string CurrentObjective => _currentObjective;
|
||||
public int DecisionCount => _decisionCount;
|
||||
|
||||
public AutonomousAgentLoop(Game game)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开始执行开放式目标
|
||||
/// </summary>
|
||||
public void StartObjective(string objective)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objective))
|
||||
{
|
||||
OnError?.Invoke("目标不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentObjective = objective;
|
||||
_isRunning = true;
|
||||
_decisionCount = 0;
|
||||
_actionHistory.Clear();
|
||||
_lastDecisionTime = Time.realtimeSinceStartup;
|
||||
|
||||
WulaLog.Debug($"[AgentLoop] Started objective: {objective}");
|
||||
Messages.Message($"AI Agent 开始执行: {objective}", MessageTypeDefOf.NeutralEvent);
|
||||
|
||||
// 立即执行第一次决策
|
||||
_ = ExecuteDecisionCycleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 Agent
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
_isRunning = false;
|
||||
WulaLog.Debug($"[AgentLoop] Stopped after {_decisionCount} decisions");
|
||||
Messages.Message($"AI Agent 已停止,执行了 {_decisionCount} 次决策", MessageTypeDefOf.NeutralEvent);
|
||||
}
|
||||
|
||||
public override void GameComponentTick()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
// 检查是否到达决策间隔
|
||||
if (Time.realtimeSinceStartup - _lastDecisionTime < DecisionIntervalSeconds) return;
|
||||
|
||||
// 检查是否超过最大操作次数
|
||||
if (_decisionCount >= MaxActionsPerObjective)
|
||||
{
|
||||
Messages.Message($"AI Agent: 已达到最大操作次数 ({MaxActionsPerObjective}),暂停执行", MessageTypeDefOf.CautionInput);
|
||||
Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
_lastDecisionTime = Time.realtimeSinceStartup;
|
||||
|
||||
// 异步执行决策
|
||||
_ = ExecuteDecisionCycleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行一次决策循环: Observe → Think → Act
|
||||
/// </summary>
|
||||
private async Task ExecuteDecisionCycleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Observe - 收集游戏状态
|
||||
var gameState = StateObserver.CaptureState();
|
||||
string stateText = gameState.ToPromptText();
|
||||
|
||||
// 2. 构建决策提示词
|
||||
string prompt = BuildDecisionPrompt(stateText);
|
||||
|
||||
// 3. Think - 调用 AI 获取决策
|
||||
var settings = WulaFallenEmpireMod.settings;
|
||||
if (settings == null || string.IsNullOrEmpty(settings.apiKey))
|
||||
{
|
||||
OnError?.Invoke("API Key 未配置");
|
||||
Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
string apiKey = settings.useGeminiProtocol ? settings.geminiApiKey : settings.apiKey;
|
||||
string baseUrl = settings.useGeminiProtocol ? settings.geminiBaseUrl : settings.baseUrl;
|
||||
string model = settings.useGeminiProtocol ? settings.geminiModel : settings.model;
|
||||
|
||||
var client = new SimpleAIClient(apiKey, baseUrl, model, settings.useGeminiProtocol);
|
||||
|
||||
string decision;
|
||||
string base64Image = null;
|
||||
|
||||
// 如果启用了视觉特性,则在决策前截图 (Autonomous Loop 默认认为是开启视觉即全自动,或者我们可以加逻辑判断,但暂时保持 VLM 开启即截图对于 Agent Loop 来说更合理,因为它需要时刻观察)
|
||||
// 实际上,Agent Loop 通常需要全视觉,所以我们这里只检查 enableVlmFeatures
|
||||
if (settings.enableVlmFeatures)
|
||||
{
|
||||
base64Image = ScreenCaptureUtility.CaptureScreenAsBase64();
|
||||
if (settings.showThinkingProcess)
|
||||
{
|
||||
Messages.Message("AI Agent: 正在通过视觉传感器分析实地情况...", MessageTypeDefOf.NeutralEvent);
|
||||
}
|
||||
}
|
||||
else if (settings.showThinkingProcess)
|
||||
{
|
||||
Messages.Message("AI Agent: 正在分析传感器遥测数据...", MessageTypeDefOf.NeutralEvent);
|
||||
}
|
||||
|
||||
// 直接调用 GetChatCompletionAsync (它已支持 multimodal 参数)
|
||||
var messages = new List<(string role, string message)>
|
||||
{
|
||||
("user", prompt)
|
||||
};
|
||||
decision = await client.GetChatCompletionAsync(GetAgentSystemPrompt(), messages, 512, 0.3f, base64Image: base64Image);
|
||||
|
||||
if (string.IsNullOrEmpty(decision))
|
||||
{
|
||||
WulaLog.Debug("[AgentLoop] Empty decision received");
|
||||
return;
|
||||
}
|
||||
|
||||
_decisionCount++;
|
||||
WulaLog.Debug($"[AgentLoop] Decision #{_decisionCount}: {decision.Substring(0, Math.Min(100, decision.Length))}...");
|
||||
|
||||
// 4. Act - 执行决策
|
||||
ExecuteDecision(decision);
|
||||
|
||||
// 5. 记录历史
|
||||
_actionHistory.Add($"[{_decisionCount}] {decision.Substring(0, Math.Min(50, decision.Length))}");
|
||||
if (_actionHistory.Count > 20)
|
||||
{
|
||||
_actionHistory.RemoveAt(0);
|
||||
}
|
||||
|
||||
OnDecisionMade?.Invoke(decision);
|
||||
|
||||
// 6. 检查是否完成目标
|
||||
if (decision.Contains("<objective_complete") || decision.Contains("目标已完成"))
|
||||
{
|
||||
OnObjectiveComplete?.Invoke(_currentObjective);
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[AgentLoop] Error in decision cycle: {ex.Message}");
|
||||
OnError?.Invoke(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildDecisionPrompt(string gameStateText)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("# 当前任务");
|
||||
sb.AppendLine($"**目标**: {_currentObjective}");
|
||||
sb.AppendLine($"**已执行决策次数**: {_decisionCount}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine(gameStateText);
|
||||
|
||||
if (_actionHistory.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("# 最近操作历史");
|
||||
foreach (var action in _actionHistory)
|
||||
{
|
||||
sb.AppendLine($"- {action}");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("# 请决定下一步操作");
|
||||
sb.AppendLine("分析当前状态,输出一个 XML 工具调用来推进目标。");
|
||||
sb.AppendLine("如果目标已完成,输出 <objective_complete/>。");
|
||||
sb.AppendLine("如果不需要操作(等待中),输出 <no_action/>。");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string GetAgentSystemPrompt()
|
||||
{
|
||||
return @"你是一个自主 RimWorld 游戏 AI Agent。你的任务是独立完成用户给出的开放式目标。
|
||||
|
||||
# 核心原则
|
||||
1. **自主决策**: 不要等待用户指示,主动分析情况并采取行动
|
||||
2. **循序渐进**: 每次只执行一个操作,观察结果后再决定下一步
|
||||
3. **问题应对**: 遇到障碍时自己想办法解决
|
||||
4. **目标导向**: 始终围绕目标推进,避免无关操作
|
||||
|
||||
# 可用工具
|
||||
- get_game_state: 获取详细游戏状态
|
||||
- designate_mine: <designate_mine><x>X坐标</x><z>Z坐标</z><radius>可选半径</radius></designate_mine> 标记采矿
|
||||
- draft_pawn: <draft_pawn><pawn_name>名字</pawn_name><draft>true/false</draft></draft_pawn> 征召殖民者
|
||||
- analyze_screen: <analyze_screen><context>分析目标</context></analyze_screen> 分析屏幕(需要VLM)
|
||||
- visual_click: <visual_click><x>0-1比例</x><y>0-1比例</y></visual_click> 模拟点击
|
||||
|
||||
# 输出格式
|
||||
直接输出一个 XML 工具调用,不要解释。
|
||||
如果目标已完成: <objective_complete/>
|
||||
如果需要等待: <no_action/>
|
||||
|
||||
# 注意事项
|
||||
- 坐标使用游戏内整数坐标,不是屏幕比例
|
||||
- 优先使用 API 工具(designate_mine 等),视觉工具用于 mod 内容
|
||||
- 保持简洁高效";
|
||||
}
|
||||
|
||||
private void ExecuteDecision(string decision)
|
||||
{
|
||||
// 解析并执行 AI 的决策
|
||||
// 从 AIIntelligenceCore 借用工具执行逻辑
|
||||
|
||||
var core = AIIntelligenceCore.Instance;
|
||||
if (core == null)
|
||||
{
|
||||
WulaLog.Debug("[AgentLoop] AIIntelligenceCore not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取工具调用并执行
|
||||
// 暂时使用简单的正则匹配,实际应整合 AIIntelligenceCore 的解析逻辑
|
||||
|
||||
if (decision.Contains("<no_action") || decision.Contains("<objective_complete"))
|
||||
{
|
||||
// 不需要执行
|
||||
return;
|
||||
}
|
||||
|
||||
// 委托给 AIIntelligenceCore 执行工具
|
||||
// TODO: 整合更完善的工具执行逻辑
|
||||
WulaLog.Debug($"[AgentLoop] Executing: {decision}");
|
||||
}
|
||||
|
||||
public override void ExposeData()
|
||||
{
|
||||
base.ExposeData();
|
||||
Scribe_Values.Look(ref _currentObjective, "agentObjective", "");
|
||||
Scribe_Values.Look(ref _isRunning, "agentRunning", false);
|
||||
Scribe_Values.Look(ref _decisionCount, "agentDecisionCount", 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using RimWorld;
|
||||
using RimWorld.Planet;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// 游戏状态快照 - 包含 AI 决策所需的所有游戏信息
|
||||
/// </summary>
|
||||
public class GameStateSnapshot
|
||||
{
|
||||
// 时间信息
|
||||
public int Hour;
|
||||
public string Season;
|
||||
public int DayOfQuadrum;
|
||||
public int Year;
|
||||
public float GameSpeedMultiplier;
|
||||
|
||||
// 殖民者信息
|
||||
public List<PawnSnapshot> Colonists = new List<PawnSnapshot>();
|
||||
public List<PawnSnapshot> Prisoners = new List<PawnSnapshot>();
|
||||
public List<PawnSnapshot> Animals = new List<PawnSnapshot>();
|
||||
|
||||
// 资源统计
|
||||
public Dictionary<string, int> Resources = new Dictionary<string, int>();
|
||||
|
||||
// 建筑和蓝图
|
||||
public int TotalBuildings;
|
||||
public int PendingBlueprints;
|
||||
public int ConstructionFrames;
|
||||
|
||||
// 威胁
|
||||
public List<ThreatSnapshot> Threats = new List<ThreatSnapshot>();
|
||||
|
||||
// 最近事件
|
||||
public List<string> RecentMessages = new List<string>();
|
||||
|
||||
// 地图信息
|
||||
public string BiomeName;
|
||||
public float OutdoorTemperature;
|
||||
public string Weather;
|
||||
|
||||
/// <summary>
|
||||
/// 生成给 VLM 的文本描述
|
||||
/// </summary>
|
||||
public string ToPromptText()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// 时间
|
||||
sb.AppendLine($"# 当前游戏状态");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"## 时间");
|
||||
sb.AppendLine($"- {Season},第 {DayOfQuadrum} 天,{Hour}:00");
|
||||
sb.AppendLine($"- 第 {Year} 年");
|
||||
sb.AppendLine();
|
||||
|
||||
// 环境
|
||||
sb.AppendLine($"## 环境");
|
||||
sb.AppendLine($"- 生物群系: {BiomeName}");
|
||||
sb.AppendLine($"- 室外温度: {OutdoorTemperature:F1}°C");
|
||||
sb.AppendLine($"- 天气: {Weather}");
|
||||
sb.AppendLine();
|
||||
|
||||
// 殖民者
|
||||
sb.AppendLine($"## 殖民者 ({Colonists.Count}人)");
|
||||
foreach (var c in Colonists.Take(10))
|
||||
{
|
||||
string status = c.CurrentJob ?? "空闲";
|
||||
string health = c.HealthPercent >= 0.9f ? "健康" :
|
||||
c.HealthPercent >= 0.5f ? "受伤" : "重伤";
|
||||
string mood = c.MoodPercent >= 0.7f ? "😊" :
|
||||
c.MoodPercent >= 0.4f ? "😐" : "😞";
|
||||
sb.AppendLine($"- {c.Name}: {health} {c.HealthPercent:P0}, 心情{mood} {c.MoodPercent:P0}, {status}");
|
||||
}
|
||||
if (Colonists.Count > 10)
|
||||
{
|
||||
sb.AppendLine($"- ... 还有 {Colonists.Count - 10} 人");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// 资源
|
||||
sb.AppendLine($"## 主要资源");
|
||||
var importantResources = new[] { "Silver", "Steel", "Plasteel", "ComponentIndustrial", "Gold", "MealSimple", "MealFine", "MedicineHerbal", "MedicineIndustrial", "WoodLog" };
|
||||
foreach (var res in importantResources)
|
||||
{
|
||||
if (Resources.TryGetValue(res, out int count) && count > 0)
|
||||
{
|
||||
string label = GetResourceLabel(res);
|
||||
sb.AppendLine($"- {label}: {count}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// 建造
|
||||
if (PendingBlueprints > 0 || ConstructionFrames > 0)
|
||||
{
|
||||
sb.AppendLine($"## 建造进度");
|
||||
if (PendingBlueprints > 0) sb.AppendLine($"- 待建蓝图: {PendingBlueprints}");
|
||||
if (ConstructionFrames > 0) sb.AppendLine($"- 建造中: {ConstructionFrames}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// 威胁
|
||||
if (Threats.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"## ⚠️ 威胁");
|
||||
foreach (var t in Threats)
|
||||
{
|
||||
sb.AppendLine($"- {t.Description} (距离: {t.Distance:F0})");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"## 威胁: 暂无");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// 最近消息
|
||||
if (RecentMessages.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"## 最近事件");
|
||||
foreach (var msg in RecentMessages.Skip(Math.Max(0, RecentMessages.Count - 5)))
|
||||
{
|
||||
sb.AppendLine($"- {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetResourceLabel(string defName)
|
||||
{
|
||||
return defName switch
|
||||
{
|
||||
"Silver" => "银",
|
||||
"Steel" => "钢铁",
|
||||
"Plasteel" => "玻璃钢",
|
||||
"ComponentIndustrial" => "零部件",
|
||||
"Gold" => "金",
|
||||
"MealSimple" => "简单食物",
|
||||
"MealFine" => "精致食物",
|
||||
"MedicineHerbal" => "草药",
|
||||
"MedicineIndustrial" => "医药",
|
||||
"WoodLog" => "木材",
|
||||
_ => defName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 殖民者/Pawn 快照
|
||||
/// </summary>
|
||||
public class PawnSnapshot
|
||||
{
|
||||
public string Name;
|
||||
public float HealthPercent;
|
||||
public float MoodPercent;
|
||||
public bool IsDrafted;
|
||||
public bool IsDowned;
|
||||
public string CurrentJob;
|
||||
public int X;
|
||||
public int Z;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 威胁快照
|
||||
/// </summary>
|
||||
public class ThreatSnapshot
|
||||
{
|
||||
public string Description;
|
||||
public float Distance;
|
||||
public int Count;
|
||||
public bool IsHostile;
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// 模拟鼠标输入工具 - 用于 VLM 视觉操作模式
|
||||
/// 支持移动鼠标、点击、拖拽等操作
|
||||
/// </summary>
|
||||
public static class MouseSimulator
|
||||
{
|
||||
// Windows API 导入
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetCursorPos(int X, int Y);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, int dwExtraInfo);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
// 鼠标事件标志
|
||||
private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
private const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
private const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
||||
private const uint MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
private const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||
|
||||
/// <summary>
|
||||
/// 将比例坐标 (0-1) 转换为屏幕坐标
|
||||
/// </summary>
|
||||
public static (int x, int y) ProportionalToScreen(float propX, float propY)
|
||||
{
|
||||
int screenWidth = Screen.width;
|
||||
int screenHeight = Screen.height;
|
||||
|
||||
int x = Mathf.Clamp(Mathf.RoundToInt(propX * screenWidth), 0, screenWidth - 1);
|
||||
int y = Mathf.Clamp(Mathf.RoundToInt(propY * screenHeight), 0, screenHeight - 1);
|
||||
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移动鼠标到指定屏幕坐标
|
||||
/// </summary>
|
||||
public static bool MoveTo(int screenX, int screenY)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Windows坐标系原点在左上角,与 VLM Agent 使用的坐标 convention 一致
|
||||
int windowsY = screenY;
|
||||
|
||||
return SetCursorPos(screenX, windowsY);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[MouseSimulator] MoveTo failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移动鼠标到比例坐标 (0-1)
|
||||
/// </summary>
|
||||
public static bool MoveToProportional(float propX, float propY)
|
||||
{
|
||||
var (x, y) = ProportionalToScreen(propX, propY);
|
||||
return MoveTo(x, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在当前位置执行左键点击
|
||||
/// </summary>
|
||||
public static void LeftClick()
|
||||
{
|
||||
try
|
||||
{
|
||||
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
|
||||
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[MouseSimulator] LeftClick failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在当前位置执行右键点击
|
||||
/// </summary>
|
||||
public static void RightClick()
|
||||
{
|
||||
try
|
||||
{
|
||||
mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0);
|
||||
mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[MouseSimulator] RightClick failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在指定屏幕坐标执行左键点击
|
||||
/// </summary>
|
||||
public static bool ClickAt(int screenX, int screenY, bool rightClick = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!MoveTo(screenX, screenY))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 短暂延迟确保鼠标位置更新
|
||||
System.Threading.Thread.Sleep(10);
|
||||
|
||||
if (rightClick)
|
||||
{
|
||||
RightClick();
|
||||
}
|
||||
else
|
||||
{
|
||||
LeftClick();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[MouseSimulator] ClickAt failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在比例坐标 (0-1) 执行点击
|
||||
/// </summary>
|
||||
public static bool ClickAtProportional(float propX, float propY, bool rightClick = false)
|
||||
{
|
||||
var (x, y) = ProportionalToScreen(propX, propY);
|
||||
return ClickAt(x, y, rightClick);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 滚动鼠标滚轮
|
||||
/// </summary>
|
||||
public static void Scroll(int delta)
|
||||
{
|
||||
try
|
||||
{
|
||||
// delta 为正向上滚动,为负向下滚动
|
||||
// Windows 使用 WHEEL_DELTA = 120 作为一个单位
|
||||
uint wheelDelta = (uint)(delta * 120);
|
||||
mouse_event(MOUSEEVENTF_WHEEL, 0, 0, wheelDelta, 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[MouseSimulator] Scroll failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行拖拽操作
|
||||
/// </summary>
|
||||
public static bool Drag(int startX, int startY, int endX, int endY, int durationMs = 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 移动到起始位置
|
||||
MoveTo(startX, startY);
|
||||
System.Threading.Thread.Sleep(20);
|
||||
|
||||
// 按下左键
|
||||
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
|
||||
|
||||
// 平滑移动到目标位置
|
||||
int steps = Math.Max(5, durationMs / 20);
|
||||
for (int i = 1; i <= steps; i++)
|
||||
{
|
||||
float t = (float)i / steps;
|
||||
int x = Mathf.RoundToInt(Mathf.Lerp(startX, endX, t));
|
||||
int y = Mathf.RoundToInt(Mathf.Lerp(startY, endY, t));
|
||||
MoveTo(x, y);
|
||||
System.Threading.Thread.Sleep(durationMs / steps);
|
||||
}
|
||||
|
||||
// 释放左键
|
||||
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[MouseSimulator] Drag failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前鼠标位置
|
||||
/// </summary>
|
||||
public static (int x, int y) GetCurrentPosition()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (GetCursorPos(out POINT point))
|
||||
{
|
||||
return (point.X, point.Y);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// Qwen-Omni-Realtime WebSocket 客户端
|
||||
/// 用于实时流式图像输入和文本输出
|
||||
/// </summary>
|
||||
public class OmniRealtimeClient : IDisposable
|
||||
{
|
||||
private ClientWebSocket _webSocket;
|
||||
private CancellationTokenSource _cancellationSource;
|
||||
private readonly string _apiKey;
|
||||
private readonly string _model;
|
||||
private bool _isConnected;
|
||||
private bool _isDisposed;
|
||||
|
||||
private readonly Queue<string> _pendingResponses = new Queue<string>();
|
||||
private readonly StringBuilder _currentResponse = new StringBuilder();
|
||||
|
||||
// 事件
|
||||
public event Action<string> OnTextDelta;
|
||||
public event Action<string> OnTextComplete;
|
||||
public event Action<string> OnError;
|
||||
public event Action OnConnected;
|
||||
public event Action OnDisconnected;
|
||||
|
||||
// WebSocket 端点
|
||||
private const string WS_ENDPOINT_CN = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime";
|
||||
private const string WS_ENDPOINT_INTL = "wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime";
|
||||
|
||||
public bool IsConnected => _isConnected && _webSocket?.State == WebSocketState.Open;
|
||||
|
||||
public OmniRealtimeClient(string apiKey, string model = "qwen3-omni-flash-realtime")
|
||||
{
|
||||
_apiKey = apiKey;
|
||||
_model = model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 建立 WebSocket 连接
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(bool useInternational = false)
|
||||
{
|
||||
if (_isConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
_webSocket = new ClientWebSocket();
|
||||
_cancellationSource = new CancellationTokenSource();
|
||||
|
||||
// 设置认证头
|
||||
_webSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
|
||||
|
||||
// 构建连接 URL
|
||||
string endpoint = useInternational ? WS_ENDPOINT_INTL : WS_ENDPOINT_CN;
|
||||
string url = $"{endpoint}?model={_model}";
|
||||
|
||||
WulaLog.Debug($"[OmniRealtime] Connecting to {url}");
|
||||
|
||||
await _webSocket.ConnectAsync(new Uri(url), _cancellationSource.Token);
|
||||
|
||||
if (_webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
_isConnected = true;
|
||||
WulaLog.Debug("[OmniRealtime] Connected successfully");
|
||||
|
||||
// 启动接收循环
|
||||
_ = ReceiveLoopAsync();
|
||||
|
||||
// 配置会话(仅文本输出,禁用 VAD)
|
||||
await ConfigureSessionAsync();
|
||||
|
||||
OnConnected?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Connection failed: {ex.Message}");
|
||||
OnError?.Invoke($"连接失败: {ex.Message}");
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置会话参数
|
||||
/// </summary>
|
||||
private async Task ConfigureSessionAsync()
|
||||
{
|
||||
var sessionConfig = new
|
||||
{
|
||||
event_id = GenerateEventId(),
|
||||
type = "session.update",
|
||||
session = new
|
||||
{
|
||||
// 仅输出文本(不需要音频)
|
||||
modalities = new[] { "text" },
|
||||
// 系统指令
|
||||
instructions = @"你是一个 RimWorld 游戏 AI 代理。你可以看到游戏屏幕截图。
|
||||
分析屏幕内容,识别重要元素(殖民者、资源、威胁、建筑等)。
|
||||
根据观察做出决策,输出 XML 格式的工具调用。
|
||||
可用工具: designate_mine, draft_pawn, visual_click, get_game_state 等。
|
||||
如果不需要操作,输出 <no_action/>。
|
||||
保持简洁,直接输出工具调用,不要解释。",
|
||||
// 禁用 VAD(手动模式,因为我们不使用音频)
|
||||
turn_detection = (object)null
|
||||
}
|
||||
};
|
||||
|
||||
await SendEventAsync(sessionConfig);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送图像到服务端
|
||||
/// </summary>
|
||||
public async Task SendImageAsync(string base64Image)
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
WulaLog.Debug("[OmniRealtime] Not connected, cannot send image");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 发送图像
|
||||
var imageEvent = new
|
||||
{
|
||||
event_id = GenerateEventId(),
|
||||
type = "input_image_buffer.append",
|
||||
image = base64Image
|
||||
};
|
||||
|
||||
await SendEventAsync(imageEvent);
|
||||
WulaLog.Debug($"[OmniRealtime] Sent image ({base64Image.Length} chars)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Failed to send image: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送文本消息并请求响应
|
||||
/// </summary>
|
||||
public async Task SendTextAndRequestResponseAsync(string text)
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
// 对于手动模式,需要发送 conversation.item.create 和 response.create
|
||||
var itemEvent = new
|
||||
{
|
||||
event_id = GenerateEventId(),
|
||||
type = "conversation.item.create",
|
||||
item = new
|
||||
{
|
||||
type = "message",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new { type = "input_text", text = text }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await SendEventAsync(itemEvent);
|
||||
|
||||
// 请求响应
|
||||
var responseEvent = new
|
||||
{
|
||||
event_id = GenerateEventId(),
|
||||
type = "response.create"
|
||||
};
|
||||
|
||||
await SendEventAsync(responseEvent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Failed to send text: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送图像并请求分析
|
||||
/// </summary>
|
||||
public async Task SendImageAndRequestAnalysisAsync(string base64Image, string prompt = "分析当前游戏画面,决定下一步操作")
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
// 先发送图像
|
||||
await SendImageAsync(base64Image);
|
||||
|
||||
// 提交输入并请求响应
|
||||
var commitEvent = new
|
||||
{
|
||||
event_id = GenerateEventId(),
|
||||
type = "input_audio_buffer.commit" // 这会同时提交图像缓冲区
|
||||
};
|
||||
await SendEventAsync(commitEvent);
|
||||
|
||||
// 发送文本提示
|
||||
await SendTextAndRequestResponseAsync(prompt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Failed to send image for analysis: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 接收消息循环
|
||||
/// </summary>
|
||||
private async Task ReceiveLoopAsync()
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
var messageBuffer = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
while (_webSocket?.State == WebSocketState.Open && !_cancellationSource.IsCancellationRequested)
|
||||
{
|
||||
var segment = new ArraySegment<byte>(buffer);
|
||||
var result = await _webSocket.ReceiveAsync(segment, _cancellationSource.Token);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
WulaLog.Debug("[OmniRealtime] Server closed connection");
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
string chunk = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
messageBuffer.Append(chunk);
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
ProcessServerEvent(messageBuffer.ToString());
|
||||
messageBuffer.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常取消
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Receive error: {ex.Message}");
|
||||
OnError?.Invoke($"接收错误: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isConnected = false;
|
||||
OnDisconnected?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理服务端事件
|
||||
/// </summary>
|
||||
private void ProcessServerEvent(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 简单解析 JSON 获取事件类型和内容
|
||||
// 注意:这里使用简单的字符串解析,生产环境应使用 JSON 库
|
||||
|
||||
string eventType = ExtractJsonValue(json, "type");
|
||||
|
||||
switch (eventType)
|
||||
{
|
||||
case "session.created":
|
||||
case "session.updated":
|
||||
WulaLog.Debug($"[OmniRealtime] Session event: {eventType}");
|
||||
break;
|
||||
|
||||
case "response.text.delta":
|
||||
string textDelta = ExtractJsonValue(json, "delta");
|
||||
if (!string.IsNullOrEmpty(textDelta))
|
||||
{
|
||||
_currentResponse.Append(textDelta);
|
||||
OnTextDelta?.Invoke(textDelta);
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.text.done":
|
||||
string completeText = _currentResponse.ToString();
|
||||
_currentResponse.Clear();
|
||||
OnTextComplete?.Invoke(completeText);
|
||||
WulaLog.Debug($"[OmniRealtime] Response complete: {completeText.Substring(0, Math.Min(100, completeText.Length))}...");
|
||||
break;
|
||||
|
||||
case "response.audio_transcript.delta":
|
||||
// 音频转录的文本增量(如果启用了音频输出)
|
||||
string transcriptDelta = ExtractJsonValue(json, "delta");
|
||||
if (!string.IsNullOrEmpty(transcriptDelta))
|
||||
{
|
||||
_currentResponse.Append(transcriptDelta);
|
||||
OnTextDelta?.Invoke(transcriptDelta);
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.audio_transcript.done":
|
||||
string transcript = _currentResponse.ToString();
|
||||
_currentResponse.Clear();
|
||||
OnTextComplete?.Invoke(transcript);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
string errorMsg = ExtractJsonValue(json, "message") ?? json;
|
||||
WulaLog.Debug($"[OmniRealtime] Error: {errorMsg}");
|
||||
OnError?.Invoke(errorMsg);
|
||||
break;
|
||||
|
||||
case "response.done":
|
||||
// 响应完成
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他事件
|
||||
if (Prefs.DevMode)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Event: {eventType}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Failed to process event: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendEventAsync(object eventObj)
|
||||
{
|
||||
if (_webSocket?.State != WebSocketState.Open) return;
|
||||
|
||||
string json = ToSimpleJson(eventObj);
|
||||
byte[] data = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
await _webSocket.SendAsync(
|
||||
new ArraySegment<byte>(data),
|
||||
WebSocketMessageType.Text,
|
||||
true,
|
||||
_cancellationSource.Token
|
||||
);
|
||||
}
|
||||
|
||||
private static string GenerateEventId()
|
||||
{
|
||||
return $"event_{Guid.NewGuid():N}".Substring(0, 24);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 简单的对象转 JSON(避免依赖外部库)
|
||||
/// </summary>
|
||||
private static string ToSimpleJson(object obj)
|
||||
{
|
||||
// 对于复杂对象,建议使用 Newtonsoft.Json 或 System.Text.Json
|
||||
// 这里使用简化实现
|
||||
var sb = new StringBuilder();
|
||||
SerializeObject(sb, obj);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void SerializeObject(StringBuilder sb, object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return;
|
||||
}
|
||||
|
||||
var type = obj.GetType();
|
||||
|
||||
if (obj is string str)
|
||||
{
|
||||
sb.Append('"');
|
||||
sb.Append(str.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"));
|
||||
sb.Append('"');
|
||||
}
|
||||
else if (obj is bool b)
|
||||
{
|
||||
sb.Append(b ? "true" : "false");
|
||||
}
|
||||
else if (obj is int || obj is long || obj is float || obj is double)
|
||||
{
|
||||
sb.Append(obj.ToString());
|
||||
}
|
||||
else if (type.IsArray)
|
||||
{
|
||||
sb.Append('[');
|
||||
var arr = (Array)obj;
|
||||
for (int i = 0; i < arr.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
SerializeObject(sb, arr.GetValue(i));
|
||||
}
|
||||
sb.Append(']');
|
||||
}
|
||||
else if (type.IsClass)
|
||||
{
|
||||
sb.Append('{');
|
||||
bool first = true;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
var value = prop.GetValue(obj);
|
||||
if (value == null) continue;
|
||||
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
|
||||
sb.Append('"');
|
||||
sb.Append(prop.Name);
|
||||
sb.Append("\":");
|
||||
SerializeObject(sb, value);
|
||||
}
|
||||
// 匿名类型使用字段
|
||||
foreach (var field in type.GetFields())
|
||||
{
|
||||
var value = field.GetValue(obj);
|
||||
if (value == null) continue;
|
||||
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
|
||||
sb.Append('"');
|
||||
sb.Append(field.Name);
|
||||
sb.Append("\":");
|
||||
SerializeObject(sb, value);
|
||||
}
|
||||
sb.Append('}');
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractJsonValue(string json, string key)
|
||||
{
|
||||
// 简单提取 JSON 值
|
||||
string pattern = $"\"{key}\":";
|
||||
int idx = json.IndexOf(pattern);
|
||||
if (idx < 0) return null;
|
||||
|
||||
idx += pattern.Length;
|
||||
while (idx < json.Length && char.IsWhiteSpace(json[idx])) idx++;
|
||||
|
||||
if (idx >= json.Length) return null;
|
||||
|
||||
if (json[idx] == '"')
|
||||
{
|
||||
// 字符串值
|
||||
idx++;
|
||||
int end = idx;
|
||||
while (end < json.Length && json[end] != '"')
|
||||
{
|
||||
if (json[end] == '\\') end++; // 跳过转义字符
|
||||
end++;
|
||||
}
|
||||
return json.Substring(idx, end - idx).Replace("\\n", "\n").Replace("\\\"", "\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 其他值
|
||||
int end = idx;
|
||||
while (end < json.Length && json[end] != ',' && json[end] != '}' && json[end] != ']')
|
||||
{
|
||||
end++;
|
||||
}
|
||||
return json.Substring(idx, end - idx).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断开连接
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (!_isConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
_cancellationSource?.Cancel();
|
||||
|
||||
if (_webSocket?.State == WebSocketState.Open)
|
||||
{
|
||||
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnect", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[OmniRealtime] Disconnect error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isConnected = false;
|
||||
_webSocket?.Dispose();
|
||||
_webSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
|
||||
_cancellationSource?.Cancel();
|
||||
_cancellationSource?.Dispose();
|
||||
_webSocket?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using RimWorld;
|
||||
using RimWorld.Planet;
|
||||
using Verse;
|
||||
using Verse.AI;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// 游戏状态观察器 - 收集当前游戏状态用于 AI 决策
|
||||
/// </summary>
|
||||
public static class StateObserver
|
||||
{
|
||||
/// <summary>
|
||||
/// 捕获当前游戏状态快照
|
||||
/// </summary>
|
||||
public static GameStateSnapshot CaptureState()
|
||||
{
|
||||
var snapshot = new GameStateSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
Map map = Find.CurrentMap ?? Find.Maps?.FirstOrDefault();
|
||||
if (map == null)
|
||||
{
|
||||
WulaLog.Debug("[StateObserver] No map available");
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// 时间信息
|
||||
CaptureTimeInfo(snapshot);
|
||||
|
||||
// 环境信息
|
||||
CaptureEnvironmentInfo(snapshot, map);
|
||||
|
||||
// 殖民者信息
|
||||
CaptureColonistInfo(snapshot, map);
|
||||
|
||||
// 资源统计
|
||||
CaptureResourceInfo(snapshot, map);
|
||||
|
||||
// 建筑信息
|
||||
CaptureBuildingInfo(snapshot, map);
|
||||
|
||||
// 威胁检测
|
||||
CaptureThreatInfo(snapshot, map);
|
||||
|
||||
// 最近消息
|
||||
CaptureRecentMessages(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing state: {ex.Message}");
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void CaptureTimeInfo(GameStateSnapshot snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tickManager = Find.TickManager;
|
||||
if (tickManager == null) return;
|
||||
|
||||
int ticksAbs = Find.TickManager.TicksAbs;
|
||||
int tile = Find.CurrentMap?.Tile ?? 0;
|
||||
float longitude = Find.WorldGrid.LongLatOf(tile).x;
|
||||
snapshot.Hour = GenDate.HourOfDay(ticksAbs, longitude);
|
||||
snapshot.DayOfQuadrum = GenDate.DayOfQuadrum(ticksAbs, longitude) + 1;
|
||||
snapshot.Year = GenDate.Year(ticksAbs, longitude);
|
||||
snapshot.Season = GenDate.Season(ticksAbs, Find.WorldGrid.LongLatOf(tile)).Label();
|
||||
snapshot.GameSpeedMultiplier = Find.TickManager.TickRateMultiplier;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing time: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CaptureEnvironmentInfo(GameStateSnapshot snapshot, Map map)
|
||||
{
|
||||
try
|
||||
{
|
||||
snapshot.BiomeName = map.Biome?.label ?? "Unknown";
|
||||
snapshot.OutdoorTemperature = map.mapTemperature?.OutdoorTemp ?? 20f;
|
||||
snapshot.Weather = map.weatherManager?.curWeather?.label ?? "Unknown";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing environment: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CaptureColonistInfo(GameStateSnapshot snapshot, Map map)
|
||||
{
|
||||
try
|
||||
{
|
||||
var colonists = map.mapPawns?.FreeColonists;
|
||||
if (colonists == null) return;
|
||||
|
||||
foreach (var pawn in colonists)
|
||||
{
|
||||
if (pawn == null || pawn.Dead) continue;
|
||||
|
||||
var pawnSnapshot = new PawnSnapshot
|
||||
{
|
||||
Name = pawn.LabelShortCap,
|
||||
HealthPercent = pawn.health?.summaryHealth?.SummaryHealthPercent ?? 1f,
|
||||
MoodPercent = pawn.needs?.mood?.CurLevelPercentage ?? 0.5f,
|
||||
IsDrafted = pawn.Drafted,
|
||||
IsDowned = pawn.Downed,
|
||||
CurrentJob = GetJobDescription(pawn),
|
||||
X = pawn.Position.x,
|
||||
Z = pawn.Position.z
|
||||
};
|
||||
|
||||
snapshot.Colonists.Add(pawnSnapshot);
|
||||
}
|
||||
|
||||
// 囚犯
|
||||
var prisoners = map.mapPawns?.PrisonersOfColony;
|
||||
if (prisoners != null)
|
||||
{
|
||||
foreach (var pawn in prisoners)
|
||||
{
|
||||
if (pawn == null || pawn.Dead) continue;
|
||||
snapshot.Prisoners.Add(new PawnSnapshot
|
||||
{
|
||||
Name = pawn.LabelShortCap,
|
||||
HealthPercent = pawn.health?.summaryHealth?.SummaryHealthPercent ?? 1f,
|
||||
IsDowned = pawn.Downed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 驯养动物
|
||||
var animals = map.mapPawns?.SpawnedColonyAnimals;
|
||||
if (animals != null)
|
||||
{
|
||||
snapshot.Animals.AddRange(animals.Take(20).Select(a => new PawnSnapshot
|
||||
{
|
||||
Name = a.LabelShortCap,
|
||||
HealthPercent = a.health?.summaryHealth?.SummaryHealthPercent ?? 1f
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing colonists: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetJobDescription(Pawn pawn)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (pawn.Drafted) return "已征召";
|
||||
if (pawn.Downed) return "倒地";
|
||||
if (pawn.InMentalState) return $"精神崩溃: {pawn.MentalStateDef?.label ?? "未知"}";
|
||||
|
||||
var job = pawn.CurJob;
|
||||
if (job == null) return "空闲";
|
||||
|
||||
// 返回简短的工作描述
|
||||
string jobLabel = job.def?.reportString ?? job.def?.label ?? "工作中";
|
||||
|
||||
// 清理一些常见的格式
|
||||
if (job.targetA.Thing != null)
|
||||
{
|
||||
jobLabel = $"{job.def?.label}: {job.targetA.Thing.LabelShortCap}";
|
||||
}
|
||||
else
|
||||
{
|
||||
jobLabel = job.def?.label ?? "工作中";
|
||||
}
|
||||
|
||||
return jobLabel;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
private static void CaptureResourceInfo(GameStateSnapshot snapshot, Map map)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resourceCounter = map.resourceCounter;
|
||||
if (resourceCounter == null) return;
|
||||
|
||||
foreach (var kvp in resourceCounter.AllCountedAmounts)
|
||||
{
|
||||
if (kvp.Value > 0)
|
||||
{
|
||||
snapshot.Resources[kvp.Key.defName] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing resources: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CaptureBuildingInfo(GameStateSnapshot snapshot, Map map)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 统计建筑
|
||||
snapshot.TotalBuildings = map.listerBuildings?.allBuildingsColonist?.Count ?? 0;
|
||||
|
||||
// 统计蓝图
|
||||
snapshot.PendingBlueprints = map.listerThings?.ThingsInGroup(ThingRequestGroup.Blueprint)?.Count ?? 0;
|
||||
|
||||
// 统计建造中的框架
|
||||
snapshot.ConstructionFrames = map.listerThings?.ThingsInGroup(ThingRequestGroup.BuildingFrame)?.Count ?? 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing buildings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CaptureThreatInfo(GameStateSnapshot snapshot, Map map)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检测敌对 pawns
|
||||
var hostilePawns = map.mapPawns?.AllPawnsSpawned?
|
||||
.Where(p => p != null && !p.Dead && p.HostileTo(Faction.OfPlayer))
|
||||
.ToList();
|
||||
|
||||
if (hostilePawns == null || hostilePawns.Count == 0) return;
|
||||
|
||||
// 按派系分组
|
||||
var threatGroups = hostilePawns.GroupBy(p => p.Faction?.Name ?? "野生动物");
|
||||
|
||||
foreach (var group in threatGroups)
|
||||
{
|
||||
IntVec3 colonyCenter = map.IsPlayerHome ? map.mapPawns.FreeColonists.FirstOrDefault()?.Position ?? IntVec3.Zero : IntVec3.Zero;
|
||||
float minDistance = group.Min(p => (p.Position - colonyCenter).LengthHorizontal);
|
||||
|
||||
snapshot.Threats.Add(new ThreatSnapshot
|
||||
{
|
||||
Description = $"{group.Count()} 个 {group.Key}",
|
||||
Count = group.Count(),
|
||||
Distance = minDistance,
|
||||
IsHostile = true
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing threats: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CaptureRecentMessages(GameStateSnapshot snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = Find.Archive?.ArchivablesListForReading;
|
||||
if (messages == null) return;
|
||||
|
||||
// 获取最近 5 条消息
|
||||
var allMessages = messages.OfType<Message>().ToList();
|
||||
var recentMessages = allMessages
|
||||
.Skip(Math.Max(0, allMessages.Count - 5))
|
||||
.Select(m => m.text?.ToString() ?? "")
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
|
||||
snapshot.RecentMessages.AddRange(recentMessages);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[StateObserver] Error capturing messages: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// 纯视觉交互工具集 - 仿照 Python VLM Agent
|
||||
/// 当没有原生 API 可用时,AI 可以通过这些工具操作任何界面
|
||||
/// </summary>
|
||||
public static class VisualInteractionTools
|
||||
{
|
||||
// Windows API
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetCursorPos(int X, int Y);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, int dwExtraInfo);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, int dwExtraInfo);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern short VkKeyScan(char ch);
|
||||
|
||||
// 鼠标事件标志
|
||||
private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
private const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
private const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
||||
private const uint MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
private const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||
|
||||
// 键盘事件标志
|
||||
private const uint KEYEVENTF_KEYDOWN = 0x0000;
|
||||
private const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
|
||||
// 虚拟键码
|
||||
private const byte VK_CONTROL = 0x11;
|
||||
private const byte VK_SHIFT = 0x10;
|
||||
private const byte VK_ALT = 0x12;
|
||||
private const byte VK_RETURN = 0x0D;
|
||||
private const byte VK_BACK = 0x08;
|
||||
private const byte VK_ESCAPE = 0x1B;
|
||||
private const byte VK_TAB = 0x09;
|
||||
private const byte VK_LWIN = 0x5B;
|
||||
private const byte VK_F4 = 0x73;
|
||||
|
||||
/// <summary>
|
||||
/// 1. 鼠标点击 - 在比例坐标处点击
|
||||
/// </summary>
|
||||
public static string MouseClick(float x, float y, string button = "left", int clicks = 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
int screenX = Mathf.RoundToInt(x * Screen.width);
|
||||
int windowsY = Mathf.RoundToInt(y * Screen.height);
|
||||
|
||||
SetCursorPos(screenX, windowsY);
|
||||
Thread.Sleep(20);
|
||||
|
||||
for (int i = 0; i < clicks; i++)
|
||||
{
|
||||
if (button == "right")
|
||||
{
|
||||
mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0);
|
||||
mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
|
||||
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
|
||||
}
|
||||
if (i < clicks - 1) Thread.Sleep(50);
|
||||
}
|
||||
|
||||
string buttonText = button == "right" ? "右键" : "左键";
|
||||
string clickText = clicks == 2 ? "双击" : "单击";
|
||||
return $"Success: 在 ({screenX}, {windowsY}) 处{buttonText}{clickText}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 点击失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2. 输入文本 - 在指定位置点击后输入文本(通过剪贴板)
|
||||
/// </summary>
|
||||
public static string TypeText(float x, float y, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 先点击
|
||||
MouseClick(x, y);
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 通过剪贴板输入
|
||||
GUIUtility.systemCopyBuffer = text;
|
||||
Thread.Sleep(50);
|
||||
|
||||
// Ctrl+V 粘贴
|
||||
keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
keybd_event(0x56, 0, KEYEVENTF_KEYDOWN, 0); // V
|
||||
keybd_event(0x56, 0, KEYEVENTF_KEYUP, 0);
|
||||
keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0);
|
||||
|
||||
return $"Success: 在 ({x:F3}, {y:F3}) 处输入文本: {text}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 输入文本失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3. 滚动窗口 - 在指定位置滚动
|
||||
/// </summary>
|
||||
public static string ScrollWindow(float x, float y, string direction = "up", int amount = 3)
|
||||
{
|
||||
try
|
||||
{
|
||||
int screenX = Mathf.RoundToInt(x * Screen.width);
|
||||
int windowsY = Mathf.RoundToInt(y * Screen.height);
|
||||
|
||||
SetCursorPos(screenX, windowsY);
|
||||
Thread.Sleep(20);
|
||||
|
||||
int wheelDelta = (direction == "up" ? 1 : -1) * 120 * amount;
|
||||
mouse_event(MOUSEEVENTF_WHEEL, 0, 0, (uint)wheelDelta, 0);
|
||||
|
||||
string dir = direction == "up" ? "向上" : "向下";
|
||||
return $"Success: 在 ({screenX}, {windowsY}) 处{dir}滚动 {amount} 步";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 滚动失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 4. 鼠标拖拽 - 从起点拖到终点
|
||||
/// </summary>
|
||||
public static string MouseDrag(float startX, float startY, float endX, float endY, float durationSec = 0.5f)
|
||||
{
|
||||
try
|
||||
{
|
||||
int sx = Mathf.RoundToInt(startX * Screen.width);
|
||||
int sy = Mathf.RoundToInt(startY * Screen.height);
|
||||
int ex = Mathf.RoundToInt(endX * Screen.width);
|
||||
int ey = Mathf.RoundToInt(endY * Screen.height);
|
||||
|
||||
// 移动到起点
|
||||
SetCursorPos(sx, sy);
|
||||
Thread.Sleep(50);
|
||||
|
||||
// 按下
|
||||
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
|
||||
|
||||
// 平滑移动
|
||||
int steps = Mathf.Max(5, Mathf.RoundToInt(durationSec * 20));
|
||||
int delayMs = Mathf.RoundToInt(durationSec * 1000 / steps);
|
||||
|
||||
for (int i = 1; i <= steps; i++)
|
||||
{
|
||||
float t = (float)i / steps;
|
||||
int cx = Mathf.RoundToInt(Mathf.Lerp(sx, ex, t));
|
||||
int cy = Mathf.RoundToInt(Mathf.Lerp(sy, ey, t));
|
||||
SetCursorPos(cx, cy);
|
||||
Thread.Sleep(delayMs);
|
||||
}
|
||||
|
||||
// 释放
|
||||
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
|
||||
|
||||
return $"Success: 从 ({startX:F3}, {startY:F3}) 拖拽到 ({endX:F3}, {endY:F3})";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 拖拽失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 5. 等待 - 暂停指定秒数
|
||||
/// </summary>
|
||||
public static string Wait(float seconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.Sleep(Mathf.RoundToInt(seconds * 1000));
|
||||
return $"Success: 等待了 {seconds} 秒";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 等待失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 6. 按下回车键
|
||||
/// </summary>
|
||||
public static string PressEnter()
|
||||
{
|
||||
try
|
||||
{
|
||||
keybd_event(VK_RETURN, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
keybd_event(VK_RETURN, 0, KEYEVENTF_KEYUP, 0);
|
||||
return "Success: 按下回车键";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 按键失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 7. 按下 Escape 键
|
||||
/// </summary>
|
||||
public static string PressEscape()
|
||||
{
|
||||
try
|
||||
{
|
||||
keybd_event(VK_ESCAPE, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
keybd_event(VK_ESCAPE, 0, KEYEVENTF_KEYUP, 0);
|
||||
return "Success: 按下 Escape 键";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 按键失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 8. 删除文本 - 按 Backspace 删除指定数量字符
|
||||
/// </summary>
|
||||
public static string DeleteText(float x, float y, int count = 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
MouseClick(x, y);
|
||||
Thread.Sleep(100);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
keybd_event(VK_BACK, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
keybd_event(VK_BACK, 0, KEYEVENTF_KEYUP, 0);
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
|
||||
return $"Success: 删除了 {count} 个字符";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 删除失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 9. 执行快捷键 - 如 Ctrl+C, Alt+F4 等
|
||||
/// </summary>
|
||||
public static string PressHotkey(float x, float y, string hotkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 先点击获取焦点
|
||||
MouseClick(x, y);
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 解析快捷键
|
||||
var keys = hotkey.ToLowerInvariant().Replace("+", " ").Replace("-", " ").Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// 按下修饰键
|
||||
foreach (var key in keys)
|
||||
{
|
||||
byte vk = GetVirtualKeyCode(key);
|
||||
if (vk != 0)
|
||||
{
|
||||
keybd_event(vk, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
|
||||
// 释放修饰键(逆序)
|
||||
for (int i = keys.Length - 1; i >= 0; i--)
|
||||
{
|
||||
byte vk = GetVirtualKeyCode(keys[i]);
|
||||
if (vk != 0)
|
||||
{
|
||||
keybd_event(vk, 0, KEYEVENTF_KEYUP, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $"Success: 执行快捷键 {hotkey}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 快捷键失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 10. 关闭窗口 - Alt+F4
|
||||
/// </summary>
|
||||
public static string CloseWindow(float x, float y)
|
||||
{
|
||||
try
|
||||
{
|
||||
MouseClick(x, y);
|
||||
Thread.Sleep(100);
|
||||
|
||||
keybd_event(VK_ALT, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
keybd_event(VK_F4, 0, KEYEVENTF_KEYDOWN, 0);
|
||||
keybd_event(VK_F4, 0, KEYEVENTF_KEYUP, 0);
|
||||
keybd_event(VK_ALT, 0, KEYEVENTF_KEYUP, 0);
|
||||
|
||||
return "Success: 关闭窗口";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: 关闭窗口失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static byte GetVirtualKeyCode(string keyName)
|
||||
{
|
||||
return keyName.ToLowerInvariant() switch
|
||||
{
|
||||
"ctrl" or "control" => VK_CONTROL,
|
||||
"shift" => VK_SHIFT,
|
||||
"alt" => VK_ALT,
|
||||
"enter" or "return" => VK_RETURN,
|
||||
"esc" or "escape" => VK_ESCAPE,
|
||||
"tab" => VK_TAB,
|
||||
"backspace" or "back" => VK_BACK,
|
||||
"win" or "windows" => VK_LWIN,
|
||||
"f4" => VK_F4,
|
||||
// 字母键
|
||||
"a" => 0x41, "b" => 0x42, "c" => 0x43, "d" => 0x44, "e" => 0x45,
|
||||
"f" => 0x46, "g" => 0x47, "h" => 0x48, "i" => 0x49, "j" => 0x4A,
|
||||
"k" => 0x4B, "l" => 0x4C, "m" => 0x4D, "n" => 0x4E, "o" => 0x4F,
|
||||
"p" => 0x50, "q" => 0x51, "r" => 0x52, "s" => 0x53, "t" => 0x54,
|
||||
"u" => 0x55, "v" => 0x56, "w" => 0x57, "x" => 0x58, "y" => 0x59,
|
||||
"z" => 0x5A,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ using System.Text;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
using WulaFallenEmpire;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
{
|
||||
public class Tool_CallBombardment : AITool
|
||||
{
|
||||
public override string Name => "call_bombardment";
|
||||
public override string Description => "Calls orbital bombardment support at a specified map coordinate using an AbilityDef's bombardment configuration (e.g., WULA_Firepower_Cannon_Salvo).";
|
||||
public override string UsageSchema => "<call_bombardment><abilityDef>string (optional, default WULA_Firepower_Cannon_Salvo)</abilityDef><x>int</x><z>int</z><cell>x,z (optional)</cell><filterFriendlyFire>true/false (optional, default true)</filterFriendlyFire></call_bombardment>";
|
||||
public override string Description => "Calls orbital bombardment/support using an AbilityDef configuration (e.g., WULA_Firepower_Cannon_Salvo, WULA_Firepower_EnergyLance_Strafe). Supports Circular Bombardment, Strafe, Energy Lance, and Surveillance.";
|
||||
public override string UsageSchema => "<call_bombardment><abilityDef>string</abilityDef><x>int</x><z>int</z><cell>x,z</cell><direction>x,z (optional)</direction><angle>degrees (optional)</angle><filterFriendlyFire>true/false</filterFriendlyFire></call_bombardment>";
|
||||
|
||||
public override string Execute(string args)
|
||||
{
|
||||
@@ -36,31 +37,21 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
AbilityDef abilityDef = DefDatabase<AbilityDef>.GetNamed(abilityDefName, false);
|
||||
if (abilityDef == null) return $"Error: AbilityDef '{abilityDefName}' not found.";
|
||||
|
||||
var bombardmentProps = abilityDef.comps?.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||
if (bombardmentProps == null) return $"Error: AbilityDef '{abilityDefName}' has no CompProperties_AbilityCircularBombardment.";
|
||||
if (bombardmentProps.skyfallerDef == null) return $"Error: AbilityDef '{abilityDefName}' has no skyfallerDef configured.";
|
||||
// Switch logic based on AbilityDef components
|
||||
var circular = abilityDef.comps?.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||
if (circular != null) return ExecuteCircularBombardment(map, targetCell, abilityDef, circular, parsed);
|
||||
|
||||
bool filterFriendlyFire = true;
|
||||
if (parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff))
|
||||
{
|
||||
filterFriendlyFire = ff;
|
||||
}
|
||||
var bombard = abilityDef.comps?.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||
if (bombard != null) return ExecuteStrafeBombardment(map, targetCell, abilityDef, bombard, parsed);
|
||||
|
||||
List<IntVec3> selectedTargets = SelectTargetCells(map, targetCell, bombardmentProps, filterFriendlyFire);
|
||||
if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
|
||||
var lance = abilityDef.comps?.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||
if (lance != null) return ExecuteEnergyLance(map, targetCell, abilityDef, lance, parsed);
|
||||
|
||||
bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
|
||||
int totalLaunches = ScheduleBombardment(map, selectedTargets, bombardmentProps, spawnImmediately: isPaused);
|
||||
var skyfaller = abilityDef.comps?.OfType<CompProperties_AbilityCallSkyfaller>().FirstOrDefault();
|
||||
if (skyfaller != null) return ExecuteCallSkyfaller(map, targetCell, abilityDef, skyfaller);
|
||||
|
||||
return $"Error: AbilityDef '{abilityDefName}' is not a supported bombardment/support type.";
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine("Success: Bombardment scheduled.");
|
||||
sb.AppendLine($"- abilityDef: {abilityDefName}");
|
||||
sb.AppendLine($"- center: {targetCell}");
|
||||
sb.AppendLine($"- skyfallerDef: {bombardmentProps.skyfallerDef.defName}");
|
||||
sb.AppendLine($"- launches: {totalLaunches}/{bombardmentProps.maxLaunches}");
|
||||
sb.AppendLine($"- mode: {(isPaused ? "spawned immediately (game paused)" : "delayed schedule")}");
|
||||
sb.AppendLine("- prereqs: ignored (facility/cooldown/non-hostility/research)");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -68,6 +59,39 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
}
|
||||
}
|
||||
|
||||
// Shared helper for determining direction and end point
|
||||
private void ParseDirectionInfo(Dictionary<string, string> parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos)
|
||||
{
|
||||
direction = Vector3.forward;
|
||||
endPos = startPos;
|
||||
|
||||
if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
|
||||
{
|
||||
direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
|
||||
endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
|
||||
}
|
||||
else if (TryParseDirectionCell(parsed, out IntVec3 dirCell))
|
||||
{
|
||||
// direction towards dirCell
|
||||
direction = (dirCell.ToVector3() - startPos.ToVector3()).normalized;
|
||||
if (direction == Vector3.zero) direction = Vector3.forward;
|
||||
|
||||
if (useFixedDistance)
|
||||
{
|
||||
endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
|
||||
}
|
||||
else
|
||||
{
|
||||
endPos = dirCell;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default North
|
||||
endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseTargetCell(Dictionary<string, string> parsed, out IntVec3 cell)
|
||||
{
|
||||
cell = IntVec3.Invalid;
|
||||
@@ -211,6 +235,282 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
return launchesCompleted;
|
||||
}
|
||||
|
||||
private string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary<string, string> parsed)
|
||||
{
|
||||
// Determine EnergyLanceDef
|
||||
ThingDef lanceDef = props.energyLanceDef ?? DefDatabase<ThingDef>.GetNamedSilentFail("EnergyLance");
|
||||
if (lanceDef == null) return $"Error: Could not resolve EnergyLance ThingDef for '{def.defName}'.";
|
||||
|
||||
// Determine Start and End positions
|
||||
// For AI usage, 'targetCell' is primarily the START position (focus point),
|
||||
// but we need a direction to make it move effectively.
|
||||
|
||||
IntVec3 startPos = targetCell;
|
||||
IntVec3 endPos = targetCell; // Default if no direction
|
||||
|
||||
// Determine direction/end position
|
||||
Vector3 direction = Vector3.forward;
|
||||
if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
|
||||
{
|
||||
direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
|
||||
endPos = (startPos.ToVector3() + direction * props.moveDistance).ToIntVec3();
|
||||
}
|
||||
else if (TryParseDirectionCell(parsed, out IntVec3 dirCell))
|
||||
{
|
||||
// If a specific cell is given for direction, that acts as the "target/end" point or direction vector
|
||||
direction = (dirCell.ToVector3() - startPos.ToVector3()).normalized;
|
||||
if (direction == Vector3.zero) direction = Vector3.forward;
|
||||
|
||||
// If using fixed distance, calculate end based on direction and distance
|
||||
if (props.useFixedDistance)
|
||||
{
|
||||
endPos = (startPos.ToVector3() + direction * props.moveDistance).ToIntVec3();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, move TO the specific cell
|
||||
endPos = dirCell;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default direction (North) if none specified, moving props.moveDistance
|
||||
endPos = (startPos.ToVector3() + Vector3.forward * props.moveDistance).ToIntVec3();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnergyLance.MakeEnergyLance(
|
||||
lanceDef,
|
||||
startPos,
|
||||
endPos,
|
||||
map,
|
||||
props.moveDistance,
|
||||
props.useFixedDistance,
|
||||
props.durationTicks,
|
||||
null // No specific pawn instigator available for AI calls
|
||||
);
|
||||
|
||||
return $"Success: Triggered Energy Lance '{def.defName}' from {startPos} towards {endPos}. Type: {lanceDef.defName}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: Failed to spawn EnergyLance: {ex.Message}";
|
||||
}
|
||||
}
|
||||
private string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary<string, string> parsed)
|
||||
{
|
||||
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
|
||||
|
||||
bool filter = true;
|
||||
if (parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff)) filter = ff;
|
||||
|
||||
List<IntVec3> selectedTargets = SelectTargetCells(map, targetCell, props, filter);
|
||||
if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
|
||||
|
||||
bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
|
||||
int totalLaunches = ScheduleBombardment(map, selectedTargets, props, spawnImmediately: isPaused);
|
||||
|
||||
return $"Success: Scheduled Circular Bombardment '{def.defName}' at {targetCell}. Launches: {totalLaunches}/{props.maxLaunches}.";
|
||||
}
|
||||
|
||||
private string ExecuteCallSkyfaller(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCallSkyfaller props)
|
||||
{
|
||||
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
|
||||
|
||||
var delayed = map.GetComponent<MapComponent_SkyfallerDelayed>();
|
||||
if (delayed == null)
|
||||
{
|
||||
delayed = new MapComponent_SkyfallerDelayed(map);
|
||||
map.components.Add(delayed);
|
||||
}
|
||||
|
||||
// Using the delay from props
|
||||
int delay = props.delayTicks;
|
||||
if (delay <= 0)
|
||||
{
|
||||
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
|
||||
GenSpawn.Spawn(skyfaller, targetCell, map);
|
||||
return $"Success: Spawned Skyfaller '{def.defName}' immediately at {targetCell}.";
|
||||
}
|
||||
else
|
||||
{
|
||||
delayed.ScheduleSkyfaller(props.skyfallerDef, targetCell, delay);
|
||||
return $"Success: Scheduled Skyfaller '{def.defName}' at {targetCell} in {delay} ticks.";
|
||||
}
|
||||
}
|
||||
|
||||
private string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary<string, string> parsed)
|
||||
{
|
||||
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
|
||||
|
||||
// Determine direction
|
||||
// Use shared helper - though Strafe uses width/length, the direction logic is same.
|
||||
// Strafe doesn't really have a "moveDistance" in the same way, but it aligns along direction.
|
||||
// We use dummy distance for calculation.
|
||||
ParseDirectionInfo(parsed, targetCell, props.bombardmentLength, true, out Vector3 direction, out IntVec3 _);
|
||||
|
||||
// Calculate target cells based on direction (Simulating CompAbilityEffect_Bombardment logic)
|
||||
// We use a simplified version here suitable for AI god-mode instant scheduling.
|
||||
// Note: Since we don't have a Comp instance attached to a Pawn, we rely on a static helper or just spawn them.
|
||||
// To make it look "progressive" like the real ability, we need a MapComponent or just reuse the SkyfallerDelayed logic.
|
||||
|
||||
var targetCells = CalculateBombardmentAreaCells(map, targetCell, direction, props.bombardmentWidth, props.bombardmentLength);
|
||||
|
||||
if (targetCells.Count == 0) return $"Error: No valid targets found for strafe at {targetCell}.";
|
||||
|
||||
// Filter cells by selection chance
|
||||
var selectedCells = new List<IntVec3>();
|
||||
var missedCells = new List<IntVec3>();
|
||||
foreach (var cell in targetCells)
|
||||
{
|
||||
if (Rand.Value <= props.targetSelectionChance) selectedCells.Add(cell);
|
||||
else missedCells.Add(cell);
|
||||
}
|
||||
|
||||
// Apply min/max constraints
|
||||
if (selectedCells.Count < props.minTargetCells && missedCells.Count > 0)
|
||||
{
|
||||
int needed = props.minTargetCells - selectedCells.Count;
|
||||
selectedCells.AddRange(missedCells.InRandomOrder().Take(Math.Min(needed, missedCells.Count)));
|
||||
}
|
||||
else if (selectedCells.Count > props.maxTargetCells)
|
||||
{
|
||||
selectedCells = selectedCells.InRandomOrder().Take(props.maxTargetCells).ToList();
|
||||
}
|
||||
|
||||
if (selectedCells.Count == 0) return $"Error: No cells selected for strafe after chance filter.";
|
||||
|
||||
// Organize into rows for progressive effect
|
||||
var rows = OrganizeIntoRows(targetCell, direction, selectedCells);
|
||||
|
||||
// Schedule via MapComponent_SkyfallerDelayed
|
||||
var delayed = map.GetComponent<MapComponent_SkyfallerDelayed>();
|
||||
if (delayed == null)
|
||||
{
|
||||
delayed = new MapComponent_SkyfallerDelayed(map);
|
||||
map.components.Add(delayed);
|
||||
}
|
||||
|
||||
int now = Find.TickManager.TicksGame;
|
||||
int startTick = now + props.warmupTicks;
|
||||
int totalScheduled = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
// Each row starts after rowDelayTicks
|
||||
int rowStartTick = startTick + (row.Key * props.rowDelayTicks);
|
||||
|
||||
for (int i = 0; i < row.Value.Count; i++)
|
||||
{
|
||||
// Within a row, each cell is hit after impactDelayTicks
|
||||
int hitTick = rowStartTick + (i * props.impactDelayTicks);
|
||||
int delay = hitTick - now;
|
||||
|
||||
if (delay <= 0)
|
||||
{
|
||||
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
|
||||
GenSpawn.Spawn(skyfaller, row.Value[i], map);
|
||||
}
|
||||
else
|
||||
{
|
||||
delayed.ScheduleSkyfaller(props.skyfallerDef, row.Value[i], delay);
|
||||
}
|
||||
totalScheduled++;
|
||||
}
|
||||
}
|
||||
|
||||
return $"Success: Scheduled Strafe Bombardment '{def.defName}' at {targetCell}. Direction: {direction}. Targets: {totalScheduled}.";
|
||||
}
|
||||
|
||||
private static bool TryParseDirectionCell(Dictionary<string, string> parsed, out IntVec3 cell)
|
||||
{
|
||||
cell = IntVec3.Invalid;
|
||||
if (parsed.TryGetValue("dirX", out var xStr) && parsed.TryGetValue("dirZ", out var zStr) &&
|
||||
int.TryParse(xStr, out int x) && int.TryParse(zStr, out int z))
|
||||
{
|
||||
cell = new IntVec3(x, 0, z);
|
||||
return true;
|
||||
}
|
||||
// Optional: Support <direction>x,z</direction>
|
||||
if (parsed.TryGetValue("direction", out var dirStr) && !string.IsNullOrWhiteSpace(dirStr))
|
||||
{
|
||||
var parts = dirStr.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && int.TryParse(parts[0], out int dx) && int.TryParse(parts[1], out int dz))
|
||||
{
|
||||
cell = new IntVec3(dx, 0, dz);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Logic adapted from CompAbilityEffect_Bombardment
|
||||
private List<IntVec3> CalculateBombardmentAreaCells(Map map, IntVec3 startCell, Vector3 direction, int width, int length)
|
||||
{
|
||||
var areaCells = new List<IntVec3>();
|
||||
Vector3 start = startCell.ToVector3();
|
||||
Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
|
||||
|
||||
float halfWidth = width * 0.5f;
|
||||
float totalLength = length;
|
||||
|
||||
int widthSteps = Math.Max(1, width);
|
||||
int lengthSteps = Math.Max(1, length);
|
||||
|
||||
for (int l = 0; l <= lengthSteps; l++)
|
||||
{
|
||||
float lengthProgress = (float)l / lengthSteps;
|
||||
float lengthOffset = UnityEngine.Mathf.Lerp(0, totalLength, lengthProgress);
|
||||
|
||||
for (int w = 0; w <= widthSteps; w++)
|
||||
{
|
||||
float widthProgress = (float)w / widthSteps;
|
||||
float widthOffset = UnityEngine.Mathf.Lerp(-halfWidth, halfWidth, widthProgress);
|
||||
|
||||
Vector3 cellPos = start + direction * lengthOffset + perpendicularDirection * widthOffset;
|
||||
IntVec3 cell = new IntVec3(
|
||||
UnityEngine.Mathf.RoundToInt(cellPos.x),
|
||||
UnityEngine.Mathf.RoundToInt(cellPos.y),
|
||||
UnityEngine.Mathf.RoundToInt(cellPos.z)
|
||||
);
|
||||
|
||||
if (cell.InBounds(map) && !areaCells.Contains(cell))
|
||||
{
|
||||
areaCells.Add(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
return areaCells;
|
||||
}
|
||||
|
||||
private Dictionary<int, List<IntVec3>> OrganizeIntoRows(IntVec3 startCell, Vector3 direction, List<IntVec3> cells)
|
||||
{
|
||||
var rows = new Dictionary<int, List<IntVec3>>();
|
||||
Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
|
||||
|
||||
foreach (var cell in cells)
|
||||
{
|
||||
Vector3 cellVector = cell.ToVector3() - startCell.ToVector3();
|
||||
float dot = Vector3.Dot(cellVector, direction);
|
||||
int rowIndex = UnityEngine.Mathf.RoundToInt(dot);
|
||||
|
||||
if (!rows.ContainsKey(rowIndex)) rows[rowIndex] = new List<IntVec3>();
|
||||
rows[rowIndex].Add(cell);
|
||||
}
|
||||
|
||||
// Sort rows by index (distance from start)
|
||||
var sortedRows = rows.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
// Sort cells within rows by width position
|
||||
foreach (var key in sortedRows.Keys.ToList())
|
||||
{
|
||||
sortedRows[key] = sortedRows[key].OrderBy(c => Vector3.Dot((c.ToVector3() - startCell.ToVector3()), perpendicularDirection)).ToList();
|
||||
}
|
||||
|
||||
return sortedRows;
|
||||
}
|
||||
|
||||
private static void SpawnOrSchedule(Map map, MapComponent_SkyfallerDelayed delayed, ThingDef skyfallerDef, IntVec3 cell, bool spawnImmediately, int delayTicks)
|
||||
{
|
||||
if (!cell.IsValid || !cell.InBounds(map)) return;
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using RimWorld;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 采矿指令工具 - 在指定位置或区域添加采矿标记
|
||||
/// </summary>
|
||||
public class Tool_DesignateMine : AITool
|
||||
{
|
||||
public override string Name => "designate_mine";
|
||||
|
||||
public override string Description =>
|
||||
"在指定坐标添加采矿标记。可以指定单个格子或矩形区域。只能标记可采矿的岩石。";
|
||||
|
||||
public override string UsageSchema =>
|
||||
"<designate_mine><x>整数X坐标</x><z>整数Z坐标</z><radius>可选,整数半径,默认0表示单格</radius></designate_mine>";
|
||||
|
||||
public override string Execute(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var argsDict = ParseXmlArgs(args);
|
||||
|
||||
// 解析坐标
|
||||
if (!argsDict.TryGetValue("x", out string xStr) || !int.TryParse(xStr, out int x))
|
||||
{
|
||||
return "Error: 缺少有效的 x 坐标";
|
||||
}
|
||||
if (!argsDict.TryGetValue("z", out string zStr) || !int.TryParse(zStr, out int z))
|
||||
{
|
||||
return "Error: 缺少有效的 z 坐标";
|
||||
}
|
||||
|
||||
int radius = 0;
|
||||
if (argsDict.TryGetValue("radius", out string radiusStr))
|
||||
{
|
||||
int.TryParse(radiusStr, out radius);
|
||||
}
|
||||
radius = Math.Max(0, Math.Min(10, radius)); // 限制半径 0-10
|
||||
|
||||
// 获取地图
|
||||
Map map = Find.CurrentMap;
|
||||
if (map == null)
|
||||
{
|
||||
return "Error: 没有活动的地图";
|
||||
}
|
||||
|
||||
IntVec3 center = new IntVec3(x, 0, z);
|
||||
if (!center.InBounds(map))
|
||||
{
|
||||
return $"Error: 坐标 ({x}, {z}) 超出地图边界";
|
||||
}
|
||||
|
||||
// 收集要标记的格子
|
||||
List<IntVec3> cellsToMark = new List<IntVec3>();
|
||||
|
||||
if (radius == 0)
|
||||
{
|
||||
cellsToMark.Add(center);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 矩形区域
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
for (int dz = -radius; dz <= radius; dz++)
|
||||
{
|
||||
IntVec3 cell = new IntVec3(x + dx, 0, z + dz);
|
||||
if (cell.InBounds(map))
|
||||
{
|
||||
cellsToMark.Add(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int successCount = 0;
|
||||
int alreadyMarkedCount = 0;
|
||||
int notMineableCount = 0;
|
||||
|
||||
foreach (var cell in cellsToMark)
|
||||
{
|
||||
// 检查是否已有采矿标记
|
||||
if (map.designationManager.DesignationAt(cell, DesignationDefOf.Mine) != null)
|
||||
{
|
||||
alreadyMarkedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否可采矿
|
||||
Mineable mineable = cell.GetFirstMineable(map);
|
||||
if (mineable == null)
|
||||
{
|
||||
notMineableCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 添加采矿标记
|
||||
map.designationManager.AddDesignation(new Designation(cell, DesignationDefOf.Mine));
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// 生成结果报告
|
||||
if (successCount > 0)
|
||||
{
|
||||
string result = $"Success: 已标记 {successCount} 个格子进行采矿";
|
||||
if (alreadyMarkedCount > 0)
|
||||
{
|
||||
result += $",{alreadyMarkedCount} 个已有标记";
|
||||
}
|
||||
if (notMineableCount > 0)
|
||||
{
|
||||
result += $",{notMineableCount} 个不可采矿";
|
||||
}
|
||||
|
||||
Messages.Message($"AI: 标记了 {successCount} 处采矿", MessageTypeDefOf.NeutralEvent);
|
||||
return result;
|
||||
}
|
||||
else if (alreadyMarkedCount > 0)
|
||||
{
|
||||
return $"Info: 该区域 {alreadyMarkedCount} 个格子已有采矿标记";
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"Error: 坐标 ({x}, {z}) 附近没有可采矿的岩石";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[Tool_DesignateMine] Error: {ex}");
|
||||
return $"Error: 采矿指令失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using System;
|
||||
using RimWorld;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 征召殖民者工具 - 将殖民者置于征召状态以便直接控制
|
||||
/// </summary>
|
||||
public class Tool_DraftPawn : AITool
|
||||
{
|
||||
public override string Name => "draft_pawn";
|
||||
|
||||
public override string Description =>
|
||||
"征召或解除征召殖民者。征召后可以直接控制殖民者移动和攻击。";
|
||||
|
||||
public override string UsageSchema =>
|
||||
"<draft_pawn><pawn_name>殖民者名字</pawn_name><draft>true征召/false解除</draft></draft_pawn>";
|
||||
|
||||
public override string Execute(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var argsDict = ParseXmlArgs(args);
|
||||
|
||||
// 解析殖民者名字
|
||||
if (!argsDict.TryGetValue("pawn_name", out string pawnName) || string.IsNullOrWhiteSpace(pawnName))
|
||||
{
|
||||
// 尝试其他常见参数名
|
||||
if (!argsDict.TryGetValue("name", out pawnName) || string.IsNullOrWhiteSpace(pawnName))
|
||||
{
|
||||
return "Error: 缺少殖民者名字 (pawn_name)";
|
||||
}
|
||||
}
|
||||
|
||||
// 解析征召状态
|
||||
bool draft = true;
|
||||
if (argsDict.TryGetValue("draft", out string draftStr))
|
||||
{
|
||||
draft = draftStr.ToLowerInvariant() != "false" && draftStr != "0";
|
||||
}
|
||||
|
||||
// 获取地图
|
||||
Map map = Find.CurrentMap;
|
||||
if (map == null)
|
||||
{
|
||||
return "Error: 没有活动的地图";
|
||||
}
|
||||
|
||||
// 查找殖民者
|
||||
Pawn targetPawn = null;
|
||||
foreach (var pawn in map.mapPawns.FreeColonists)
|
||||
{
|
||||
if (pawn.LabelShortCap.Equals(pawnName, StringComparison.OrdinalIgnoreCase) ||
|
||||
pawn.Name?.ToStringShort?.Equals(pawnName, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
pawn.LabelCap.ToString().IndexOf(pawnName, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
targetPawn = pawn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPawn == null)
|
||||
{
|
||||
return $"Error: 找不到殖民者 '{pawnName}'";
|
||||
}
|
||||
|
||||
// 检查是否可以征召
|
||||
if (targetPawn.Downed)
|
||||
{
|
||||
return $"Error: {targetPawn.LabelShortCap} 已倒地,无法征召";
|
||||
}
|
||||
|
||||
if (targetPawn.Dead)
|
||||
{
|
||||
return $"Error: {targetPawn.LabelShortCap} 已死亡";
|
||||
}
|
||||
|
||||
if (targetPawn.drafter == null)
|
||||
{
|
||||
return $"Error: {targetPawn.LabelShortCap} 无法被征召";
|
||||
}
|
||||
|
||||
// 执行征召/解除
|
||||
bool wasDrafted = targetPawn.Drafted;
|
||||
targetPawn.drafter.Drafted = draft;
|
||||
|
||||
string action = draft ? "征召" : "解除征召";
|
||||
|
||||
if (wasDrafted == draft)
|
||||
{
|
||||
return $"Info: {targetPawn.LabelShortCap} 已经处于{(draft ? "征召" : "非征召")}状态";
|
||||
}
|
||||
|
||||
Messages.Message($"AI: {action}了 {targetPawn.LabelShortCap}", targetPawn, MessageTypeDefOf.NeutralEvent);
|
||||
return $"Success: 已{action} {targetPawn.LabelShortCap},当前位置 ({targetPawn.Position.x}, {targetPawn.Position.z})";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[Tool_DraftPawn] Error: {ex}");
|
||||
return $"Error: 征召操作失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using RimWorld;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
{
|
||||
public class Tool_GetAvailableBombardments : AITool
|
||||
{
|
||||
public override string Name => "get_available_bombardments";
|
||||
public override string Description => "Returns a list of available orbital bombardment abilities (AbilityDefs) that can be called. " +
|
||||
"Use this to find the correct 'abilityDef' for the 'call_bombardment' tool.";
|
||||
public override string UsageSchema => "<get_available_bombardments/>";
|
||||
|
||||
public override string Execute(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var allAbilityDefs = DefDatabase<AbilityDef>.AllDefs.ToList();
|
||||
var validBombardments = new List<AbilityDef>();
|
||||
|
||||
foreach (var def in allAbilityDefs)
|
||||
{
|
||||
if (def.comps == null) continue;
|
||||
|
||||
// Support multiple bombardment types:
|
||||
// 1. Circular Bombardment (original)
|
||||
var circularProps = def.comps.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||
if (circularProps != null && circularProps.skyfallerDef != null)
|
||||
{
|
||||
validBombardments.Add(def);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Standard/Rectangular Bombardment (e.g. Minigun Strafe)
|
||||
var bombardProps = def.comps.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||
if (bombardProps != null && bombardProps.skyfallerDef != null)
|
||||
{
|
||||
validBombardments.Add(def);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Energy Lance (e.g. EnergyLance Strafe)
|
||||
var lanceProps = def.comps.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||
if (lanceProps != null)
|
||||
{
|
||||
validBombardments.Add(def);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Call Skyfaller / Surveillance (e.g. Cannon Surveillance)
|
||||
var skyfallerProps = def.comps.OfType<CompProperties_AbilityCallSkyfaller>().FirstOrDefault();
|
||||
if (skyfallerProps != null && skyfallerProps.skyfallerDef != null)
|
||||
{
|
||||
validBombardments.Add(def);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (validBombardments.Count == 0)
|
||||
{
|
||||
return "No valid bombardment abilities found in the database.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine($"Found {validBombardments.Count} available bombardment options:");
|
||||
|
||||
// Group by prefix to help AI categorize
|
||||
var wulaBombardments = validBombardments.Where(p => p.defName.StartsWith("WULA_", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var otherBombardments = validBombardments.Where(p => !p.defName.StartsWith("WULA_", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (wulaBombardments.Count > 0)
|
||||
{
|
||||
sb.AppendLine("\n[Wula Empire Specialized Bombardments]:");
|
||||
foreach (var p in wulaBombardments)
|
||||
{
|
||||
string label = !string.IsNullOrEmpty(p.label) ? $" ({p.label})" : "";
|
||||
var details = "";
|
||||
|
||||
var circular = p.comps.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||
if (circular != null) details = $"Type: Circular, Radius: {circular.radius}, Launches: {circular.maxLaunches}";
|
||||
|
||||
var bombard = p.comps.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||
if (bombard != null) details = $"Type: Strafe, Area: {bombard.bombardmentWidth}x{bombard.bombardmentLength}";
|
||||
|
||||
var lance = p.comps.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||
if (lance != null) details = $"Type: Energy Lance, Duration: {lance.durationTicks}";
|
||||
|
||||
var skyfaller = p.comps.OfType<CompProperties_AbilityCallSkyfaller>().FirstOrDefault();
|
||||
if (skyfaller != null) details = $"Type: Surveillance/Signal, Delay: {skyfaller.delayTicks}";
|
||||
|
||||
sb.AppendLine($"- {p.defName}{label} [{details}]");
|
||||
}
|
||||
}
|
||||
|
||||
if (otherBombardments.Count > 0)
|
||||
{
|
||||
sb.AppendLine("\n[Generic/Other Bombardments]:");
|
||||
// Limit generic ones to avoid token bloat
|
||||
var genericToShow = otherBombardments.Take(20).ToList();
|
||||
foreach (var p in genericToShow)
|
||||
{
|
||||
string label = !string.IsNullOrEmpty(p.label) ? $" ({p.label})" : "";
|
||||
var props = p.comps.OfType<CompProperties_AbilityCircularBombardment>().First();
|
||||
sb.AppendLine($"- {p.defName}{label} [MaxLaunches: {props.maxLaunches}, Radius: {props.radius}]");
|
||||
}
|
||||
if (otherBombardments.Count > 20)
|
||||
{
|
||||
sb.AppendLine($"- ... and {otherBombardments.Count - 20} more generic bombardments.");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前游戏状态工具 - 让 AI 了解殖民地当前情况
|
||||
/// </summary>
|
||||
public class Tool_GetGameState : AITool
|
||||
{
|
||||
public override string Name => "get_game_state";
|
||||
|
||||
public override string Description =>
|
||||
"获取当前游戏状态的详细报告,包括殖民者状态、资源、建筑进度、威胁等信息。在做出任何操作决策前应先调用此工具了解当前情况。";
|
||||
|
||||
public override string UsageSchema =>
|
||||
"<get_game_state/>";
|
||||
|
||||
public override string Execute(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = Agent.StateObserver.CaptureState();
|
||||
|
||||
if (snapshot == null)
|
||||
{
|
||||
return "Error: 无法捕获游戏状态,可能没有活动的地图。";
|
||||
}
|
||||
|
||||
string stateText = snapshot.ToPromptText();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(stateText))
|
||||
{
|
||||
return "Error: 游戏状态为空。";
|
||||
}
|
||||
|
||||
return stateText;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[Tool_GetGameState] Error: {ex}");
|
||||
return $"Error: 获取游戏状态失败 - {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user