Starsector Nihilism Guidance
首页
GitHub 项目
首页
GitHub 项目
  • 首页
  • 基础及舰船相关
    • MOD 骨架和入口文件
    • 制作一艘基本舰船
    • 制作一艘航母与战机
    • 制作一艘相位舰
    • 制作火炮、光束与导弹
    • 为舰船加入内置武器、船插与战机
    • 补充描述文本
  • 无需入门的代码修改
    • 以船插为例初步熟悉代码
    • 制作完整船插
    • 制作最小战术系统
    • 为武器加入命中与光束特效
  • 势力与生涯星系
    • 注册一个势力
    • 配置势力舰队
    • 制作 MOD 入口类的最基础适配
    • 制作一个生涯星系
    • 补完描述文本
  • 战役与无代码对象
    • 制作测试战役装配
    • 为模拟战与标题画面加入装配
    • 为舰船加入外观风格
    • 制作一艘模块船
    • 制作一种星体类型
    • 制作一个自定义实体
  • 音频资源与播放
    • 准备 OGG 音频资源
    • 注册音频资源
    • 为游戏对象加入音频
  • Java 工程化
    • 确认 Java 与编译依赖
    • 迁移代码文件到 src
    • 配置 Gradle 工程
    • 构建 JAR 并声明产物
  • 全局服务
    • 制作 MOD 入口类
    • 使用 Global 访问游戏服务
    • 读取设置
    • 调用声音播放器
    • 记录和定位 MOD 日志
  • 战斗运行时
    • 读取战斗引擎状态
    • 读取战斗对象
    • 制作每帧战斗插件
    • 修改舰船数值
    • 配置武器脚本
    • 配置战斗监听器
    • 制作分层渲染插件
  • 战斗内容扩展
    • 详解舰船插件
    • 详解战术系统
    • 详解武器动画
  • 生涯世界与基础服务
    • 读取生涯世界状态
    • 构建生涯地点
    • 配置生涯势力
    • 放置生涯实体
  • 生涯舰队与舰队成员
    • 生成生涯舰队
    • 管理舰队中的成员
    • 舰队状态的重算过程
    • 维护舰队成员状态
    • 读取并修改舰船装配
  • 市场与商品
    • 创建生涯市场
    • 读写货舱内容
    • 注册市场商品
    • 实现专用子市场
    • 制作专用产业
    • 注册特殊物品
    • 实现市场条件
  • 人物与技能
    • 配置人物基础
    • 让人物承担角色
    • 制作角色技能
  • 生涯脚本与情报事件
    • 编写生涯逐帧脚本
    • 监听生涯事件
    • 选择生涯插件
    • 管理舰队活动情报
  • 酒吧事件与任务
    • 组织生涯交互对话
    • 实现对话插件
    • 接入酒吧事件
    • 构建阶段任务
    • 接入酒吧任务
  • Rules 系统、命令与内存
    • 组织 Rules 规则表
    • 编排 Rules 对话
    • 接入 Rules 生涯场景
    • 编写 Rules 命令
    • 使用 MemoryAPI 保存状态
  • 文件读取与写入
    • 读取 MOD 内文本
    • 读取 MOD 内 JSON 文件
    • 读取 MOD 内 CSV
    • 读写 common 数据
    • 实现本地化机制
  • 常用依赖库
    • 接入常用依赖库
    • 使用 LazyLib 几何工具
    • 使用 LazyLib 查询战场
    • 使用 LazyLib 保存公共 JSON
    • 注册 GraphicsLib 光照数据
    • 创建 GraphicsLib 动态光源
    • 制作 GraphicsLib 纹理映射
    • 配置 MagicLib 自动尾迹
    • 创建 MagicLib 运行时尾迹
    • 配置 MagicLib 数据赏金
    • 使用 MagicLib 辅助生涯开发
    • 使用 MagicLib 战斗小工具
  • 可选依赖与 Nexerelin
    • 接入可选依赖
    • 登记 Nexerelin 势力与外交配置
    • 配置 Nexerelin 的两种星区开局
    • 配置 Nexerelin 战略 AI 与扩张规模
    • 登记 Nexerelin 地面战单位与产业
    • 为 Nexerelin 特遣队添加命名与事件响应
    • 制作即时生效的 LunaLib 设置面板
  • 着色器与渲染
    • 使用 Java OpenGL
    • 使用 GLSL 进阶
    • 使用 VBO 和 VAO 保存顶点
    • 使用 SSBO 传递参数
    • 使用实例化进行绘制
    • 选择 BoxUtil 渲染方案

