diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index e08503ed..75455d8a 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.dll and b/1.6/1.6/Assemblies/WulaFallenEmpire.dll differ diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.pdb b/1.6/1.6/Assemblies/WulaFallenEmpire.pdb index 4dc794eb..57582f75 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.pdb and b/1.6/1.6/Assemblies/WulaFallenEmpire.pdb differ diff --git a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml index f9ddaf3b..521d8cb6 100644 --- a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml +++ b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml @@ -172,4 +172,20 @@ 重试中 {FACTION_name}已经在附近投下了一些资源。 + + + P.I.A 轨道监视已启动 + P.I.A 已经接管了轨道防御系统,正在持续扫描敌对目标。\n\n如果有敌人出现且无误伤风险,AI 将自动进行打击。\n\n剩余时间:{0} 秒 + P.I.A 轨道监视协议已处于激活状态(剩余 {0} 秒)。请求被忽略。 + P.I.A 轨道监视协议:已启动,持续 {0} 秒。 + P.I.A 轨道监视协议:已解除。 + [P.I.A 轨道监视] 系统运行中。剩余时间:{0} 秒。 + [P.I.A 轨道监视] 目标 {0} 打击中止:友军误伤风险。 + [P.I.A 轨道监视] 发现超大型敌群({0} 个目标)!执行联合打击! + [P.I.A 轨道监视] 对敌群({0} 个目标)发射光矛。 + [P.I.A 轨道监视] 对敌群({0} 个目标)发射副炮齐射。 + [P.I.A 轨道监视] 对敌方({0} 个目标)发射链炮扫射。 + [P.I.A 轨道监视] 帝国舰队已抵达轨道。 + [P.I.A 轨道监视] 航道已净空。 + diff --git a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs index 1cf4ccd7..29d468dd 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs @@ -340,6 +340,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori _tools.Add(new Tool_SearchThingDef()); _tools.Add(new Tool_SearchPawnKind()); _tools.Add(new Tool_CallPrefabAirdrop()); + _tools.Add(new Tool_SetOverwatchMode()); // Agent 工具 - 保留画面分析截图能力,移除所有模拟操作工具 if (WulaFallenEmpireMod.settings?.enableVlmFeatures == true) @@ -494,6 +495,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori "- call_bombardment\n" + "- modify_goodwill\n" + "- call_prefab_airdrop\n" + + "- set_overwatch_mode\n" + "If no action is required, output exactly: .\n" + "Query tools exist but are disabled in this phase (not listed here).\n" : string.Empty; @@ -509,7 +511,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori string actionWhitelist = phase == RequestPhase.ActionTools ? "ACTION PHASE VALID TAGS ONLY:\n" + - ", , , , , \n" + + ", , , , , , \n" + "INVALID EXAMPLES (do NOT use now): , , , \n" : string.Empty; @@ -655,7 +657,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori toolName == "send_reinforcement" || toolName == "call_bombardment" || toolName == "modify_goodwill" || - toolName == "call_prefab_airdrop"; + toolName == "call_prefab_airdrop" || + toolName == "set_overwatch_mode"; } private static bool IsQueryToolName(string toolName) diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Alerts/Alert_AIOverwatchActive.cs b/Source/WulaFallenEmpire/EventSystem/AI/Alerts/Alert_AIOverwatchActive.cs new file mode 100644 index 00000000..3ce33e24 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Alerts/Alert_AIOverwatchActive.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using RimWorld; +using UnityEngine; +using Verse; +using WulaFallenEmpire.EventSystem.AI; + +namespace WulaFallenEmpire.EventSystem.AI.Alerts +{ + public class Alert_AIOverwatchActive : Alert_Critical + { + public Alert_AIOverwatchActive() + { + this.defaultLabel = "WULA_AIOverwatch_Label".Translate(); + this.defaultExplanation = "WULA_AIOverwatch_Desc".Translate(0); + } + + public override AlertReport GetReport() + { + var map = Find.CurrentMap; + if (map == null) return AlertReport.Inactive; + + var comp = map.GetComponent(); + if (comp != null && comp.IsEnabled) + { + return AlertReport.Active; + } + + return AlertReport.Inactive; + } + + public override string GetLabel() + { + var map = Find.CurrentMap; + if (map != null) + { + var comp = map.GetComponent(); + if (comp != null && comp.IsEnabled) + { + int secondsLeft = comp.DurationTicks / 60; + return "WULA_AIOverwatch_Label".Translate() + $"\n({secondsLeft}s)"; + } + } + return "WULA_AIOverwatch_Label".Translate(); + } + + public override TaggedString GetExplanation() + { + var map = Find.CurrentMap; + if (map != null) + { + var comp = map.GetComponent(); + if (comp != null && comp.IsEnabled) + { + return "WULA_AIOverwatch_Desc".Translate(comp.DurationTicks / 60); + } + } + return base.GetExplanation(); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/MapComponent_AIOverwatch.cs b/Source/WulaFallenEmpire/EventSystem/AI/MapComponent_AIOverwatch.cs new file mode 100644 index 00000000..ed9a77f6 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/MapComponent_AIOverwatch.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using RimWorld; +using UnityEngine; +using Verse; +using WulaFallenEmpire.EventSystem.AI.Tools; + +namespace WulaFallenEmpire.EventSystem.AI +{ + public class MapComponent_AIOverwatch : MapComponent + { + private bool enabled = false; + private int durationTicks = 0; + private int tickCounter = 0; + private int checkInterval = 180; // Check every 3 seconds (180 ticks) + + // Configurable cooldown to prevent spamming too many simultaneous strikes + private int globalCooldownTicks = 0; + + public bool IsEnabled => enabled; + public int DurationTicks => durationTicks; + + public MapComponent_AIOverwatch(Map map) : base(map) + { + } + + public void EnableOverwatch(int durationSeconds) + { + if (this.enabled) + { + Messages.Message("WULA_AIOverwatch_AlreadyActive".Translate(this.durationTicks / 60), MessageTypeDefOf.RejectInput); + return; + } + + // Hard limit: 3 minutes (180 seconds) + int clampedDuration = Math.Min(durationSeconds, 180); + + this.enabled = true; + this.durationTicks = clampedDuration * 60; + this.tickCounter = 0; + this.globalCooldownTicks = 0; + + // Call fleet when overwatch starts + TryCallFleet(); + + Messages.Message("WULA_AIOverwatch_Engaged".Translate(clampedDuration), MessageTypeDefOf.PositiveEvent); + } + + public void DisableOverwatch() + { + this.enabled = false; + this.durationTicks = 0; + + // Clear flight path when overwatch ends + TryClearFlightPath(); + + Messages.Message("WULA_AIOverwatch_Disengaged".Translate(), MessageTypeDefOf.NeutralEvent); + } + + private void TryCallFleet() + { + try + { + // Find the FlyOver spawner component and trigger it + var flyOverDef = DefDatabase.GetNamedSilentFail("WULA_AircraftCarrier"); + if (flyOverDef != null) + { + // Use the FlyOver spawning system + var flyOver = ThingMaker.MakeThing(flyOverDef) as FlyOver; + if (flyOver != null) + { + // Configure the flyover + flyOver.flightSpeed = 0.03f; + flyOver.altitude = 20; + + // Spawn at map edge + IntVec3 spawnPos = CellFinder.RandomEdgeCell(map); + GenSpawn.Spawn(flyOver, spawnPos, map); + + Messages.Message("WULA_AIOverwatch_FleetCalled".Translate(), MessageTypeDefOf.PositiveEvent); + WulaLog.Debug("[AI Overwatch] Called fleet: WULA_AircraftCarrier spawned."); + } + } + else + { + WulaLog.Debug("[AI Overwatch] Could not find WULA_AircraftCarrier ThingDef."); + } + } + catch (Exception ex) + { + WulaLog.Debug($"[AI Overwatch] Failed to call fleet: {ex.Message}"); + } + } + + private void TryClearFlightPath() + { + try + { + // Find all FlyOver entities on the map and destroy them + var flyOvers = map.listerThings.AllThings + .Where(t => t is FlyOver || t.def.defName.Contains("FlyOver") || t.def.defName.Contains("AircraftCarrier")) + .ToList(); + + foreach (var flyOver in flyOvers) + { + if (!flyOver.Destroyed) + { + flyOver.Destroy(DestroyMode.Vanish); + } + } + + if (flyOvers.Count > 0) + { + Messages.Message("WULA_AIOverwatch_FleetCleared".Translate(), MessageTypeDefOf.NeutralEvent); + WulaLog.Debug($"[AI Overwatch] Cleared flight path: Destroyed {flyOvers.Count} entities."); + } + } + catch (Exception ex) + { + WulaLog.Debug($"[AI Overwatch] Failed to clear flight path: {ex.Message}"); + } + } + + public override void MapComponentTick() + { + base.MapComponentTick(); + if (!enabled) return; + + durationTicks--; + if (durationTicks <= 0) + { + DisableOverwatch(); + return; + } + + if (globalCooldownTicks > 0) + { + globalCooldownTicks--; + } + + tickCounter++; + if (tickCounter >= checkInterval) + { + tickCounter = 0; + + // Optional: Notify user every 30 seconds (1800 ticks) that system is still active + if ((durationTicks % 1800) < checkInterval) + { + Messages.Message("WULA_AIOverwatch_SystemActive".Translate(durationTicks / 60), MessageTypeDefOf.NeutralEvent); + } + + PerformScanAndStrike(); + } + } + + private void PerformScanAndStrike() + { + // Gather all valid hostile targets + List hostiles = map.mapPawns.AllPawnsSpawned + .Where(p => !p.Dead && !p.Downed && p.HostileTo(Faction.OfPlayer) && !p.IsPrisoner) + .ToList(); + + if (hostiles.Count == 0) return; + + // Simple clustering: Group hostiles that are close to each other + var clusters = ClusterPawns(hostiles, 12f); // 12 tile radius for a cluster + + // Prioritize larger clusters + clusters.Sort((a, b) => b.Count.CompareTo(a.Count)); // Descending order + + // Process clusters + _strikesThisScan = 0; + + foreach (var cluster in clusters) + { + if (globalCooldownTicks > 0) break; + ProcessCluster(cluster); + } + } + + private int _strikesThisScan = 0; + + private void ProcessCluster(List cluster) + { + if (cluster.Count == 0) return; + if (_strikesThisScan >= 3) return; // Self-limit + + // Calculate center of mass + float x = 0, z = 0; + foreach (var p in cluster) + { + x += p.Position.x; + z += p.Position.z; + } + IntVec3 center = new IntVec3((int)(x / cluster.Count), 0, (int)(z / cluster.Count)); + + if (!center.InBounds(map)) return; + + float angle = Rand.Range(0, 360); + + // NEW Decision Logic: + // >= 20: Heavy (Energy Lance + Primary Cannon together) + // >= 10: Energy Lance only + // >= 3: Cannon Salvo (Medium) + // < 3: Minigun (Light) + + if (cluster.Count >= 20) + { + // Ultra Heavy: Fire BOTH Energy Lance AND Primary Cannon + float safetyRadius = 18.9f; + if (IsFriendlyFireRisk(center, safetyRadius)) + { + Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput); + return; + } + + var lanceDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe"); + var cannonDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_Primary_Cannon_Strafe"); + + Messages.Message("WULA_AIOverwatch_MassiveCluster".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.ThreatBig); + WulaLog.Debug($"[AI Overwatch] MASSIVE cluster ({cluster.Count}), executing combined strike at {center}."); + + if (lanceDef != null) FireAbility(lanceDef, center, angle); + if (cannonDef != null) FireAbility(cannonDef, center, angle + 45f); // Offset angle for variety + + _strikesThisScan++; + return; + } + + if (cluster.Count >= 10) + { + // Heavy: Energy Lance only + float safetyRadius = 16.9f; + if (IsFriendlyFireRisk(center, safetyRadius)) + { + Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput); + return; + } + + var lanceDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe"); + if (lanceDef != null) + { + Messages.Message("WULA_AIOverwatch_EngagingLance".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent); + WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Energy Lance at {center}."); + FireAbility(lanceDef, center, angle); + _strikesThisScan++; + } + return; + } + + if (cluster.Count >= 3) + { + // Medium: Cannon Salvo + float safetyRadius = 9.9f; + if (IsFriendlyFireRisk(center, safetyRadius)) + { + Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput); + return; + } + + var cannonDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_Cannon_Salvo"); + if (cannonDef != null) + { + Messages.Message("WULA_AIOverwatch_EngagingCannon".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent); + WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Cannon Salvo at {center}."); + FireAbility(cannonDef, center, angle); + _strikesThisScan++; + } + return; + } + + // Light: Minigun Strafe + { + float safetyRadius = 5.9f; + if (IsFriendlyFireRisk(center, safetyRadius)) + { + Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput); + return; + } + + var minigunDef = DefDatabase.GetNamedSilentFail("WULA_Firepower_Minigun_Strafe"); + if (minigunDef != null) + { + Messages.Message("WULA_AIOverwatch_EngagingMinigun".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent); + WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Minigun Strafe at {center}."); + FireAbility(minigunDef, center, angle); + _strikesThisScan++; + } + } + } + + private void FireAbility(AbilityDef ability, IntVec3 target, float angle) + { + // Route via BombardmentUtility + // We need to check components again to know which method to call + + var circular = ability.comps?.OfType().FirstOrDefault(); + if (circular != null) + { + BombardmentUtility.ExecuteCircularBombardment(map, target, ability, circular); + return; + } + + var bombard = ability.comps?.OfType().FirstOrDefault(); + if (bombard != null) + { + BombardmentUtility.ExecuteStrafeBombardmentDirect(map, target, ability, bombard, angle); + return; + } + + var lance = ability.comps?.OfType().FirstOrDefault(); + if (lance != null) + { + BombardmentUtility.ExecuteEnergyLanceDirect(map, target, ability, lance, angle); + return; + } + } + + private bool IsFriendlyFireRisk(IntVec3 center, float radius) + { + var pawns = map.mapPawns.AllPawnsSpawned; + foreach (var p in pawns) + { + if (p.Faction == Faction.OfPlayer || p.IsPrisonerOfColony) + { + if (p.Position.InHorDistOf(center, radius)) return true; + } + } + return false; + } + + private List> ClusterPawns(List pawns, float radius) + { + var clusters = new List>(); + var assigned = new HashSet(); + + foreach (var p in pawns) + { + if (assigned.Contains(p)) continue; + + var newCluster = new List { p }; + assigned.Add(p); + + // Find neighbors + foreach (var neighbor in pawns) + { + if (assigned.Contains(neighbor)) continue; + if (p.Position.InHorDistOf(neighbor.Position, radius)) + { + newCluster.Add(neighbor); + assigned.Add(neighbor); + } + } + clusters.Add(newCluster); + } + return clusters; + } + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref enabled, "enabled", false); + Scribe_Values.Look(ref durationTicks, "durationTicks", 0); + Scribe_Values.Look(ref tickCounter, "tickCounter", 0); + Scribe_Values.Look(ref globalCooldownTicks, "globalCooldownTicks", 0); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/BombardmentUtility.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/BombardmentUtility.cs new file mode 100644 index 00000000..05e467c4 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/BombardmentUtility.cs @@ -0,0 +1,429 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RimWorld; +using UnityEngine; +using Verse; +using WulaFallenEmpire; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + public static class BombardmentUtility + { + public static string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary parsed = null) + { + if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef."; + + bool filter = true; + if (parsed != null && parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff)) filter = ff; + + List 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}."; + } + + public static string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary parsed = null) + { + if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef."; + + ParseDirectionInfo(parsed, targetCell, props.bombardmentLength, true, out Vector3 direction, out IntVec3 _); + + var targetCells = CalculateBombardmentAreaCells(map, targetCell, direction, props.bombardmentWidth, props.bombardmentLength); + + if (targetCells.Count == 0) return $"Error: No valid targets found for strafe at {targetCell}."; + + var selectedCells = new List(); + var missedCells = new List(); + foreach (var cell in targetCells) + { + if (Rand.Value <= props.targetSelectionChance) selectedCells.Add(cell); + else missedCells.Add(cell); + } + + 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."; + + var rows = OrganizeIntoRows(targetCell, direction, selectedCells); + + var delayed = map.GetComponent(); + 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) + { + int rowStartTick = startTick + (row.Key * props.rowDelayTicks); + for (int i = 0; i < row.Value.Count; i++) + { + 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}."; + } + + public static string ExecuteStrafeBombardmentDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, float angle) + { + // Overload for direct execution with angle (no parsing needed) + Vector3 direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward; + // Reuse the main logic by passing a mock dictionary or separating the logic further? + // To simplify, let's just copy the core logic or create a private helper that takes explicit args. + // Actually, the main method parses direction from 'parsed'. + // Let's make a Dictionary to pass to it. + var dict = new Dictionary { { "angle", angle.ToString() } }; + return ExecuteStrafeBombardment(map, targetCell, def, props, dict); + } + + public static string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary parsed = null) + { + ThingDef lanceDef = props.energyLanceDef ?? DefDatabase.GetNamedSilentFail("EnergyLance"); + if (lanceDef == null) return $"Error: Could not resolve EnergyLance ThingDef for '{def.defName}'."; + + ParseDirectionInfo(parsed, targetCell, props.moveDistance, props.useFixedDistance, out Vector3 direction, out IntVec3 endPos); + + try + { + EnergyLance.MakeEnergyLance( + lanceDef, + targetCell, + endPos, + map, + props.moveDistance, + props.useFixedDistance, + props.durationTicks, + null + ); + + return $"Success: Triggered Energy Lance '{def.defName}' from {targetCell} towards {endPos}. Type: {lanceDef.defName}."; + } + catch (Exception ex) + { + return $"Error: Failed to spawn EnergyLance: {ex.Message}"; + } + } + + public static string ExecuteEnergyLanceDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, float angle) + { + var dict = new Dictionary { { "angle", angle.ToString() } }; + return ExecuteEnergyLance(map, targetCell, def, props, dict); + } + + public static 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(); + if (delayed == null) + { + delayed = new MapComponent_SkyfallerDelayed(map); + map.components.Add(delayed); + } + + 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."; + } + } + + // --- Helpers --- + + private static void ParseDirectionInfo(Dictionary parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos) + { + direction = Vector3.forward; + endPos = startPos; + + if (parsed == null) + { + // Default North + endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3(); + return; + } + + 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 = (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 TryParseDirectionCell(Dictionary parsed, out IntVec3 cell) + { + cell = IntVec3.Invalid; + if (parsed == null) return false; + + 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; + } + + 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; + } + + private static List SelectTargetCells(Map map, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire) + { + var candidates = GenRadial.RadialCellsAround(center, props.radius, true) + .Where(c => c.InBounds(map)) + .Where(c => IsValidTargetCell(map, c, center, props, filterFriendlyFire)) + .ToList(); + + if (candidates.Count == 0) return new List(); + + var selected = new List(); + foreach (var cell in candidates.InRandomOrder()) + { + if (Rand.Value <= props.targetSelectionChance) + { + selected.Add(cell); + } + + if (selected.Count >= props.maxTargets) break; + } + + if (selected.Count < props.minTargets) + { + var missedCells = candidates.Except(selected).InRandomOrder().ToList(); + int needed = props.minTargets - selected.Count; + if (needed > 0 && missedCells.Count > 0) + { + selected.AddRange(missedCells.Take(Math.Min(needed, missedCells.Count))); + } + } + else if (selected.Count > props.maxTargets) + { + selected = selected.InRandomOrder().Take(props.maxTargets).ToList(); + } + + return selected; + } + + private static bool IsValidTargetCell(Map map, IntVec3 cell, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire) + { + if (props.minDistanceFromCenter > 0f) + { + float distance = Vector3.Distance(cell.ToVector3(), center.ToVector3()); + if (distance < props.minDistanceFromCenter) return false; + } + + if (props.avoidBuildings && cell.GetEdifice(map) != null) + { + return false; + } + + if (filterFriendlyFire && props.avoidFriendlyFire) + { + var things = map.thingGrid.ThingsListAt(cell); + if (things != null) + { + for (int i = 0; i < things.Count; i++) + { + if (things[i] is Pawn pawn && pawn.Faction == Faction.OfPlayer) + { + return false; + } + } + } + } + + return true; + } + + private static int ScheduleBombardment(Map map, List targets, CompProperties_AbilityCircularBombardment props, bool spawnImmediately) + { + int now = Find.TickManager?.TicksGame ?? 0; + int startTick = now + props.warmupTicks; + int launchesCompleted = 0; + int groupIndex = 0; + + var remainingTargets = new List(targets); + + MapComponent_SkyfallerDelayed delayed = null; + if (!spawnImmediately) + { + delayed = map.GetComponent(); + if (delayed == null) + { + delayed = new MapComponent_SkyfallerDelayed(map); + map.components.Add(delayed); + } + } + + while (remainingTargets.Count > 0 && launchesCompleted < props.maxLaunches) + { + int groupSize = Math.Min(props.simultaneousLaunches, remainingTargets.Count); + var groupTargets = remainingTargets.Take(groupSize).ToList(); + remainingTargets.RemoveRange(0, groupSize); + + if (props.useIndependentIntervals) + { + for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++) + { + int scheduledTick = startTick + groupIndex * props.launchIntervalTicks + i * props.innerLaunchIntervalTicks; + SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now); + launchesCompleted++; + } + groupIndex++; + } + else + { + int scheduledTick = startTick + groupIndex * props.launchIntervalTicks; + for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++) + { + SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now); + launchesCompleted++; + } + groupIndex++; + } + } + + return launchesCompleted; + } + + private static void SpawnOrSchedule(Map map, MapComponent_SkyfallerDelayed delayed, ThingDef skyfallerDef, IntVec3 cell, bool spawnImmediately, int delayTicks) + { + if (!cell.IsValid || !cell.InBounds(map)) return; + + if (spawnImmediately || delayTicks <= 0) + { + Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(skyfallerDef); + GenSpawn.Spawn(skyfaller, cell, map); + return; + } + + delayed?.ScheduleSkyfaller(skyfallerDef, cell, delayTicks); + } + + private static List CalculateBombardmentAreaCells(Map map, IntVec3 startCell, Vector3 direction, int width, int length) + { + var areaCells = new List(); + 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 static Dictionary> OrganizeIntoRows(IntVec3 startCell, Vector3 direction, List cells) + { + var rows = new Dictionary>(); + 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(); + 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; + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs index 6c62a41a..4beeef98 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_CallBombardment.cs @@ -39,59 +39,25 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools // Switch logic based on AbilityDef components var circular = abilityDef.comps?.OfType().FirstOrDefault(); - if (circular != null) return ExecuteCircularBombardment(map, targetCell, abilityDef, circular, parsed); + if (circular != null) return BombardmentUtility.ExecuteCircularBombardment(map, targetCell, abilityDef, circular, parsed); var bombard = abilityDef.comps?.OfType().FirstOrDefault(); - if (bombard != null) return ExecuteStrafeBombardment(map, targetCell, abilityDef, bombard, parsed); + if (bombard != null) return BombardmentUtility.ExecuteStrafeBombardment(map, targetCell, abilityDef, bombard, parsed); var lance = abilityDef.comps?.OfType().FirstOrDefault(); - if (lance != null) return ExecuteEnergyLance(map, targetCell, abilityDef, lance, parsed); + if (lance != null) return BombardmentUtility.ExecuteEnergyLance(map, targetCell, abilityDef, lance, parsed); var skyfaller = abilityDef.comps?.OfType().FirstOrDefault(); - if (skyfaller != null) return ExecuteCallSkyfaller(map, targetCell, abilityDef, skyfaller); + if (skyfaller != null) return BombardmentUtility.ExecuteCallSkyfaller(map, targetCell, abilityDef, skyfaller); return $"Error: AbilityDef '{abilityDefName}' is not a supported bombardment/support type."; - } catch (Exception ex) { return $"Error: {ex.Message}"; } } - - // Shared helper for determining direction and end point - private void ParseDirectionInfo(Dictionary 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 parsed, out IntVec3 cell) { cell = IntVec3.Invalid; @@ -115,415 +81,5 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools return false; } - - private static List SelectTargetCells(Map map, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire) - { - var candidates = GenRadial.RadialCellsAround(center, props.radius, true) - .Where(c => c.InBounds(map)) - .Where(c => IsValidTargetCell(map, c, center, props, filterFriendlyFire)) - .ToList(); - - if (candidates.Count == 0) return new List(); - - var selected = new List(); - foreach (var cell in candidates.InRandomOrder()) - { - if (Rand.Value <= props.targetSelectionChance) - { - selected.Add(cell); - } - - if (selected.Count >= props.maxTargets) break; - } - - if (selected.Count < props.minTargets) - { - var missedCells = candidates.Except(selected).InRandomOrder().ToList(); - int needed = props.minTargets - selected.Count; - if (needed > 0 && missedCells.Count > 0) - { - selected.AddRange(missedCells.Take(Math.Min(needed, missedCells.Count))); - } - } - else if (selected.Count > props.maxTargets) - { - selected = selected.InRandomOrder().Take(props.maxTargets).ToList(); - } - - return selected; - } - - private static bool IsValidTargetCell(Map map, IntVec3 cell, IntVec3 center, CompProperties_AbilityCircularBombardment props, bool filterFriendlyFire) - { - if (props.minDistanceFromCenter > 0f) - { - float distance = Vector3.Distance(cell.ToVector3(), center.ToVector3()); - if (distance < props.minDistanceFromCenter) return false; - } - - if (props.avoidBuildings && cell.GetEdifice(map) != null) - { - return false; - } - - if (filterFriendlyFire && props.avoidFriendlyFire) - { - var things = map.thingGrid.ThingsListAt(cell); - if (things != null) - { - for (int i = 0; i < things.Count; i++) - { - if (things[i] is Pawn pawn && pawn.Faction == Faction.OfPlayer) - { - return false; - } - } - } - } - - return true; - } - - private static int ScheduleBombardment(Map map, List targets, CompProperties_AbilityCircularBombardment props, bool spawnImmediately) - { - int now = Find.TickManager?.TicksGame ?? 0; - int startTick = now + props.warmupTicks; - int launchesCompleted = 0; - int groupIndex = 0; - - var remainingTargets = new List(targets); - - MapComponent_SkyfallerDelayed delayed = null; - if (!spawnImmediately) - { - delayed = map.GetComponent(); - if (delayed == null) - { - delayed = new MapComponent_SkyfallerDelayed(map); - map.components.Add(delayed); - } - } - - while (remainingTargets.Count > 0 && launchesCompleted < props.maxLaunches) - { - int groupSize = Math.Min(props.simultaneousLaunches, remainingTargets.Count); - var groupTargets = remainingTargets.Take(groupSize).ToList(); - remainingTargets.RemoveRange(0, groupSize); - - if (props.useIndependentIntervals) - { - for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++) - { - int scheduledTick = startTick + groupIndex * props.launchIntervalTicks + i * props.innerLaunchIntervalTicks; - SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now); - launchesCompleted++; - } - groupIndex++; - } - else - { - int scheduledTick = startTick + groupIndex * props.launchIntervalTicks; - for (int i = 0; i < groupTargets.Count && launchesCompleted < props.maxLaunches; i++) - { - SpawnOrSchedule(map, delayed, props.skyfallerDef, groupTargets[i], spawnImmediately, scheduledTick - now); - launchesCompleted++; - } - groupIndex++; - } - } - - return launchesCompleted; - } - - private string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary parsed) - { - // Determine EnergyLanceDef - ThingDef lanceDef = props.energyLanceDef ?? DefDatabase.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 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 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(); - 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 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(); - var missedCells = new List(); - 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(); - 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 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 x,z - 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 CalculateBombardmentAreaCells(Map map, IntVec3 startCell, Vector3 direction, int width, int length) - { - var areaCells = new List(); - 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> OrganizeIntoRows(IntVec3 startCell, Vector3 direction, List cells) - { - var rows = new Dictionary>(); - 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(); - 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; - - if (spawnImmediately || delayTicks <= 0) - { - Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(skyfallerDef); - GenSpawn.Spawn(skyfaller, cell, map); - return; - } - - delayed?.ScheduleSkyfaller(skyfallerDef, cell, delayTicks); - } } } - diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_SetOverwatchMode.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_SetOverwatchMode.cs new file mode 100644 index 00000000..ea2faeff --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_SetOverwatchMode.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using RimWorld; +using UnityEngine; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + public class Tool_SetOverwatchMode : AITool + { + public override string Name => "set_overwatch_mode"; + public override string Description => "Enables or disables the AI Overwatch Combat Protocol. When enabled (enabled=true), the AI will autonomously scan for hostile targets every few seconds and launch appropriate orbital bombardments for a set duration. When disabled (enabled=false), it immediately stops any active overwatch and clears the flight path. Use enabled=false to stop overwatch early if the player requests it."; + public override string UsageSchema => "true/falseamount (only needed when enabling)"; + + public override string Execute(string args) + { + var parsed = ParseXmlArgs(args); + + bool enabled = true; + if (parsed.TryGetValue("enabled", out var enabledStr) && bool.TryParse(enabledStr, out bool e)) + { + enabled = e; + } + + int duration = 60; + if (parsed.TryGetValue("durationSeconds", out var dStr) && int.TryParse(dStr, out int d)) + { + duration = d; + } + + Map map = Find.CurrentMap; + if (map == null) return "Error: No active map."; + + var overwatch = map.GetComponent(); + if (overwatch == null) + { + overwatch = new MapComponent_AIOverwatch(map); + map.components.Add(overwatch); + } + + if (enabled) + { + overwatch.EnableOverwatch(duration); + return $"Success: AI Overwatch Protocol ENABLED for {duration} seconds. Hostiles will be engaged automatically."; + } + else + { + overwatch.DisableOverwatch(); + return "Success: AI Overwatch Protocol DISABLED."; + } + } + } +}