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