使用 LazyLib 几何工具

导弹追踪横向移动的目标,需要根据距离与速度取得提前点,再把方向转换成转向指令。本节沿用德瓦林弹体制作测试导弹,用 LazyLib 完成这些计算,并绘制预测路径、交点、碰撞圆与椭圆,让计算结果能够在战斗中直接对应到位置。

本节预期阅读时间:~28 分钟。

本节预期操作时间:~35 分钟。

学习目标

完成后,您将能够:

  • 区分坐标距离、实体表面距离和距离平方的适用场景;
  • 用随机点、角度和向量方法建立可观察的空间分布;
  • 判断向量方法会返回新坐标还是修改传入对象;
  • 取得预测线与实体 bounds 的交点;
  • 用旋转椭圆描述随目标朝向变化的区域;
  • 将几何计算接入一个具有线性提前和路径标记的导弹 AI。

前置知识

  • [C7-S1] 的 BaseModPlugin 与导弹 AI 选择入口;
  • [C1-S5] 的德瓦林武器、导弹弹体与 engineSpec;
  • [C8-S2] 的战斗实体、坐标、速度与碰撞类型;
  • [C8-S3] 的每帧调用、暂停判断与 IntervalUtil;
  • [C18-S1] 的 LazyLib 依赖声明与编译 classpath。

准备测试导弹

复制 [C1-S5] 的 data/weapons/proj/GearIn_dwalin_rocket.proj,将副本保存为同目录的 GearIn_geometry_missile.proj。在副本中修改以下局部字段,保留贴图、尺寸、中心、碰撞与引擎配置:

"id": "GearIn_geometry_missile",
"missileType": "MISSILE"

德瓦林的 engineSpec 已提供转向加速度 360、最大转向速度 180 和加速度 1000,足以观察后面的左右转向。测试时,把 GearIn_dwalin.wpn 的 projectileSpecId 改为新 ID:

"projectileSpecId": "GearIn_geometry_missile"

新弹体在本节定义,原弹体继续保存。完成 AI 类和选择入口后再构建、发射;本次验证让德瓦林武器使用这枚测试导弹。

从导弹的动作确定所需计算

GearInGeometryMissileAI 在构造时取得发射舰当前目标。每帧发出加速命令后,AI 用目标中心距离除以导弹最大速度估算提前时间,再把时间限制在 0f 到 2f。目标速度乘以这段时间,就得到相对当前位置的线性提前量。

这个提前点假定目标保持当前速度。它计算便宜,足以表现横向移动目标的追踪差异;目标加速或急转时,下一帧重新计算,预测也随之改变。制导使用的是当前运动数据,而不是目标未来动作。

取得提前点以后,VectorUtils.getAngle(...) 给出应转向的角度,MathUtils.getShortestRotation(...) 决定左右。速度方向与弹体朝向相差超过 20f 度时,AI 再发出横移命令,修正弹体已经转向、速度仍偏向一侧的侧滑。

目标离场、进入相位或采用 CollisionClass.NONE 时,AI 暂停这部分跟踪,只保持加速直飞。引用继续保留,同一目标恢复为可跟踪状态后再继续计算。下面的工具分别处理距离、方向与边界,完整类在最后将它们接回这套行为。

认识坐标、角度与可变向量

战斗空间使用 Vector2f 保存二维坐标和向量。坐标与方向都用两个浮点分量表示,含义由当前调用决定。**计算前先确认这份向量代表什么,以及方法会修改哪一个对象。**角度以度为单位,0f 指向世界坐标的正 X 轴,增加角度表示逆时针旋转。

