diff --git a/1.6/1.6/Defs/Thing_building/ARA_ThingDef_Building_CatastropheMissileSilo.xml b/1.6/1.6/Defs/Thing_building/ARA_ThingDef_Building_CatastropheMissileSilo.xml new file mode 100644 index 0000000..cd30468 --- /dev/null +++ b/1.6/1.6/Defs/Thing_building/ARA_ThingDef_Building_CatastropheMissileSilo.xml @@ -0,0 +1,244 @@ + + + + + CatastropheMissileSilo + + 一个多功能导弹发射平台。它装备的武器系统既可以作为自动炮塔进行本地防御,也可以在操作员的指引下,将“天灾”级巡航导弹发射到全球任何一个角落。 + ArachnaeSwarm.Building_CatastropheMissileSilo + MapMeshAndRealTime + + Things/Building/Security/TurretHeavy_Base + Graphic_Single + (3, 3) + (0,0,-0.1) + + (0.2,0.2,0.6,0.6) + + + (1.5,0.35,1.4) + (0,0,-0.05) + + + (2,2) + Building + PassThroughOnly + 50 + 0.5 + false + + 500 + 12000 + 800 + -20 + + Normal + +
  • + 导弹 + 导弹 + 缺少导弹 + + +
  • ComponentSpacer
  • + + + 10 + 0 + 1 + true + true + +
  • +
  • +
  • + + + CatastropheMissile_Weapon + 5.0 + +
  • Artillery
  • + + + ARA_Buildings + 8 + +
  • ShipbuildingBasics
  • +
    +
    + + + Projectile_CatastropheMissile + + ArachnaeSwarm.Projectile_CruiseMissile + + Graphic_Single_AgeSecs + Things/Projectile/FleshmassSpitterProjectileSheet + (3,3) + MoteGlow + + + True + 1 + ARA_AcidBurn + 150 + 80 + true + Filth_SpentAcid + 4 + ARA_Shell_AcidSpitImpact + 60 + false + 10.9 + MortarBomb_Explode + + +
  • + ARA_AcidBurn + 150 + 10.9 + MortarBomb_Explode + true + 8 + 2.9 + 50 + 15 + ARA_AcidBurn + MortarBomb_Explode + 0.01 + 1 + 5 + 0.05 + 0.05 + 1.5 +
  • +
    + +
  • + Shell_AcidSpitStream +
  • +
  • + Shell_AcidSpitLaunched +
  • +
    +
    + + + ARA_Shell_AcidSpitImpact + +
  • + SubEffecter_SprayerChance + Fleck_AcidSpitImpact + 1 + 5 + 1 + 2 + 8 + OnSource +
  • +
  • + SubEffecter_SprayerTriggered + Fleck_AcidSpitLaunchedMist + 20 + 3~6 + OnSource + false + 0~100 + 1 + -1~1 + 0 +
  • +
  • + SubEffecter_SprayerTriggered + Fleck_AcidSpitLaunchedGlobFast + 6~10 + OnSource + false + .7 + .7 + true + true + 0~100 + 2.5 + 20~45 + 0~360 +
  • +
    +
    + + + Projectile_CatastropheMissile_Fake + + ArachnaeSwarm.Projectile_CruiseMissile + + Graphic_Single_AgeSecs + Things/Projectile/FleshmassSpitterProjectileSheet + (3,3) + MoteGlow + + + True + 1 + ARA_AcidBurn + 0 + 80 + true + + +
  • + true + false + 0.01 + 1 + 5 + 0.05 + 0.05 + 1.5 +
  • +
    + +
  • + Shell_AcidSpitStream +
  • +
  • + Shell_AcidSpitLaunched +
  • +
    +
    + + + CatastropheMissile_Weapon + + 天灾导弹的发射系统。 + Spacer + + Things/Building/Security/TurretMortar_Top + Graphic_Single + + + 5.0 + 50 + + +
  • + Verb_Shoot + true + Projectile_CatastropheMissile + 1 + 3.0 + 1 + true + false + 10.9 + 1 + 500 + Shot_Autocannon + 16 + + true + +
  • +
    +
    + +
    \ No newline at end of file diff --git a/1.6/1.6/Defs/WorldObjectDefs/WorldObjectDef_CatastropheMissile.xml b/1.6/1.6/Defs/WorldObjectDefs/WorldObjectDef_CatastropheMissile.xml new file mode 100644 index 0000000..a9f4e11 --- /dev/null +++ b/1.6/1.6/Defs/WorldObjectDefs/WorldObjectDef_CatastropheMissile.xml @@ -0,0 +1,13 @@ + + + + + CatastropheMissile_Flying + + ArachnaeSwarm.WorldObject_CatastropheMissile + World/WorldObjects/Caravan + true + true + + + \ No newline at end of file diff --git a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/ArachnaeSwarm_MissileSilo.xml b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/ArachnaeSwarm_MissileSilo.xml new file mode 100644 index 0000000..b65457c --- /dev/null +++ b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/ArachnaeSwarm_MissileSilo.xml @@ -0,0 +1,17 @@ + + + + 发射远程打击 + 选择一个世界地图上的目标进行远程打击。 + + 发射本地打击 + 手动命令炮塔攻击一个本地地图上的目标。 + + 远程目标已设定:{0} + 远程打击的目标必须是一个已探索的地点。 + 没有可用的导弹。 + + 取消所有目标 + 取消当前设定的所有本地和远程目标。 + + \ No newline at end of file diff --git a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Wormhole_Keys.xml b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Wormhole_Keys.xml deleted file mode 100644 index 12fc1e7..0000000 --- a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Wormhole_Keys.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - 部署虫洞传送门 - 选择一名驾驶员来启动一个B端传送门。 - 没有可用的驾驶员 - - \ No newline at end of file diff --git a/CatastropheMissileSilo_Implementation_Plan.md b/CatastropheMissileSilo_Implementation_Plan.md new file mode 100644 index 0000000..2cf1df7 --- /dev/null +++ b/CatastropheMissileSilo_Implementation_Plan.md @@ -0,0 +1,468 @@ +# 天灾导弹防御塔 - C# 实现计划 + +本文档包含构建“天灾导弹防御塔”所需的全部C#类的源代码。这些代码基于 `Rimatomics` 的跨地图打击框架,并与我们自己的 `Projectile_CruiseMissile` 弹头相结合。 + +--- + +## 1. 建筑类: `Building_CatastropheMissileSilo.cs` + +**路径**: `Source/ArachnaeSwarm/Buildings/Building_CatastropheMissileSilo.cs` + +**功能**: 这是导弹发射井的建筑本体,负责处理玩家的瞄准指令。它几乎是 `Building_Railgun` 的翻版,但进行了一些重命名和命名空间调整。 + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace ArachnaeSwarm +{ + [StaticConstructorOnStartup] + public class Building_CatastropheMissileSilo : Building + { + public CompPowerTrader powerComp; + public CompRefuelable refuelableComp; + public Verb_LaunchCatastropheMissile verb; + + private GlobalTargetInfo longTargetInt; + private List selectedSilos; + + public static readonly Texture2D FireMissionTex = ContentFinder.Get("UI/Commands/Attack", true); + + public override void SpawnSetup(Map map, bool respawningAfterLoad) + { + base.SpawnSetup(map, respawningAfterLoad); + this.powerComp = base.GetComp(); + this.refuelableComp = base.GetComp(); + // This is a placeholder, verb will be initialized from the weapon ThingDef + this.verb = (Verb_LaunchCatastropheMissile)Activator.CreateInstance(typeof(Verb_LaunchCatastropheMissile)); + this.verb.caster = this; + } + + public override void ExposeData() + { + base.ExposeData(); + Scribe_TargetInfo.Look(ref this.longTargetInt, "longTargetInt"); + } + + public override IEnumerable GetGizmos() + { + foreach (Gizmo c in base.GetGizmos()) + { + yield return c; + } + + Command_Action launch = new Command_Action + { + defaultLabel = "CommandFireMission".Translate(), + defaultDesc = "CommandFireMissionDesc".Translate(), + icon = FireMissionTex, + action = new Action(this.StartChoosingDestination) + }; + + if (!CanFire()) + { + launch.Disable("CannotFire".Translate() + ": " + GetDisabledReason()); + } + yield return launch; + } + + private bool CanFire() + { + return powerComp.PowerOn && refuelableComp.HasFuel; + } + + private string GetDisabledReason() + { + if (!powerComp.PowerOn) return "NoPower".Translate().CapitalizeFirst(); + if (!refuelableComp.HasFuel) return "NoFuel".Translate().CapitalizeFirst(); + return "Unknown"; + } + + private void StartChoosingDestination() + { + this.selectedSilos = Find.Selector.SelectedObjects.OfType().ToList(); + CameraJumper.TryJump(CameraJumper.GetWorldTarget(this), CameraJumper.MovementMode.Pan); + Find.WorldSelector.ClearSelection(); + + Find.WorldTargeter.BeginTargeting( + new Func(this.ChoseWorldTarget), + true, // Can target self + FireMissionTex, + true, + () => GenDraw.DrawWorldRadiusRing(this.Map.Tile, this.MaxWorldRange), + (GlobalTargetInfo t) => "Select target", + null, null, true); + } + + private bool ChoseWorldTarget(GlobalTargetInfo target) + { + if (!target.IsValid) + { + Messages.Message("MessageTargetInvalid".Translate(), MessageTypeDefOf.RejectInput, true); + return false; + } + + int distance = Find.WorldGrid.TraversalDistanceBetween(this.Map.Tile, target.Tile, true, int.MaxValue); + if (distance > this.MaxWorldRange) + { + Messages.Message("MessageTargetBeyondMaximumRange".Translate(), this, MessageTypeDefOf.RejectInput, true); + return false; + } + + MapParent mapParent = target.WorldObject as MapParent; + if (mapParent != null && mapParent.HasMap) + { + Map targetMap = mapParent.Map; + var originalMap = base.Map; + + Action onFinished = () => { + if (Current.Game.CurrentMap != originalMap) Current.Game.CurrentMap = originalMap; + }; + + Current.Game.CurrentMap = targetMap; + + Find.Targeter.BeginTargeting( + new TargetingParameters { canTargetLocations = true }, + (LocalTargetInfo localTarget) => + { + foreach (var silo in this.selectedSilos) + { + silo.FireMission(targetMap.Tile, localTarget, targetMap.uniqueID); + } + }, + null, onFinished, FireMissionTex, true); + + return true; + } + else // For non-map targets (caravans, sites) + { + foreach (var silo in this.selectedSilos) + { + silo.FireMission(target.Tile, new LocalTargetInfo(target.Cell), -1); + } + return true; + } + } + + public void FireMission(int tile, LocalTargetInfo targ, int mapId) + { + if (!targ.IsValid) + { + this.longTargetInt = GlobalTargetInfo.Invalid; + return; + } + + Map targetMap = (mapId != -1) ? Find.Maps.FirstOrDefault(m => m.uniqueID == mapId) : null; + this.longTargetInt = new GlobalTargetInfo(targ.Cell, targetMap); + + this.verb.verbProps.defaultProjectile.GetModExtension(); // Ensure verb has properties. + this.verb.TryStartCastOn(this.longTargetInt); + } + + public int MaxWorldRange => 99999; // Effectively global range + + public GlobalTargetInfo LongTarget => longTargetInt; + } +} +``` + +--- + +## 2. 动作类: `Verb_LaunchCatastropheMissile.cs` + +**路径**: `Source/ArachnaeSwarm/Verbs/Verb_LaunchCatastropheMissile.cs` + +**功能**: 定义发射动作。它会创建 `WorldObject_CatastropheMissile` 并将其发射到世界地图。 + +```csharp +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; + +namespace ArachnaeSwarm +{ + public class Verb_LaunchCatastropheMissile : Verb_Shoot + { + public override bool CanHitTargetFrom(IntVec3 root, LocalTargetInfo targ) + { + return true; // Always true for world-map targeting + } + + protected override bool TryCastShot() + { + Building_CatastropheMissileSilo silo = this.caster as Building_CatastropheMissileSilo; + if (silo == null || !silo.LongTarget.IsValid) return false; + + WorldObject_CatastropheMissile missile = (WorldObject_CatastropheMissile)WorldObjectMaker.MakeWorldObject( + DefDatabase.GetNamed("CatastropheMissile_Flying") + ); + + missile.Tile = silo.Map.Tile; + missile.destinationTile = silo.LongTarget.Tile; + missile.destinationCell = silo.LongTarget.Cell; + missile.Projectile = this.verbProps.defaultProjectile; + + Find.WorldObjects.Add(missile); + + // Consume fuel + silo.refuelableComp.ConsumeFuel(silo.refuelableComp.Props.fuelConsumptionRate); + + // Visual/Sound effects at launch site + MoteMaker.MakeStaticMote(silo.TrueCenter(), silo.Map, ThingDefOf.Mote_ExplosionFlash, 10f); + SoundDefOf.RocketLaunch.PlayOneShot(new TargetInfo(silo.Position, silo.Map)); + + return true; + } + } +} +``` + +--- + +## 3. 飞行物类: `WorldObject_CatastropheMissile.cs` + +**路径**: `Source/ArachnaeSwarm/World/WorldObject_CatastropheMissile.cs` + +**功能**: 模拟导弹在世界地图上的飞行,并在抵达时生成 `Projectile_CruiseMissile`。 + +```csharp +using RimWorld.Planet; +using UnityEngine; +using Verse; +using RimWorld; + +namespace ArachnaeSwarm +{ + public class WorldObject_CatastropheMissile : WorldObject + { + public int destinationTile = -1; + public IntVec3 destinationCell = IntVec3.Invalid; + public ThingDef Projectile; + + private int initialTile = -1; + private float traveledPct; + private const float TravelSpeed = 0.0002f; // Faster than sabot + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref destinationTile, "destinationTile", 0); + Scribe_Values.Look(ref destinationCell, "destinationCell"); + Scribe_Defs.Look(ref Projectile, "Projectile"); + Scribe_Values.Look(ref initialTile, "initialTile", 0); + Scribe_Values.Look(ref traveledPct, "traveledPct", 0f); + } + + public override void PostAdd() + { + base.PostAdd(); + this.initialTile = this.Tile; + } + + private Vector3 StartPos => Find.WorldGrid.GetTileCenter(this.initialTile); + private Vector3 EndPos => Find.WorldGrid.GetTileCenter(this.destinationTile); + + public override Vector3 DrawPos => Vector3.Slerp(StartPos, EndPos, traveledPct); + + public override void Tick() + { + base.Tick(); + traveledPct += TravelSpeed / GenMath.SphericalDistance(StartPos.normalized, EndPos.normalized); + + if (traveledPct >= 1f) + { + Arrived(); + } + } + + private void Arrived() + { + Map targetMap = Current.Game.FindMap(this.destinationTile); + if (targetMap != null) + { + // Target is a loaded map, spawn the projectile to hit it + IntVec3 entryCell = Find.WorldGrid.GetRotatedPos(new IntVec2(0, 1), targetMap.info.parent.Rotation).ToIntVec3() * (targetMap.Size.x / 2); + entryCell.y = 0; + entryCell.z += targetMap.Size.z / 2; + + Projectile_CruiseMissile missile = (Projectile_CruiseMissile)GenSpawn.Spawn(this.Projectile, entryCell, targetMap, WipeMode.Vanish); + missile.Launch(this, this.destinationCell, this.destinationCell, ProjectileHitFlags.IntendedTarget); + } + else + { + // Target is not a loaded map (e.g., caravan, site), do direct damage + GenExplosion.DoExplosion( + this.destinationCell, + null, // No map + this.Projectile.GetModExtension()?.customExplosionRadius ?? 5f, + this.Projectile.GetModExtension()?.customDamageDef ?? DamageDefOf.Bomb, + null, // Launcher + this.Projectile.GetModExtension()?.customDamageAmount ?? 50 + ); + } + + Find.WorldObjects.Remove(this); + } + } +} +--- + +## 4. XML 定义 + +以下是实现天灾导弹防御塔所需的所有XML定义。 + +### 4.1 建筑定义: `ThingDef_Building_CatastropheMissileSilo.xml` + +**路径**: `1.6/Defs/ThingDefs_Buildings/ThingDef_Building_CatastropheMissileSilo.xml` + +**功能**: 定义导弹发射井这个建筑,链接到 `Building_CatastropheMissileSilo` 类,并配置其电力、燃料和内置武器。 + +```xml + + + + + CatastropheMissileSilo + + 一个重型加固的导弹发射设施,能够将“天灾”级巡航导弹发射到全球任何一个角落。需要大量的电力和化学燃料来运作。 + ArachnaeSwarm.Building_CatastropheMissileSilo + + Things/Building/Security/ShipMissileTurret + Graphic_Single + (3,3) + + (3,3) + + 500 + 8000 + 1000 + 0.5 + + + 400 + 150 + 10 + 4 + + +
  • + CompPowerTrader + 500 +
  • +
  • + 10 + 10.0 + + +
  • Chemfuel
  • + + + true + +
    + + +
  • + ArachnaeSwarm.Verb_LaunchCatastropheMissile + false + Projectile_CatastropheMissile +
  • +
    + + true + + +
  • ShipbuildingBasics
  • +
    +
    + +
    +``` + +### 4.2 武器/投射物定义: `ThingDef_Projectile_CatastropheMissile.xml` + +**路径**: `1.6/Defs/ThingDefs_Misc/ThingDef_Projectile_CatastropheMissile.xml` + +**功能**: 定义导弹本身 (`Projectile_CatastropheMissile`) 和它作为投射物的行为 (`ThingDef` of `Projectile_CruiseMissile`)。这里是配置集束弹头和弹道参数的地方。 + +```xml + + + + + Projectile_CatastropheMissile + + ArachnaeSwarm.Projectile_CruiseMissile + + Things/Projectile/Missile + Graphic_Single + + + Bomb + 200 + 10 + 5.9 + MortarBomb_Explode + + + +
  • + + Bomb + 150 + 5.9 + MortarBomb_Explode + + + true + 8 + 2.9 + 50 + 15 + Bomb + Fragment_Explode + + + 0.05 + 5 + 20 + 0.1 + 0.2 + 0.5 +
  • +
    +
    + +
    +``` + +### 4.3 飞行物定义: `WorldObjectDef_CatastropheMissile.xml` + +**路径**: `1.6/Defs/WorldObjectDefs/WorldObjectDef_CatastropheMissile.xml` + +**功能**: 定义在世界地图上飞行的那个导弹实体。 + +```xml + + + + + CatastropheMissile_Flying + + ArachnaeSwarm.WorldObject_CatastropheMissile + World/WorldObjects/Missile + true + false + false + true + + + +``` \ No newline at end of file diff --git a/Railgun_System_Documentation.md b/Railgun_System_Documentation.md new file mode 100644 index 0000000..986fb33 --- /dev/null +++ b/Railgun_System_Documentation.md @@ -0,0 +1,743 @@ +# Rimatomics 电磁炮战略打击系统技术文档 + +## 1. 引言 + +本文档旨在详细解析 Rimatomics Mod 中的电磁炮(Railgun)远程战略打击系统的实现机制。该系统允许玩家对世界地图上的任意(已加载的)地图进行精确的火力投送,是游戏中一种强大的后期战略武器。 + +我们将通过分析其三个核心 C# 类:`Building_Railgun`、`Verb_Railgun` 和 `WorldObject_Sabot`,来深入理解其功能、设计模式以及类之间的交互。 + +## 2. 核心组件概览 + +整个系统由三个主要部分协同工作,各司其职: + +* **`Building_Railgun`**: 炮塔建筑本身,是整个系统的用户交互入口和武器平台。它负责处理玩家的瞄准指令,管理武器的属性(如射程、精度),并发起射击流程。 +* **`Verb_Railgun`**: 定义了电磁炮的“射击”这一具体动作。它能够智能区分常规的本地射击和需要跨地图飞行的远程打击,并根据不同模式执行相应的逻辑。 +* **`WorldObject_Sabot`**: 一个在世界地图上存在的临时对象,用于模拟炮弹在飞向目标过程中的状态。它负责处理跨地图飞行、轨迹计算,并在抵达目标后触发最终的命中效果。 + +## 3. 类详解 + +### 3.1 `Building_Railgun` + +`Building_Railgun` 是电磁炮塔的建筑类,继承自 `Building_EnergyWeapon`。它是玩家能直接看到并与之交互的实体。 + +#### 功能概述 + +此类作为系统的交互枢纽,主要负责以下功能: +* **提供玩家操作界面**:通过 Gizmo(操作按钮)让玩家启动“火控任务”。 +* **处理目标选择流程**:引导玩家从世界地图选择目标区域,再到目标地图内选择精确弹着点。 +* **管理武器状态与属性**:存储远程目标信息,并根据已安装的升级动态计算射程、散布等属性。 +* **发起射击指令**:在获取有效目标后,命令自身的武器组件(Verb)开火。 + +#### 源代码 + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using Multiplayer.API; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Rimatomics +{ + [StaticConstructorOnStartup] + public class Building_Railgun : Building_EnergyWeapon + { + public override bool TurretBased + { + get + { + return true; + } + } + + public override Vector3 TipOffset + { + get + { + Vector3 pos = this.DrawPos; + Vector3 vecOffset = new Vector3(0f, 1f, 5f); + vecOffset = vecOffset.RotatedBy(this.TurretRotation); + return pos + vecOffset; + } + } + + public override bool CanFireWhileRoofed() + { + // For remote fire missions, cannot be roofed. For local targets, it's allowed. + if (this.longTargetInt == GlobalTargetInfo.Invalid) + { + return true; + } + return !base.Position.Roofed(base.Map); + } + + public float RangeToWorldTarget + { + get + { + return (float)Find.WorldGrid.TraversalDistanceBetween(base.Map.Tile, this.longTarget.Tile, true, int.MaxValue, false); + } + } + + public void TryChamberRound() + { + if (this.magazine.NullOrEmpty()) + { + return; + } + + CompChangeableProjectile comp = this.gun.TryGetComp(); + if (!comp.Loaded) + { + comp.LoadShell(this.magazine.FirstOrDefault(), 1); + this.magazine.RemoveAt(0); + } + } + + public override void MuzzleFlash() + { + Mote flash = (Mote)ThingMaker.MakeThing(Building_Railgun.Mote_RailgunMuzzleFlash, null); + flash.Scale = 5f; + flash.exactRotation = this.TurretRotation; + flash.exactPosition = this.TipOffset; + GenSpawn.Spawn(flash, base.Position, base.Map, WipeMode.Vanish); + + Vector3 vecOffset = new Vector3(1f, 1f, -1f).RotatedBy(this.TurretRotation); + FleckMaker.ThrowSmoke(this.DrawPos + vecOffset, base.Map, 1.5f); + + Vector3 vecOffset2 = new Vector3(-1f, 1f, -1f).RotatedBy(this.TurretRotation); + FleckMaker.ThrowSmoke(this.DrawPos + vecOffset2, base.Map, 1.5f); + } + + public override IEnumerable GetGizmos() + { + foreach (Gizmo c in base.GetGizmos()) + { + yield return c; + } + + Command_Action launch = new Command_Action + { + defaultLabel = "critCommandFireMission".Translate(), + defaultDesc = "critCommandFireMissionDesc".Translate(), + icon = Building_Railgun.FireMissionTex, + action = new Action(this.StartChoosingDestination) + }; + + if (base.Spawned && base.Position.Roofed(base.Map)) + { + launch.Disable("CannotFire".Translate() + ": " + "Roofed".Translate().CapitalizeFirst()); + } + yield return launch; + } + + private void StartChoosingDestination() + { + // Record all selected railguns to fire them simultaneously + this.selectedRailguns = Find.Selector.SelectedObjects.OfType().ToList(); + + CameraJumper.TryJump(CameraJumper.GetWorldTarget(this), CameraJumper.MovementMode.Pan); + Find.WorldSelector.ClearSelection(); + + int tile = base.Map.Tile; + Find.WorldTargeter.BeginTargeting( + new Func(this.ChoseWorldTarget), // Callback when target is chosen + false, + Building_Railgun.FireMissionTex, + true, + () => GenDraw.DrawWorldRadiusRing(tile, this.WorldRange, null), // Draws the range ring + null, null, null, false); + } + + public static TargetingParameters ForFireMission() + { + // In multiplayer, disallow targeting mobile pawns to prevent desync issues. + if (MP.IsInMultiplayer) + { + return new TargetingParameters + { + canTargetPawns = false, + canTargetBuildings = false, + canTargetLocations = true + }; + } + + return new TargetingParameters + { + canTargetPawns = true, + canTargetBuildings = true, + canTargetLocations = true + }; + } + + private bool ChoseWorldTarget(GlobalTargetInfo target) + { + if (!target.IsValid) + { + Messages.Message("MessageRailgunTargetInvalid".Translate(), MessageTypeDefOf.RejectInput, true); + return false; + } + + int distance = Find.WorldGrid.TraversalDistanceBetween(base.Map.Tile, target.Tile, true, int.MaxValue, false); + if (distance > this.WorldRange) + { + Messages.Message("MessageTargetBeyondMaximumRange".Translate(), this, MessageTypeDefOf.RejectInput, true); + return false; + } + + MapParent mapParent = target.WorldObject as MapParent; + if (mapParent != null && mapParent.HasMap) + { + if (mapParent.Map == base.Map) + { + Messages.Message("MessageRailgunCantTargetMyMap".Translate(), MessageTypeDefOf.RejectInput, true); + return false; + } + + // --- Refactored Code Block --- + // This block handles the logic for fine-grained targeting within the destination map. + Map targetMap = mapParent.Map; + var originalMap = base.Map; + + // Action to execute when targeting is finished or cancelled. + Action onFinished = () => { + if (Current.Game.CurrentMap != originalMap) + { + Current.Game.CurrentMap = originalMap; + } + }; + + // Switch to the target map to allow the player to pick a precise location. + Current.Game.CurrentMap = targetMap; + + Find.Targeter.BeginTargeting( + Building_Railgun.ForFireMission(), + (LocalTargetInfo localTarget) => // Lambda for when a local target is selected + { + // Assign the fire mission to all selected railguns. + foreach (Building_Railgun railgun in this.selectedRailguns) + { + railgun.FireMission(targetMap.Tile, localTarget, targetMap.uniqueID); + } + }, + null, // Action on highlight + onFinished, // Action on finish/cancel + Building_Railgun.FireMissionTex, + true); + + return true; + } + else + { + Messages.Message("MessageRailgunNeedsMap".Translate(), MessageTypeDefOf.RejectInput, true); + return false; + } + } + + [SyncMethod(SyncContext.None)] + public void FireMission(int tile, LocalTargetInfo targ, int map) + { + if (!targ.IsValid) + { + this.longTargetInt = GlobalTargetInfo.Invalid; + return; + } + + GlobalTargetInfo newtarget = targ.ToGlobalTargetInfo(Find.Maps.FirstOrDefault((Map x) => x.uniqueID == map)); + int distance = Find.WorldGrid.TraversalDistanceBetween(base.Map.Tile, tile, true, int.MaxValue, false); + + if (distance > this.WorldRange) + { + Messages.Message("MessageTargetBeyondMaximumRange".Translate(), this, MessageTypeDefOf.RejectInput, true); + return; + } + + if (this.holdFire) + { + Messages.Message("MessageTurretWontFireBecauseHoldFire".Translate(this.def.label), this, MessageTypeDefOf.RejectInput, true); + return; + } + + if (this.longTargetInt != newtarget) + { + this.longTargetInt = newtarget; + if (this.burstCooldownTicksLeft <= 0) + { + this.TryStartShootSomething(); + } + SoundDefOf.TurretAcquireTarget.PlayOneShot(new TargetInfo(base.Position, base.Map, false)); + } + } + + public override float PulseSize + { + get + { + float f = base.GunProps.EnergyWep.PulseSizeScaled; + if (this.UG.HasUpgrade(DubDef.MEPS)) + { + f *= 1.15f; + } + if (this.UG.HasUpgrade(DubDef.ERS)) + { + f *= 0.85f; // Equivalent to f -= 0.15f * f + } + return f; + } + } + + public override int WorldRange + { + get + { + // In space (SoS2 compatibility), range is unlimited. + if (base.Map != null && this.space != null && base.Map.Biome == this.space) + { + return 99999; + } + + int range = base.GunProps.EnergyWep.WorldRange; + if (this.UG.HasUpgrade(DubDef.TargetingChip)) + { + range += 10; + } + if (this.UG.HasUpgrade(DubDef.MEPS)) + { + range += 10; + } + return range; + } + } + + public int spread + { + get + { + int v = 6; + if (this.UG.HasUpgrade(DubDef.TargetingChip)) + { + v -= 3; + } + return v; + } + } + + public static readonly Texture2D FireMissionTex = ContentFinder.Get("Rimatomics/UI/FireMission", true); + public static ThingDef Mote_RailgunMuzzleFlash = ThingDef.Named("Mote_RailgunMuzzleFlash"); + private List selectedRailguns; + public BiomeDef space = DefDatabase.GetNamed("OuterSpaceBiome", false); + } +} +``` + +#### 关键方法和属性详解 + +* **`GetGizmos()`**: 为炮塔添加“火控任务” (Fire Mission) 按钮,这是远程打击流程的起点。如果炮塔被屋顶遮挡,该按钮会被禁用。 +* **`StartChoosingDestination()`**: 响应按钮点击,将视角切换到世界地图,并启动一个带射程圈的目标选择器,让玩家选择目标地块。它支持多炮塔同时选择。 +* **`ChoseWorldTarget(GlobalTargetInfo target)`**: 当玩家在世界地图上选择目标后的回调函数。它会进行一系列合法性验证(如射程、是否为当前地图等),如果通过,则将视角切换到目标地图,让玩家选择精确落点。 +* **`FireMission(int tile, LocalTargetInfo targ, int map)`**: 这是目标选择的最后一步。它将最终的、精确的目标信息(包含地块ID和地图内坐标)存入 `longTargetInt` 变量,并立即尝试触发射击 (`TryStartShootSomething()`)。该方法支持多人游戏同步。 +* **`WorldRange` (get)**: 一个计算属性,返回炮塔在世界地图上的最大射程。基础射程定义在XML中,并会受到 `TargetingChip` 和 `MEPS` 等升级的加成。 +* **`spread` (get)**: 计算属性,返回炮弹的散布半径。`TargetingChip` 升级可以减小此值,提高精度。 +* **`CanFireWhileRoofed()`**: 重写方法,规定在执行远程打击 (`longTargetInt` 有效) 时,炮塔不能处于屋顶之下。 + +### 3.2 `Verb_Railgun` + +`Verb_Railgun` 继承自 `Verb_RimatomicsVerb`,它定义了电磁炮武器的“射击”这个核心动作。它不处理玩家交互,只负责执行射击逻辑。 + +#### 功能概述 + +这个类的主要职责是根据炮塔当前的目标状态(本地目标 vs 远程目标),决定执行哪种射击模式。 + +* **动态炮弹选择**: 根据 `CompChangeableProjectile` 组件的状态,决定是发射已装填的特殊炮弹还是默认炮弹。 +* **射击模式分发**: 检查是否存在远程目标 (`longTarget`),如果存在,则执行“火控任务”流程;否则,执行标准的本地射击。 +* **远程打击发起**: 在“火控任务”模式下,负责创建并初始化 `WorldObject_Sabot`,将其发射到世界地图。 +* **本地打击后效**: 为本地射击附加额外的效果,如能量消耗、数据收集和屏幕震动。 + +#### 源代码 + +```csharp +using System; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; + +namespace Rimatomics +{ + [StaticConstructorOnStartup] + public class Verb_Railgun : Verb_RimatomicsVerb + { + public override ThingDef Projectile + { + get + { + ThingWithComps equipmentSource = base.EquipmentSource; + CompChangeableProjectile comp = (equipmentSource != null) ? equipmentSource.GetComp() : null; + if (comp != null && comp.Loaded) + { + return comp.Projectile; + } + return this.verbProps.defaultProjectile; + } + } + + public override bool CanHitTargetFrom(IntVec3 root, LocalTargetInfo targ) + { + // If a long-range target is set, we can "hit" it regardless of local range. + if (base.GetWep.longTarget.IsValid) + { + return true; + } + return base.CanHitTargetFrom(root, targ); + } + + protected bool TryCastFireMission() + { + Building_Railgun railgun = this.caster as Building_Railgun; + + // Create the flying sabot object on the world map + WorldObject_Sabot sabot = (WorldObject_Sabot)WorldObjectMaker.MakeWorldObject(DefDatabase.GetNamed("Sabot", true)); + sabot.railgun = railgun; + sabot.Tile = railgun.Map.Tile; + sabot.destinationTile = railgun.longTarget.Tile; + sabot.destinationCell = railgun.longTarget.Cell; + sabot.spread = railgun.spread; + sabot.Projectile = this.Projectile; + Find.WorldObjects.Add(sabot); + + // Post-launch effects and data gathering + railgun.GatherData("PPCWeapon", 5f); + railgun.GatherData("PPCFireMission", 10f); + railgun.GatherData("PPCRailgun", 10f); + railgun.PrototypeBang(railgun.GunProps.EnergyWep.PrototypeFailureChance); + railgun.MuzzleFlash(); + Find.CameraDriver.shaker.SetMinShake(0.1f); + + // Spawn a dummy projectile that flies off-map. This is for visual effect only. + Vector3 shellDirection = Vector3.forward.RotatedBy(railgun.TurretRotation); + IntVec3 outcell = (railgun.DrawPos + shellDirection * 500f).ToIntVec3(); + Projectile projectile2 = (Projectile)GenSpawn.Spawn(this.Projectile, railgun.Position, this.caster.Map, WipeMode.Vanish); + projectile2.Launch(railgun, railgun.DrawPos, outcell, null, ProjectileHitFlags.None, false, base.EquipmentSource, null); + + // Handle shell consumption + CompChangeableProjectile comp = base.EquipmentSource?.GetComp(); + if (comp != null) + { + comp.Notify_ProjectileLaunched(); + } + + railgun.DissipateCharge(railgun.PulseSize); + return true; + } + + public override bool TryCastShot() + { + Building_Railgun railgun = this.caster as Building_Railgun; + if (!railgun.top.TargetInSights) + { + return false; + } + if (this.Projectile == null) + { + return false; + } + + bool shotResult; + // --- Shooting Mode Dispatch --- + if (railgun.longTarget.IsValid) + { + // Execute long-range fire mission + shotResult = this.TryCastFireMission(); + } + else + { + // Execute standard local shot + shotResult = base.TryCastShot(); + if (shotResult) + { + railgun.DissipateCharge(railgun.PulseSize); + railgun.GatherData("PPCWeapon", 5f); + railgun.GatherData("PPCRailgun", 10f); + railgun.PrototypeBang(railgun.GunProps.EnergyWep.PrototypeFailureChance); + railgun.MuzzleFlash(); + Find.CameraDriver.shaker.SetMinShake(0.1f); + } + } + + // Chamber the next round after firing + railgun.TryChamberRound(); + return shotResult; + } + } +} +``` + +#### 关键方法和属性详解 + +* **`Projectile` (get)**: 这是一个动态属性,用于获取当前应该发射的炮弹类型。它会优先返回 `CompChangeableProjectile` 中已装填的炮弹,如果未装填,则返回在 XML 中定义的默认炮弹。 +* **`CanHitTargetFrom(...)`**: 重写了基类方法。当炮塔被赋予了一个远程目标 (`longTarget.IsValid`) 时,该方法直接返回 `true`,绕过了所有常规的射程和视线检查,确保远程打击流程可以启动。 +* **`TryCastShot()`**: 这是射击动作的入口点。它的核心逻辑是检查 `railgun.longTarget` 是否有效。 + * 如果**有效**,说明是远程打击任务,它便调用 `TryCastFireMission()`。 + * 如果**无效**,说明是常规的本地瞄准,它就调用基类的 `base.TryCastShot()` 来发射普通炮弹,并附加一系列开火后效。 +* **`TryCastFireMission()`**: 这是发起远程打击的核心。它不直接生成命中目标的炮弹,而是: + 1. 创建一个 `WorldObject_Sabot` 实例。 + 2. 将目标地块、精确坐标、炮弹类型、散布等关键信息从 `Building_Railgun` 传递给 `sabot` 对象。 + 3. 将 `sabot` 添加到世界对象管理器 (`Find.WorldObjects.Add(sabot)`),让其开始在世界地图上“飞行”。 + 4. 触发炮口闪光、能量消耗、屏幕震动等本地开火效果。 + 5. 生成一个飞向地图外的“虚拟”炮弹,仅用于视觉表现,它不会造成任何伤害。 + +### 3.3 `WorldObject_Sabot` + +`WorldObject_Sabot` 继承自 `WorldObject`,是远程打击流程中的“飞行”阶段的执行者。它是一个临时的、在世界地图上存在的实体,模拟了炮弹从发射点到目标点的飞行过程。 + +#### 功能概述 + +此类完全独立于发射它的炮塔,其核心职责是在世界地图上完成一段旅程,并在抵达终点时触发命中效果。 + +* **飞行轨迹模拟**: 通过 `DrawPos` 属性和 `Tick` 方法,平滑地计算并更新自身在世界地图上的位置,实现飞行-动画效果。 +* **状态持久化**: 通过 `ExposeData` 方法保存所有关键信息(如起点、终点、飞行进度、炮弹类型),确保在游戏存读档后飞行可以继续。 +* **命中触发**: 在飞行到达终点后,`Arrived` 方法负责在目标地图上生成真正的 `Projectile`(炮弹),并让其从地图边缘发射,命中最终的精确弹着点。 +* **自我销毁**: 完成命中逻辑后,将自身从世界对象管理器中移除。 +* **Mod兼容性**: 能够识别并处理 "Save Our Ship 2" Mod 中的轨道飞船,实现地对空、空对地和空对空打击。 + +#### 源代码 + +```csharp +using System; +using System.Linq; +using RimWorld.Planet; +using UnityEngine; +using Verse; + +namespace Rimatomics +{ + public class WorldObject_Sabot : WorldObject + { + private Vector3 Start + { + get + { + Vector3 startPos = Find.WorldGrid.GetTileCenter(this.initialTile); + // SoS2 compatibility: if a ship is at the tile, use its position. + if (HarmonyPatches.SoS) + { + WorldObject ship = Find.World.worldObjects.AllWorldObjects.FirstOrDefault(o => + (o.def.defName.Equals("ShipOrbiting") || o.def.defName.Equals("SiteSpace")) && o.Tile == this.initialTile); + if (ship != null) + { + startPos = ship.DrawPos; + } + } + return startPos; + } + } + + private Vector3 End + { + get + { + Vector3 endPos = Find.WorldGrid.GetTileCenter(this.destinationTile); + // SoS2 compatibility + if (HarmonyPatches.SoS) + { + WorldObject ship = Find.World.worldObjects.AllWorldObjects.FirstOrDefault(o => + (o.def.defName.Equals("ShipOrbiting") || o.def.defName.Equals("SiteSpace")) && o.Tile == this.destinationTile); + if (ship != null) + { + endPos = ship.DrawPos; + } + } + return endPos; + } + } + + public override Vector3 DrawPos + { + get + { + // Slerp for a smooth curve over the planet's surface + return Vector3.Slerp(this.Start, this.End, this.traveledPct); + } + } + + private float TraveledPctStepPerTick + { + get + { + Vector3 start = this.Start; + Vector3 end = this.End; + if (start == end) return 1f; + + float distance = GenMath.SphericalDistance(start.normalized, end.normalized); + if (distance == 0f) return 1f; + + // Travel speed is constant + return TravelSpeed / distance; + } + } + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref this.destinationTile, "destinationTile", 0, false); + Scribe_Values.Look(ref this.destinationCell, "destinationCell", default(IntVec3), false); + Scribe_Values.Look(ref this.arrived, "arrived", false, false); + Scribe_Values.Look(ref this.initialTile, "initialTile", 0, false); + Scribe_Values.Look(ref this.traveledPct, "traveledPct", 0f, false); + Scribe_Defs.Look(ref this.Projectile, "Projectile"); + Scribe_References.Look(ref this.railgun, "railgun"); + Scribe_Values.Look(ref this.spread, "spread", 1, false); + } + + public override void PostAdd() + { + base.PostAdd(); + this.initialTile = base.Tile; + } + + public override void Tick() + { + base.Tick(); + this.traveledPct += this.TraveledPctStepPerTick; + if (this.traveledPct >= 1f) + { + this.traveledPct = 1f; + this.Arrived(); + } + } + + private void Arrived() + { + if (this.arrived) return; + this.arrived = true; + + Map map = Current.Game.FindMap(this.destinationTile); + if (map != null) + { + // Spawn the projectile at the edge of the map + IntVec3 entryCell = new IntVec3(CellRect.WholeMap(map).Width / 2, 0, CellRect.WholeMap(map).maxZ); + Projectile projectile = (Projectile)GenSpawn.Spawn(this.Projectile, entryCell, map, WipeMode.Vanish); + + // Find a random cell near the target destination within the spread radius + IntVec3 finalDestination; + CellFinder.TryFindRandomCellNear(this.destinationCell, map, this.spread, null, out finalDestination, -1); + + // Launch the projectile to the final destination + projectile.Launch(this.railgun, finalDestination, finalDestination, ProjectileHitFlags.IntendedTarget, false, null); + } + + // Remove self from the world + Find.WorldObjects.Remove(this); + } + + private const float TravelSpeed = 0.0001f; + private bool arrived; + public IntVec3 destinationCell = IntVec3.Invalid; + public int destinationTile = -1; + private int initialTile = -1; + public ThingDef Projectile; + public Thing railgun; + public int spread = 1; + private float traveledPct; + } +} +``` + +#### 关键方法和属性详解 + +* **`Start` / `End` (get)**: 这两个属性负责计算飞行的起点和终点坐标。它们通过检查 `HarmonyPatches.SoS` 来判断是否加载了 "Save Our Ship 2" Mod,如果加载了,则会尝试获取轨道上飞船的位置作为起点/终点,从而实现与该Mod的无缝兼容。 +* **`DrawPos` (get)**: 重写属性,用于在世界地图上渲染该对象。它使用 `Vector3.Slerp`(球面线性插值)在起点和终点之间进行插值,根据 `traveledPct`(已飞行百分比)计算出当前帧应该在的位置,从而形成一条平滑的弧形飞行轨迹。 +* **`Tick()`**: 游戏引擎为每个世界对象调用的更新方法。它在每一帧增加 `traveledPct` 来推进飞行进度。当 `traveledPct` 达到1时,调用 `Arrived()`。 +* **`Arrived()`**: 这是飞行结束、触发命中的核心方法。它首先检查目标地块是否存在一个已加载的地图。如果存在,它会在该地图的边缘生成 `Projectile` 实体,并根据 `spread`(散布)在目标点附近随机一个最终落点,然后调用炮弹的 `Launch()` 方法完成最后的攻击。无论地图是否存在,它最终都会将自己从世界上移除。 +* **`ExposeData()`**: 保证了该飞行物体的所有状态(起点、终点、进度、炮弹类型等)都能被正确地保存和加载。 + +## 4. 系统工作流程 + +现在我们将三个核心组件联系起来,详细描述从玩家点击按钮到炮弹命中的完整工作流程。 + +### 4.1 交互时序图 + +下面的 Mermaid 时序图直观地展示了不同对象之间的交互顺序。 + +```mermaid +sequenceDiagram + participant Player as 玩家 + participant Railgun as Building_Railgun + participant Verb as Verb_Railgun + participant Sabot as WorldObject_Sabot + participant Projectile as 最终炮弹 + + Player->>Railgun: 点击 "Fire Mission" 按钮 + activate Railgun + Railgun->>Player: 显示世界地图和射程圈 + Player->>Railgun: 1. 选择世界目标 (地块) + Player->>Railgun: 2. 选择地图内目标 (坐标) + Railgun->>Railgun: 调用 FireMission() 设置 longTarget + Railgun->>Verb: 调用 TryStartShootSomething() + deactivate Railgun + + activate Verb + Verb->>Verb: TryCastShot() 检测到 longTarget + Verb->>Verb: 调用 TryCastFireMission() + Verb->>Sabot: new WorldObject_Sabot() + activate Sabot + Verb->>Sabot: 传递目标信息、炮弹类型等 + Verb-->>Player: 触发炮口闪光和声音 + deactivate Verb + + loop 飞行过程 (每Tick) + Sabot->>Sabot: 更新 traveledPct (飞行进度) + end + + Sabot->>Sabot: Arrived() - 到达目标 + Sabot->>Projectile: GenSpawn.Spawn(炮弹) + activate Projectile + Sabot->>Projectile: Launch() - 飞向最终落点 + Projectile-->>Player: 爆炸和伤害效果 + deactivate Projectile + + Sabot->>Sabot: Find.WorldObjects.Remove(this) + deactivate Sabot +``` + +### 4.2 步骤分解 + +1. **启动与目标选择 (玩家 -> `Building_Railgun`)** + * 玩家选中一门或多门 `Building_Railgun` 并点击其 "Fire Mission" Gizmo。 + * `GetGizmos` 触发 `StartChoosingDestination` 方法,视角切换至世界地图,并显示最大射程圈。 + * 玩家在世界地图上选择一个目标地块。 + * `ChoseWorldTarget` 方法被回调。在通过一系列验证后,视角切换到目标地图。 + * 玩家在目标地图上选择一个精确的弹着点。 + +2. **下达指令 (`Building_Railgun` -> `Verb_Railgun`)** + * 当精确弹着点被选定后,`ChoseWorldTarget` 方法会为所有被选中的炮塔调用 `FireMission` 方法。 + * `FireMission` 将包含地块和坐标的 `GlobalTargetInfo` 存入炮塔的 `longTarget` 变量中。 + * `FireMission` 随即调用 `TryStartShootSomething()`,这会启动炮塔的射击冷却计时器,并最终触发其Verb组件。 + +3. **发射与创建飞行物 (`Verb_Railgun` -> `WorldObject_Sabot`)** + * 炮塔的 `Verb_Railgun` 组件的 `TryCastShot` 方法被调用。 + * `TryCastShot` 检测到 `longTarget` 是有效的,因此它不会执行常规射击,而是调用 `TryCastFireMission`。 + * `TryCastFireMission` 创建一个 `WorldObject_Sabot` 的实例,并将目标信息、炮弹定义、散布等关键数据从 `Building_Railgun` 复制到这个新实例中。 + * `WorldObject_Sabot` 被添加到 `Find.WorldObjects` 管理器中,正式开始其生命周期。 + * 同时,`Verb_Railgun` 在本地触发开火的视觉和听觉效果。 + +4. **跨地图飞行 (`WorldObject_Sabot`)** + * `WorldObject_Sabot` 作为一个独立的世界对象,其 `Tick` 方法被游戏引擎在每一帧调用。 + * `Tick` 方法不断更新 `traveledPct` 属性,模拟飞行进度。 + * 其 `DrawPos` 属性根据 `traveledPct` 在世界地图上平滑地渲染出飞行轨迹。 + +5. **抵达与命中 (`WorldObject_Sabot` -> `Projectile`)** + * 当 `traveledPct` 达到100%时,`Arrived` 方法被调用。 + * `Arrived` 检查目标地块的地图是否已加载。 + * 如果地图已加载,它会在地图边缘 `GenSpawn.Spawn` 一个真正的 `Projectile`(最终炮弹)。 + * 根据从 `Building_Railgun` 继承来的 `spread` 值,在玩家指定的精确落点附近随机一个最终弹着点。 + * 调用 `Projectile.Launch()`,使其从地图边缘飞向并命中最终弹着点,产生爆炸和伤害。 + * 最后,`WorldObject_Sabot` 调用 `Find.WorldObjects.Remove(this)` 将自己从世界上移除,完成其使命。 + +## 5. 总结 + +Rimatomics的电磁炮系统是一个设计精良的远程打击模块。它通过将**交互(Building)**、**动作(Verb)**和**飞行(WorldObject)**三个阶段清晰地分离到不同的类中,实现了高度的内聚和低耦合。这种设计不仅使得代码逻辑清晰、易于维护,还通过 `WorldObject` 机制优雅地解决了跨地图状态同步和持久化的问题,并为兼容其他Mod(如Save Our Ship 2)留出了接口。 diff --git a/Source/ArachnaeSwarm/ArachnaeSwarm.csproj b/Source/ArachnaeSwarm/ArachnaeSwarm.csproj index 727b797..54c6891 100644 --- a/Source/ArachnaeSwarm/ArachnaeSwarm.csproj +++ b/Source/ArachnaeSwarm/ArachnaeSwarm.csproj @@ -209,6 +209,10 @@ + + + + diff --git a/Source/ArachnaeSwarm/Buildings/Building_CatastropheMissileSilo.cs b/Source/ArachnaeSwarm/Buildings/Building_CatastropheMissileSilo.cs new file mode 100644 index 0000000..332f412 --- /dev/null +++ b/Source/ArachnaeSwarm/Buildings/Building_CatastropheMissileSilo.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityEngine; +using Verse; +using Verse.AI; +using Verse.Sound; +using RimWorld; +using RimWorld.Planet; + +namespace ArachnaeSwarm +{ + [StaticConstructorOnStartup] + public class Building_CatastropheMissileSilo : Building_TurretGun + { + public GlobalTargetInfo longTarget; + public static readonly Texture2D FireMissionTex = ContentFinder.Get("UI/Commands/Attack", true); + + public override void SpawnSetup(Map map, bool respawningAfterLoad) + { + base.SpawnSetup(map, respawningAfterLoad); + if (!respawningAfterLoad) + { + this.longTarget = GlobalTargetInfo.Invalid; + } + } + + public override void ExposeData() + { + base.ExposeData(); + Scribe_TargetInfo.Look(ref this.longTarget, "longTarget"); + } + + protected override void Tick() + { + // Base tick handles all local targeting, cooldowns, and fuel consumption via XML. + base.Tick(); + + // If a local target is active, prevent remote targeting. + if (this.forcedTarget.IsValid && this.longTarget.IsValid) + { + this.longTarget = GlobalTargetInfo.Invalid; + } + + // If a remote target is set and the turret is ready, fire. + // The base.Tick() cooldown handling prevents this from running if a local shot was just fired. + if (this.longTarget.IsValid && this.burstCooldownTicksLeft <= 0 && base.Active && CanFireGlobal(out _)) + { + this.FireMission(this.longTarget); + } + } + + public override string GetInspectString() + { + StringBuilder sb = new StringBuilder(base.GetInspectString()); + + if (burstCooldownTicksLeft > 0) + { + if (sb.Length > 0) sb.AppendLine(); + sb.Append("Cooldown".Translate() + ": " + this.burstCooldownTicksLeft.ToStringTicksToPeriod()); + } + + if (this.longTarget.IsValid) + { + if (sb.Length > 0) sb.AppendLine(); + sb.Append("RemoteTargetSet".Translate(this.longTarget.Label)); + } + return sb.ToString(); + } + + public override IEnumerable GetGizmos() + { + foreach (var g in base.GetGizmos()) + { + yield return g; + } + + Command_Action fireGlobal = new Command_Action + { + defaultLabel = "CommandFireGlobal".Translate(), + defaultDesc = "CommandFireGlobalDesc".Translate(), + icon = FireMissionTex, + action = new Action(StartChoosingDestination) + }; + + if (!CanFireGlobal(out string reason)) + { + fireGlobal.Disable(reason); + } + if (this.forcedTarget.IsValid) + { + fireGlobal.Disable("LocalTargetForced".Translate()); + } + yield return fireGlobal; + + if (this.longTarget.IsValid) + { + Command_Action clearRemote = new Command_Action + { + defaultLabel = "CommandClearRemoteTarget".Translate(), + defaultDesc = "CommandClearRemoteTargetDesc".Translate(), + icon = ContentFinder.Get("UI/Designators/Cancel"), + action = () => + { + this.longTarget = GlobalTargetInfo.Invalid; + } + }; + yield return clearRemote; + } + } + + public void FireMission(GlobalTargetInfo target) + { + if (!CanFireGlobal(out _)) return; + + WorldObject_CatastropheMissile missile = (WorldObject_CatastropheMissile)WorldObjectMaker.MakeWorldObject( + DefDatabase.GetNamed("CatastropheMissile_Flying") + ); + missile.Tile = this.Map.Tile; + missile.destinationTile = target.Tile; + missile.destinationCell = target.Cell; + missile.Projectile = DefDatabase.GetNamed("Projectile_CatastropheMissile"); + Find.WorldObjects.Add(missile); + + Vector3 shellDirection = Vector3.forward.RotatedBy(this.top.CurRotation); + IntVec3 outcell = (this.DrawPos + shellDirection * 500f).ToIntVec3(); + + Projectile_CruiseMissile dummy = (Projectile_CruiseMissile)GenSpawn.Spawn(DefDatabase.GetNamed("Projectile_CatastropheMissile_Fake"), this.Position, this.Map); + dummy?.Launch(this, this.DrawPos, new LocalTargetInfo(outcell), new LocalTargetInfo(outcell), ProjectileHitFlags.None); + + var refuelableComp = this.TryGetComp(); + if(refuelableComp != null) + { + refuelableComp.ConsumeFuel(1); + } + SoundDef.Named("RocketLaunch").PlayOneShot(new TargetInfo(this.Position, this.Map)); + + this.BurstComplete(); + } + + private bool CanFireGlobal(out string reason) + { + var refuelableComp = this.TryGetComp(); + if (refuelableComp != null && !refuelableComp.HasFuel) + { + reason = "NoFuel".Translate().CapitalizeFirst(); + return false; + } + reason = ""; + return true; + } + + private void StartChoosingDestination() + { + CameraJumper.TryJump(CameraJumper.GetWorldTarget(this), CameraJumper.MovementMode.Pan); + Find.WorldSelector.ClearSelection(); + Find.WorldTargeter.BeginTargeting( + new Func(this.ChoseWorldTarget), + true, + FireMissionTex, + true, + () => GenDraw.DrawWorldRadiusRing(this.Map.Tile, 99999), + null, null, null, true); + } + + private bool ChoseWorldTarget(GlobalTargetInfo target) + { + if (!target.IsValid) + { + Messages.Message("MessageTargetInvalid".Translate(), MessageTypeDefOf.RejectInput, true); + return false; + } + if (target.Tile == this.Map.Tile) + { + Messages.Message("Cannot target own map for global strike.", MessageTypeDefOf.RejectInput, true); + return false; + } + + if (target.WorldObject is MapParent mapParent && mapParent.HasMap) + { + var originalMap = this.Map; + Action onFinished = () => { + if (Current.Game.CurrentMap != originalMap) Current.Game.CurrentMap = originalMap; + }; + + Current.Game.CurrentMap = mapParent.Map; + Find.Targeter.BeginTargeting(new TargetingParameters + { + canTargetLocations = true, + canTargetPawns = true, + canTargetBuildings = true + }, + (LocalTargetInfo localTarget) => + { + if (localTarget.HasThing) + { + this.longTarget = new GlobalTargetInfo(localTarget.Thing); + } + else + { + this.longTarget = new GlobalTargetInfo(localTarget.Cell, mapParent.Map); + } + this.forcedTarget = LocalTargetInfo.Invalid; + }, + null, onFinished, FireMissionTex, true); + + return true; + } + else + { + Messages.Message("MessageTargetMustBeMap".Translate(), MessageTypeDefOf.RejectInput, true); + return false; + } + } + } +} \ No newline at end of file diff --git a/Source/ArachnaeSwarm/Comps/CompForceTargetable.cs b/Source/ArachnaeSwarm/Comps/CompForceTargetable.cs new file mode 100644 index 0000000..da82707 --- /dev/null +++ b/Source/ArachnaeSwarm/Comps/CompForceTargetable.cs @@ -0,0 +1,18 @@ +using Verse; + +namespace ArachnaeSwarm +{ + public class CompProperties_ForceTargetable : CompProperties + { + public CompProperties_ForceTargetable() + { + this.compClass = typeof(CompForceTargetable); + } + } + + public class CompForceTargetable : ThingComp + { + // This component doesn't need any specific logic. + // Its mere presence on a turret is checked by the Harmony patch. + } +} \ No newline at end of file diff --git a/Source/ArachnaeSwarm/HarmonyPatches/Patch_ForceTargetable.cs b/Source/ArachnaeSwarm/HarmonyPatches/Patch_ForceTargetable.cs new file mode 100644 index 0000000..716e922 --- /dev/null +++ b/Source/ArachnaeSwarm/HarmonyPatches/Patch_ForceTargetable.cs @@ -0,0 +1,23 @@ +using HarmonyLib; +using Verse; +using RimWorld; + +namespace ArachnaeSwarm +{ + [HarmonyPatch(typeof(Building_TurretGun), "get_CanSetForcedTarget")] + public static class Patch_Building_TurretGun_CanSetForcedTarget + { + public static void Postfix(Building_TurretGun __instance, ref bool __result) + { + if (__result) + { + return; + } + + if (__instance.GetComp() != null && __instance.Faction == Faction.OfPlayer) + { + __result = true; + } + } + } +} \ No newline at end of file diff --git a/Source/ArachnaeSwarm/Verbs/Projectiles/Projectile_CruiseMissile.cs b/Source/ArachnaeSwarm/Verbs/Projectiles/Projectile_CruiseMissile.cs index ee8fdac..5ef3aa4 100644 --- a/Source/ArachnaeSwarm/Verbs/Projectiles/Projectile_CruiseMissile.cs +++ b/Source/ArachnaeSwarm/Verbs/Projectiles/Projectile_CruiseMissile.cs @@ -8,6 +8,7 @@ namespace ArachnaeSwarm { public class CruiseMissileProperties : DefModExtension { + public bool isDummy = false; public DamageDef customDamageDef; public int customDamageAmount = 5; public float customExplosionRadius = 1.1f; @@ -38,11 +39,19 @@ namespace ArachnaeSwarm private Vector3 Randdd; private Vector3 position2; public Vector3 ExPos; + public bool isDummy = false; + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref isDummy, "isDummy", false); + } public override void SpawnSetup(Map map, bool respawningAfterLoad) { base.SpawnSetup(map, respawningAfterLoad); settings = def.GetModExtension() ?? new CruiseMissileProperties(); + this.isDummy = settings.isDummy; } private void RandFactor() @@ -113,6 +122,11 @@ namespace ArachnaeSwarm var map = base.Map; base.Impact(hitThing, blockedByShield); + if (isDummy) + { + return; + } + DoExplosion( base.Position, map, diff --git a/Source/ArachnaeSwarm/World/WorldObject_CatastropheMissile.cs b/Source/ArachnaeSwarm/World/WorldObject_CatastropheMissile.cs new file mode 100644 index 0000000..c3169da --- /dev/null +++ b/Source/ArachnaeSwarm/World/WorldObject_CatastropheMissile.cs @@ -0,0 +1,76 @@ +using RimWorld.Planet; +using UnityEngine; +using Verse; +using RimWorld; + +namespace ArachnaeSwarm +{ + public class WorldObject_CatastropheMissile : WorldObject + { + public int destinationTile = -1; + public IntVec3 destinationCell = IntVec3.Invalid; + public ThingDef Projectile; + + private int initialTile = -1; + private float traveledPct; + private const float TravelSpeed = 0.0002f; + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref destinationTile, "destinationTile", 0); + Scribe_Values.Look(ref destinationCell, "destinationCell"); + Scribe_Defs.Look(ref Projectile, "Projectile"); + Scribe_Values.Look(ref initialTile, "initialTile", 0); + Scribe_Values.Look(ref traveledPct, "traveledPct", 0f); + } + + public override void PostAdd() + { + base.PostAdd(); + this.initialTile = this.Tile; + } + + private Vector3 StartPos => Find.WorldGrid.GetTileCenter(this.initialTile); + private Vector3 EndPos => Find.WorldGrid.GetTileCenter(this.destinationTile); + + public override Vector3 DrawPos => Vector3.Slerp(StartPos, EndPos, traveledPct); + + protected override void Tick() + { + base.Tick(); + float distance = GenMath.SphericalDistance(StartPos.normalized, EndPos.normalized); + if(distance > 0) + { + traveledPct += TravelSpeed / distance; + } + else + { + traveledPct = 1; + } + + if (traveledPct >= 1f) + { + Arrived(); + } + } + + private void Arrived() + { + Map targetMap = Current.Game.FindMap(this.destinationTile); + if (targetMap != null) + { + // Find a random entry point at the north edge of the target map + IntVec3 entryCell = CellFinder.RandomEdgeCell(Rot4.North, targetMap); + + // Spawn the final projectile (the cruise missile) at the entry point + Projectile_CruiseMissile missile = (Projectile_CruiseMissile)GenSpawn.Spawn(this.Projectile, entryCell, targetMap, WipeMode.Vanish); + + // Launch it from the entry point towards the final destination cell + missile.Launch(null, entryCell.ToVector3Shifted(), new LocalTargetInfo(this.destinationCell), new LocalTargetInfo(this.destinationCell), ProjectileHitFlags.IntendedTarget); + } + + Find.WorldObjects.Remove(this); + } + } +} \ No newline at end of file