This commit is contained in:
2025-12-28 13:58:01 +08:00
parent f3b94d317a
commit f221bfefb5
14 changed files with 447 additions and 2157 deletions

View File

@@ -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());

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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}");
}
}
}
}

View File

@@ -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
};
}
}
}

View File

@@ -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;

View File

@@ -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}";
}
}
}
}

View File

@@ -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}";
}
}
}
}

View File

@@ -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}";
}
}
}
}

View File

@@ -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}";
}
}
}
}