CombatEntityAPI.getLocation() 和 getVelocity() 返回引擎正在使用的可变对象。直接调用 set(...)、scale(...),或者把它传给会原地修改向量的方法,都会改变这份对象本身。只做计算时先用 new Vector2f(source) 复制,或者选择带 dest 参数的重载。

引擎返回的向量仍属于实体

把 getVelocity() 的结果交给原地缩放方法,会直接改变实体速度。只为计算提前量或预测线时,使用副本或独立的目标向量。

用于计算的向量若被意外缩放,舰船或导弹可能瞬间改变速度。几何公式尚未得出结果,输入数据已经被上一行代码改写;此时排查方向和距离都得先回到向量的来源。

使用 MathUtils 计算空间关系

区分中心距离与实体距离

下面的局部片段接收两个战斗实体和一个测试坐标,同时计算中心距离、实体表面距离和范围判断:

private void inspectDistance(CombatEntityAPI source, CombatEntityAPI target,
        Vector2f testPoint) {
    float centerDistance = MathUtils.getDistance(
            source.getLocation(), target.getLocation());
    float surfaceDistance = MathUtils.getDistance(source, target);
    float distanceSquared = MathUtils.getDistanceSquared(
            source.getLocation(), testPoint);
    boolean inRange = MathUtils.isWithinRange(source, testPoint, 600f);

    Vector2f midpoint = MathUtils.getMidpoint(
            source.getLocation(), target.getLocation());
    Vector2f point = MathUtils.getPoint(midpoint, 120f, 30f);
    Vector2f samePoint = MathUtils.getPointOnCircumference(
            midpoint, 120f, 30f);
    boolean inside = MathUtils.isPointWithinCircle(point, midpoint, 120f);
}

两个 Vector2f 传给 getDistance(...) 时,结果是中心坐标之间的直线距离。传入 CombatEntityAPI 时,计算会计入双方碰撞半径,因此 surfaceDistance 表示碰撞圆外缘之间还剩多少距离。实体与坐标的重载同样会计入该实体的碰撞半径,isWithinRange(...) 则直接把这个关系压缩成布尔结果。

getDistanceSquared(...) 的 Vector2f 重载直接计算距离平方,适合高频比较。实体重载则先取得计入碰撞半径的普通距离,再将结果平方;这个重载仍然完成了普通距离计算,不能靠方法名免费省掉它。

getMidpoint(...) 返回两点正中的新坐标。getPoint(...) 根据中心、半径和角度取得圆周坐标,getPointOnCircumference(...) 是含义更直白的别名;示例中的 point 与 samePoint 因此重合。isPointWithinCircle(...) 把圆周也算作范围内部,所以 inside 为 true。

生成随机分布并整理角度

粒子、闪光和散射效果通常不需要固定坐标,而需要明确的分布形状。下面的局部片段同时生成圆内、圆周和线段上的随机点,并计算从当前朝向转向目标朝向的最短角差:

private Vector2f sampleEffectPoint(Vector2f center, Vector2f lineEnd,
        float currentFacing, float desiredFacing) {
    float size = MathUtils.getRandomNumberInRange(4f, 8f);
    Vector2f cloudPoint = MathUtils.getRandomPointInCircle(center, 30f);
    Vector2f ringPoint = MathUtils.getRandomPointOnCircumference(center, 60f);
    Vector2f linePoint = MathUtils.getRandomPointOnLine(center, lineEnd);

    float normalized = MathUtils.clampAngle(desiredFacing);
    float turn = MathUtils.getShortestRotation(currentFacing, normalized);
    float alpha = MathUtils.clamp(size / 8f, 0f, 1f);
    return turn < 0f ? linePoint : turn > 0f ? ringPoint : cloudPoint;
}

getRandomNumberInRange(...) 同时提供浮点和整数重载,最小值与最大值决定返回区间。getRandomPointInCircle(...) 在圆的面积内均匀分布;它不会把大量点挤在中心。getRandomPointOnCircumference(...) 固定半径,只随机角度,连续绘制后会形成清晰圆环。getRandomPointOnLine(...) 的结果始终位于两个端点之间。

