420 lines
15 KiB
C#
420 lines
15 KiB
C#
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
|
|
{
|
|
var flyOverDef = DefDatabase<ThingDef>.GetNamedSilentFail("WULA_AircraftCarrier");
|
|
if (flyOverDef == null)
|
|
{
|
|
WulaLog.Debug("[AI Overwatch] Could not find WULA_AircraftCarrier ThingDef.");
|
|
return;
|
|
}
|
|
|
|
// Calculate proper start and end positions (edge to opposite edge)
|
|
IntVec3 startPos = GetRandomMapEdgePosition(map);
|
|
IntVec3 endPos = GetOppositeMapEdgePosition(map, startPos);
|
|
|
|
// Use the proper FlyOver.MakeFlyOver static method
|
|
FlyOver flyOver = FlyOver.MakeFlyOver(
|
|
flyOverDef,
|
|
startPos,
|
|
endPos,
|
|
map,
|
|
speed: 0.03f,
|
|
height: 20f
|
|
);
|
|
|
|
if (flyOver != null)
|
|
{
|
|
Messages.Message("WULA_AIOverwatch_FleetCalled".Translate(), MessageTypeDefOf.PositiveEvent);
|
|
WulaLog.Debug($"[AI Overwatch] Called fleet: WULA_AircraftCarrier spawned from {startPos} to {endPos}.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
WulaLog.Debug($"[AI Overwatch] Failed to call fleet: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private IntVec3 GetRandomMapEdgePosition(Map map)
|
|
{
|
|
int edge = Rand.Range(0, 4);
|
|
int x, z;
|
|
|
|
switch (edge)
|
|
{
|
|
case 0: // Bottom
|
|
x = Rand.Range(5, map.Size.x - 5);
|
|
z = 0;
|
|
break;
|
|
case 1: // Right
|
|
x = map.Size.x - 1;
|
|
z = Rand.Range(5, map.Size.z - 5);
|
|
break;
|
|
case 2: // Top
|
|
x = Rand.Range(5, map.Size.x - 5);
|
|
z = map.Size.z - 1;
|
|
break;
|
|
case 3: // Left
|
|
default:
|
|
x = 0;
|
|
z = Rand.Range(5, map.Size.z - 5);
|
|
break;
|
|
}
|
|
|
|
return new IntVec3(x, 0, z);
|
|
}
|
|
|
|
private IntVec3 GetOppositeMapEdgePosition(Map map, IntVec3 startPos)
|
|
{
|
|
// Calculate direction from start to center, then extend to opposite edge
|
|
IntVec3 center = map.Center;
|
|
Vector3 toCenter = (center.ToVector3() - startPos.ToVector3()).normalized;
|
|
|
|
// Extend to the opposite edge
|
|
float maxDistance = Mathf.Max(map.Size.x, map.Size.z) * 1.5f;
|
|
Vector3 endVec = startPos.ToVector3() + toCenter * maxDistance;
|
|
|
|
// Clamp to map bounds
|
|
int endX = Mathf.Clamp((int)endVec.x, 0, map.Size.x - 1);
|
|
int endZ = Mathf.Clamp((int)endVec.z, 0, map.Size.z - 1);
|
|
|
|
return new IntVec3(endX, 0, endZ);
|
|
}
|
|
|
|
private void TryClearFlightPath()
|
|
{
|
|
try
|
|
{
|
|
// Find all FlyOver entities on the map and use EmergencyDestroy for smooth exit
|
|
var flyOvers = map.listerThings.AllThings
|
|
.Where(t => t is FlyOver)
|
|
.Cast<FlyOver>()
|
|
.ToList();
|
|
|
|
foreach (var flyOver in flyOvers)
|
|
{
|
|
if (!flyOver.Destroyed)
|
|
{
|
|
flyOver.EmergencyDestroy(); // Use smooth accelerated exit instead of instant destroy
|
|
}
|
|
}
|
|
|
|
if (flyOvers.Count > 0)
|
|
{
|
|
Messages.Message("WULA_AIOverwatch_FleetCleared".Translate(), MessageTypeDefOf.NeutralEvent);
|
|
WulaLog.Debug($"[AI Overwatch] Cleared flight path: {flyOvers.Count} entities set to emergency exit.");
|
|
}
|
|
}
|
|
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<Pawn> 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<Pawn> 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<AbilityDef>.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe");
|
|
var cannonDef = DefDatabase<AbilityDef>.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<AbilityDef>.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<AbilityDef>.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<AbilityDef>.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<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
|
if (circular != null)
|
|
{
|
|
BombardmentUtility.ExecuteCircularBombardment(map, target, ability, circular);
|
|
return;
|
|
}
|
|
|
|
var bombard = ability.comps?.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
|
if (bombard != null)
|
|
{
|
|
BombardmentUtility.ExecuteStrafeBombardmentDirect(map, target, ability, bombard, angle);
|
|
return;
|
|
}
|
|
|
|
var lance = ability.comps?.OfType<CompProperties_AbilityEnergyLance>().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<List<Pawn>> ClusterPawns(List<Pawn> pawns, float radius)
|
|
{
|
|
var clusters = new List<List<Pawn>>();
|
|
var assigned = new HashSet<Pawn>();
|
|
|
|
foreach (var p in pawns)
|
|
{
|
|
if (assigned.Contains(p)) continue;
|
|
|
|
var newCluster = new List<Pawn> { 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);
|
|
}
|
|
}
|
|
}
|