clampAngle(...) 把任意角度整理到一圈以内,适合保存朝向。clamp(...) 把普通数值限制在给定上下界,导弹 AI 用它限制提前时间。getShortestRotation(...) 返回最短角差:正值表示向左转,负值表示向右转。跨过 0 度时仍会选择较短方向,不需要手工编写 360 - angle 的分支。

使用 VectorUtils 修改方向向量

从两点取得方向和朝向

向量工具把坐标关系转换成可继续缩放和旋转的方向。下面的局部片段从起点指向终点,并分别演示目标向量重载与原地修改重载:

private Vector2f buildPredictionVector(Vector2f start, Vector2f end,
        float angleOffset, float length) {
    float angle = VectorUtils.getAngle(start, end);
    Vector2f direction = VectorUtils.getDirectionalVector(start, end);
    float facing = VectorUtils.getFacing(direction);
    if (VectorUtils.isZeroVector(direction)) {
        return new Vector2f();
    }

    Vector2f rotatedCopy = VectorUtils.rotate(
            direction, angleOffset, new Vector2f());
    Vector2f resizedCopy = VectorUtils.resize(
            rotatedCopy, length, new Vector2f());

    VectorUtils.rotate(direction, angle - facing);
    VectorUtils.resize(direction, length);
    return resizedCopy;
}

getAngle(start, end) 返回起点朝向终点的世界角度,getFacing(vector) 把一个方向向量转换为同一套角度。getDirectionalVector(...) 返回从起点指向终点的单位向量;两点重合时结果是零向量,isZeroVector(...) 可以在继续计算朝向前识别它。

带 dest 参数的 rotate(...) 与 resize(...) 不修改输入,而把结果写进目标向量。省略 dest 的重载会直接修改传入对象,并返回同一个对象以便连续调用。上例中的 rotatedCopy 和 resizedCopy 与输入分离,后两次调用则改变了 direction。

导弹预测线从单位 X 向量开始,先按弹体朝向旋转,再缩放到固定长度。若把 missile.getVelocity() 直接传给无 dest 的 resize(...),导弹速度会被改成预测线长度;这类错误通常很醒目,只是方向未必指向您希望检查的代码行。

使用 CollisionUtils 检查预测路径

取得实体和线段交点

碰撞工具处理的是有限线段。下面的局部片段检查预测路径与目标实体的交点,同时检查它是否与另一条边界线相交:

private Vector2f inspectPath(Vector2f start, Vector2f end,
        CombatEntityAPI target, Vector2f edgeStart, Vector2f edgeEnd) {
    Vector2f hullHit = CollisionUtils.getCollisionPoint(start, end, target);
    Vector2f edgeHit = CollisionUtils.getCollisionPoint(
            start, end, edgeStart, edgeEnd);
    boolean endInside = CollisionUtils.isPointWithinBounds(end, target);
    return hullHit != null ? hullHit : edgeHit != null ? edgeHit
            : endInside ? new Vector2f(end) : null;
}

目标具有 exact bounds 时,实体重载返回有限预测线遇到的最近边界点;起点已在舰体内部时,返回起点副本。缺少 exact bounds 时,方法改用碰撞圆判断,发生碰撞就返回实体中心。两条线段的重载返回交点,未相交时返回 null。交点的含义取决于实际使用的边界。

isPointWithinBounds(...) 检查具体坐标是否位于实体 bounds 内。它与碰撞圆判断不是同一件事:不规则舰体的角落可能落在碰撞圆内,却不在真实舰体边界内。

预测起点已经位于 bounds 内部时,标记落在起点,即使整条线都在舰体内。需要区分 "从外部进入" 与 "已经在内部" 时,读取 isPointWithinBounds(start, target);这两种状态都可能得到有效坐标。

使用 EllipseUtils 描述旋转椭圆

生成并判断椭圆坐标

椭圆适合描述沿舰首方向拉长的搜索区、效果区或调试轮廓。下面的局部片段使用固定中心、宽度、高度和旋转角,覆盖 EllipseUtils 的全部公开方法:

private boolean inspectEllipse(Vector2f center, Vector2f testPoint) {
    float width = 320f;
    float height = 140f;
    float rotation = 35f;

    Vector2f edge = EllipseUtils.getPointOnEllipse(
            center, width, height, rotation, 20f);
    Vector2f randomEdge = EllipseUtils.getRandomPointOnEllipse(
            center, width, height, rotation);
    Vector2f randomInside = EllipseUtils.getRandomPointInEllipse(
            center, width, height, rotation);
    return EllipseUtils.isPointWithinEllipse(
            testPoint, center, width, height, rotation);
}

width 与 height 在这组 API 中是未旋转 X、Y 方向的半轴长度,320f、140f 对应完整尺寸 640 × 280 su,rotation 再旋转整条轮廓。参数名写作宽高,实际传入的是半轴。 getPointOnEllipse(...) 的最后一个角度选择轮廓上的确定位置,getRandomPointOnEllipse(...) 沿轮廓取点,getRandomPointInEllipse(...) 在内部面积均匀取点,isPointWithinEllipse(...) 接受内部与边界,并带有少量浮点容差。

连续绘制 randomEdge 会形成一圈随 rotation 转动的轮廓,绘制 randomInside 则会逐渐填满整片区域。导弹 AI 用目标朝向作为旋转角,因此紫色粒子会随着目标转向,而不是固定在世界坐标轴上。

接回导弹 AI 与调试绘制

弹体与武器引用已经准备好,现在把计算接成 GearInGeometryMissileAI。保存到 src/data/scripts/gearin/lib/GearInGeometryMissileAI.java,完整类见本节成品。

制导按本节开头的行为执行,调试绘制另外沿导弹当前朝向延伸 900f su。青色粒子显示预测线,CollisionUtils.getCollisionPoint(...) 返回有效结果时再用橙色标记交点;黄色勾出碰撞圆,紫色显示随目标朝向旋转的椭圆。IntervalUtil 把每枚导弹的绘制间隔固定为 0.08f 秒。

在现有 GearInModPlugin 中加入下面的局部方法。projectile ID 命中时,PickPriority.MOD_SET 让 GearIn 的选择覆盖更低优先级结果;其他导弹继续交给后续插件或原版逻辑:

@Override
public PluginPick<MissileAIPlugin> pickMissileAI(
        MissileAPI missile, ShipAPI launchingShip) {
    if ("GearIn_geometry_missile".equals(missile.getProjectileSpecId())) {
        return new PluginPick<>(
                new GearInGeometryMissileAI(missile, launchingShip),
                PickPriority.MOD_SET);
    }
    return null;
}

在入口类的 import 区域追加以下局部声明,PickPriority 来自 CampaignPlugin 的嵌套枚举:

import com.fs.starfarer.api.PluginPick;
import com.fs.starfarer.api.campaign.CampaignPlugin.PickPriority;
import com.fs.starfarer.api.combat.MissileAIPlugin;
import com.fs.starfarer.api.combat.MissileAPI;
import com.fs.starfarer.api.combat.ShipAPI;
import data.scripts.gearin.lib.GearInGeometryMissileAI;

发射时 launchingShip.getShipTarget() 决定初始目标,AI 本身不搜索附近实体;范围查询和自动目标选择由 [C18-S3] 负责。

常用方法速查表

类方法结果或修改对象
MathUtilsgetDistance(...)返回坐标中心距离,实体重载计入碰撞半径
MathUtilsgetDistanceSquared(...)返回距离平方,坐标重载适合热路径比较
MathUtilsisWithinRange(...)返回是否处于指定范围
MathUtilsgetPoint(...)、getPointOnCircumference(...)返回给定圆周角度的新坐标
MathUtilsgetRandomPointInCircle(...)返回圆内均匀面积分布的新坐标
MathUtilsgetRandomPointOnCircumference(...)返回固定半径圆周上的新坐标
MathUtilsgetRandomPointOnLine(...)返回有限线段上的新坐标
MathUtilsgetRandomNumberInRange(...)返回指定区间内的随机数
MathUtilsclamp(...)、clampAngle(...)返回限制后的数值或标准角度
MathUtilsgetShortestRotation(...)返回带方向的最短角差
MathUtilsgetMidpoint(...)、isPointWithinCircle(...)返回中点或圆形范围判断
VectorUtilsgetAngle(...)、getFacing(...)返回世界角度
VectorUtilsgetDirectionalVector(...)、isZeroVector(...)返回单位方向或零向量判断
VectorUtilsresize(...)、rotate(...)修改输入向量,或把结果写入 dest
CollisionUtilsgetCollisionPoint(...)返回实体或线段交点,没有交点时返回 null
CollisionUtilsisPointWithinBounds(...)返回坐标是否处于实体 bounds
EllipseUtilsgetPointOnEllipse(...)返回旋转椭圆轮廓上的确定坐标
EllipseUtilsgetRandomPointOnEllipse(...)返回旋转椭圆轮廓上的随机坐标
EllipseUtilsgetRandomPointInEllipse(...)返回旋转椭圆内部的随机坐标
EllipseUtilsisPointWithinEllipse(...)返回坐标是否处于旋转椭圆内

完整最小例

这个类接收一枚已经生成的导弹,持续计算提前点、转向和预测交点。DEBUG 保持为 true 时显示四种几何粒子;改为 false 只会停止绘制,不改变制导。

完整成品:GearInGeometryMissileAI
package data.scripts.gearin.lib;

import java.awt.Color;

import org.lazywizard.lazylib.CollisionUtils;
import org.lazywizard.lazylib.EllipseUtils;
import org.lazywizard.lazylib.MathUtils;
import org.lazywizard.lazylib.VectorUtils;
import org.lwjgl.util.vector.Vector2f;

import com.fs.starfarer.api.Global;
import com.fs.starfarer.api.combat.CollisionClass;
import com.fs.starfarer.api.combat.CombatEngineAPI;
import com.fs.starfarer.api.combat.CombatEntityAPI;
import com.fs.starfarer.api.combat.GuidedMissileAI;
import com.fs.starfarer.api.combat.MissileAIPlugin;
import com.fs.starfarer.api.combat.MissileAPI;
import com.fs.starfarer.api.combat.ShipAPI;
import com.fs.starfarer.api.combat.ShipCommand;
import com.fs.starfarer.api.util.IntervalUtil;

/**
 * Guides one missile toward the launching ship's selected target.
 * LazyLib supplies the lead, turn, prediction, collision, and debug geometry.
 */
public final class GearInGeometryMissileAI implements MissileAIPlugin, GuidedMissileAI {
    private static final boolean DEBUG = true;
    private static final float MAX_LEAD_TIME = 2f;
    private static final float PREDICTION_LENGTH = 900f;
    private static final Vector2f ZERO = new Vector2f();

    private static final Color LINE_COLOR = new Color(80, 220, 255, 220);
    private static final Color HIT_COLOR = new Color(255, 140, 60, 240);
    private static final Color CIRCLE_COLOR = new Color(255, 220, 80, 220);
    private static final Color ELLIPSE_COLOR = new Color(210, 100, 255, 220);

    private final MissileAPI missile;
    private final IntervalUtil debugInterval = new IntervalUtil(0.08f, 0.08f);
    private CombatEntityAPI target;

    public GearInGeometryMissileAI(MissileAPI missile, ShipAPI launchingShip) {
        this.missile = missile;
        target = launchingShip == null ? null : launchingShip.getShipTarget();
    }

    @Override
    public CombatEntityAPI getTarget() {
        return target;
    }

    @Override
    public void setTarget(CombatEntityAPI target) {
        this.target = target;
    }

    @Override
    public void advance(float amount) {
        CombatEngineAPI engine = Global.getCombatEngine();
        if (engine.isPaused() || missile.isFading() || missile.isFizzling()) {
            return;
        }

        missile.giveCommand(ShipCommand.ACCELERATE);
        if (!isTrackable(engine)) {
            return;
        }

        Vector2f leadPoint = calculateLeadPoint();
        steerToward(leadPoint);

        Vector2f predictionEnd = createPredictionEnd();
        Vector2f collisionPoint = CollisionUtils.getCollisionPoint(
                missile.getLocation(), predictionEnd, target);
        drawDebugGeometry(engine, amount, predictionEnd, collisionPoint);
    }

    private boolean isTrackable(CombatEngineAPI engine) {
        if (target == null || !engine.isEntityInPlay(target)
                || target.getCollisionClass() == CollisionClass.NONE) {
            return false;
        }
        return !(target instanceof ShipAPI) || !((ShipAPI) target).isPhased();
    }

    private Vector2f calculateLeadPoint() {
        float distance = MathUtils.getDistance(missile.getLocation(), target.getLocation());
        float speed = Math.max(1f, missile.getMaxSpeed());
        float leadTime = MathUtils.clamp(distance / speed, 0f, MAX_LEAD_TIME);

        Vector2f leadOffset = new Vector2f(target.getVelocity());
        leadOffset.scale(leadTime);
        return Vector2f.add(target.getLocation(), leadOffset, new Vector2f());
    }

    private void steerToward(Vector2f leadPoint) {
        float desiredFacing = VectorUtils.getAngle(missile.getLocation(), leadPoint);
        float turn = MathUtils.getShortestRotation(missile.getFacing(), desiredFacing);
        if (turn < 0f) {
            missile.giveCommand(ShipCommand.TURN_RIGHT);
        } else if (turn > 0f) {
            missile.giveCommand(ShipCommand.TURN_LEFT);
        }

        if (!VectorUtils.isZeroVector(missile.getVelocity())) {
            float velocityFacing = VectorUtils.getFacing(missile.getVelocity());
            float slip = MathUtils.getShortestRotation(missile.getFacing(), velocityFacing);
            if (slip < -20f) {
                missile.giveCommand(ShipCommand.STRAFE_LEFT);
            } else if (slip > 20f) {
                missile.giveCommand(ShipCommand.STRAFE_RIGHT);
            }
        }
    }

    private Vector2f createPredictionEnd() {
        Vector2f direction = new Vector2f(1f, 0f);
        VectorUtils.rotate(direction, missile.getFacing());
        VectorUtils.resize(direction, PREDICTION_LENGTH);
        return Vector2f.add(missile.getLocation(), direction, new Vector2f());
    }

    private void drawDebugGeometry(CombatEngineAPI engine, float amount,
            Vector2f predictionEnd, Vector2f collisionPoint) {
        if (!DEBUG) {
            return;
        }
        debugInterval.advance(amount);
        if (!debugInterval.intervalElapsed()) {
            return;
        }

        addMarker(engine, MathUtils.getRandomPointOnLine(
                missile.getLocation(), predictionEnd), LINE_COLOR);
        if (collisionPoint != null) {
            addMarker(engine, MathUtils.getRandomPointInCircle(
                    collisionPoint, 12f), HIT_COLOR);
        }

        float radius = target.getCollisionRadius();
        addMarker(engine, MathUtils.getRandomPointOnCircumference(
                target.getLocation(), radius), CIRCLE_COLOR);
        addMarker(engine, EllipseUtils.getRandomPointOnEllipse(
                target.getLocation(), radius * 1.6f, radius * 0.75f,
                target.getFacing()), ELLIPSE_COLOR);
    }

    private void addMarker(CombatEngineAPI engine, Vector2f point, Color color) {
        float size = MathUtils.getRandomNumberInRange(4f, 8f);
        engine.addSmoothParticle(point, ZERO, size, 1f, 0.2f, color);
    }
}

验证方案

前置数据:GearIn_geometry_missile 已绑定可转向导弹弹体,GearInModPlugin 已加入 AI 选择方法,并准备一艘能够发射该导弹的测试舰。

  1. 保存新弹体、德瓦林武器引用、AI 类和选择方法,执行 gradle jar。重启后装备德瓦林并锁定一个静止目标。
  2. 发射导弹,分别让目标位于弹体左侧和右侧,观察导弹转向与四色粒子。
  3. 让目标横向移动,再让预测线分别穿过和偏离目标舰体。
  4. 让目标进入相位或暂时使用 CollisionClass.NONE,随后恢复原状态。
  5. 把 DEBUG 改为 false 并重新构建,观察同一导弹的制导和粒子表现。
  6. 恢复 DEBUG = true,连续发射多枚导弹并观察粒子密度。

预期结果:

  • 导弹继承发射舰当前目标,并按最短角差向正确方向转弯;
  • 横向移动目标使导弹朝目标前方的线性提前点转向;
  • 青色粒子沿当前预测线分布,黄色粒子保持在目标碰撞圆周,紫色粒子随目标朝向旋转;
  • 预测线穿过目标 bounds 时出现橙色交点粒子,偏离时不出现;
  • 目标暂时不可跟踪时导弹保持加速直飞,恢复后继续跟踪同一目标;
  • DEBUG = false 时几何粒子停止,导弹转向与加速继续生效;
  • 连续发射时每枚导弹按固定间隔生成粒子,没有逐帧形成不受控制的粒子密度。

故障分析

  • 导弹总向目标当前坐标追尾:提前时间使用了零速度、零距离或未经限制的错误输入。

    • 解决方案:用中心距离除以至少 1f 的最大速度,并通过 MathUtils.clamp(...) 限制结果后缩放目标速度。
  • 导弹左右转向相反:getShortestRotation(...) 的正负分支与 TURN_LEFT、TURN_RIGHT 对应颠倒。

    • 解决方案:保持正值左转、负值右转,并用目标分别位于两侧的场景检查分支。
  • 计算预测线后导弹速度突然改变:无 dest 的 VectorUtils.resize(...) 或 rotate(...) 直接修改了引擎速度向量。

    • 解决方案:先复制 getVelocity(),或把独立目标向量传给带 dest 的重载。
  • 预测线附近没有橙色粒子:有限线段实际未到达目标边界,或目标处于不可跟踪状态。

    • 解决方案:核对当前朝向、900 su 预测长度与目标碰撞状态,并用 isPointWithinBounds(...) 判断起点是否已在舰体内。
  • 所有导弹都持续生成大量粒子:调试绘制没有经过 DEBUG 与 IntervalUtil 限制。

    • 解决方案:只在计时区间结束后绘制一组标记,发布构建把 DEBUG 设为 false。

总结

LazyLib 的几何工具把战斗空间拆成可以独立判断的关系:MathUtils 处理距离、角度和分布,VectorUtils 在方向与向量之间转换,CollisionUtils 把预测路径落到实体边界,EllipseUtils 提供带朝向的非圆形区域。

导弹 AI 只是在每帧把这些关系按顺序组合起来。排错时也可以沿相同顺序检查输入坐标、方向向量、转角符号和碰撞结果,而不必把整段制导代码当成一个无法拆开的公式。

建议参考项

  • org.lazywizard.lazylib.MathUtils:距离、范围、角度、坐标与随机分布;
  • org.lazywizard.lazylib.VectorUtils:方向、朝向、旋转、缩放与目标向量重载;
  • org.lazywizard.lazylib.CollisionUtils:有限路径与实体 bounds 的交点;
  • org.lazywizard.lazylib.EllipseUtils:旋转椭圆的轮廓、内部采样与范围判断;
  • com.fs.starfarer.api.combat.MissileAIPlugin:导弹 AI 的每帧入口;
  • com.fs.starfarer.api.combat.GuidedMissileAI:导弹目标的读取与替换接口。
在 GitHub 上编辑此页
上次更新: 2026/10/14 12:25
贡献者: 1847905557
上一页
接入常用依赖库
下一页
使用 LazyLib 查询战场