乐于分享
好东西不私藏

从 0 到 1 看懂 AFSIM 插件模型集成:以自定义传感器 TricorderSensor 为例

从 0 到 1 看懂 AFSIM 插件模型集成:以自定义传感器 TricorderSensor 为例

很多 AFSIM 初学者第一次接触插件开发时,都会有一种强烈的困惑:

我明明已经写了一个传感器类,为什么脚本里还是不能用?为什么 mission/warlock 还是不认识它?

问题往往不在“传感器逻辑”本身,而在于你还没有把它 完整集成进 AFSIM 的插件模型

这篇文章,我就用 AFSIM 培训里的 TRICORDER_SENSOR 例子,带你从一个初学者的视角,真正看懂下面这条链路:

DLL 被加载 → 插件入口执行 → Application Extension 注册 → 新传感器类型注册 → 场景脚本识别 → 仿真初始化 → 探测逻辑运行 → 脚本层访问结果。

如果你能把这条链路吃透,以后你再做 AFSIM 的武器、传感器、通信设备、处理器扩展,就不会总觉得“代码写了,但系统没接上”。


一、先别急着写代码:你得先明白 AFSIM 的插件模型到底在解决什么问题

AFSIM 不是一个“你写一个类,它自然就会运行”的框架。

它更像一个大型工业化仿真容器。这个容器里已经有:

  • 场景解析器

  • 仿真初始化流程

  • 平台系统

  • 传感器调度器

  • 跟踪器

  • 脚本系统

  • 观察者系统。

所以你自己写的类,必须回答三个问题:

1. AFSIM 怎么找到你?

也就是:插件怎么被加载?

2. AFSIM 怎么认识你?

也就是:新类型怎么注册进框架?

3. 脚本怎么调用你?

也就是:场景作者怎么在 .txt 里真正用起来?

如果这三个问题没解决,你的类只是一个“孤立的 C++ 类”,不是一个“AFSIM 能运行的插件能力”。


二、今天这个例子是什么:一个生命探测器插件

培训材料设计了一个很适合入门的例子:TRICORDER_SENSOR

它不是雷达,不是红外,不靠复杂电磁公式,而是一个“规则型传感器”:

  • 能探测生命体

  • 能识别生命体类型

  • 能根据目标 damage factor 评估“健康状态”。

它用来判断目标是不是生命体的依据也很朴素:

  • 如果目标平台类型属于 LIFE_FORM

  • 或者目标平台带有 klingon_life_form 这样的 category就认为它是生命体。

这个例子很棒,因为它把注意力从“复杂物理模型”转移到了“插件如何集成”上。

也就是说,这个训练真正要教你的不是“怎么写一个很强的传感器算法”,而是:

怎么把一个新的业务能力,按照 AFSIM 的规矩接进系统。


三、先看最终使用效果:场景脚本里长什么样

我们先不看 C++,先看用户最终怎么写脚本。

在 sensor_scenario.txt 里,平台 Spock 加了一个新传感器:

platform Spock VULCAN   position 34:59n 118:02w altitude 0 ft agl   add sensor tricorder TRICORDER_SENSOR      on      mode KLINGON         frame_time 2 s         reports_type         life_form_type klingon_life_form      end_mode      mode ALL_LIFE_FORMS         frame_time 1 s         reports_type         life_form_type LIFE_FORM         life_form_type klingon_life_form      end_mode   end_sensor   execute at_time 5 min absolute      PLATFORM.Sensor("tricorder").SelectMode("ALL_LIFE_FORMS");   end_executeend_platform

这个脚本告诉我们几件事:

  • TRICORDER_SENSOR 已经被 AFSIM 识别成一种合法传感器类型

  • 它支持 mode

  • 每个 mode 支持自定义命令 life_form_type

  • 脚本还可以在运行时切换模式。

这说明什么?

说明插件集成成功之后,你的自定义类不再只是“后台代码”,而是已经变成了 AFSIM 场景语言的一部分

这就是插件开发真正的终点。


四、第一步:插件入口不是传感器类,而是 WsfPluginSetup

很多初学者一上来就盯着 TricorderSensor.cpp,其实真正的入口不是它。

真正入口在 SensorPluginRegistration.cpp

extern"C"{SENSOR_EXERCISE_EXPORTvoidWsfPluginSetup(WsfApplication&aApplicationPtr)   {aApplicationPtr.RegisterExtension("tricorder_sensor_registration",ut::make_unique<TricorderSensorRegistration>());   }}

这段代码的意思非常简单:

当 AFSIM 把这个 DLL 加载进内存之后,会调用 WsfPluginSetup而你的插件就是在这里,把自己的扩展对象注册给 AFSIM。

你可以把它理解成:

“你好,AFSIM,我这个 DLL 里有个新能力,请把它纳入你的扩展系统。”

如果没有这一步,后面什么都不会发生。

初学者最容易犯的第一个错误

只写传感器类,不写插件入口。

结果就是:

  • DLL 也许编译成功了

  • 类也许写好了

  • 但 mission/warlock 根本不知道你存在

所以你要先记住一句话:

在 AFSIM 里,插件不是从 class MySensor 开始的,而是从 WsfPluginSetup 开始的。


五、第二步:为什么不是直接注册传感器,而是先注册一个 Application Extension

在这个例子里,WsfPluginSetup 没有直接注册 TricorderSensor,而是注册了一个:

classTricorderSensorRegistration : publicWsfApplicationExtension

这说明 AFSIM 的设计思想是:

插件先接到应用层,再由应用层把能力继续分发到脚本系统、场景系统、仿真系统。

也就是说,WsfApplicationExtension 像一个“总入口控制器”。

它在这个例子里主要做两件事:

  • AddedToApplication():注册脚本类

  • ScenarioCreated():注册传感器类型。

你可以把这两个函数记成两道门:

第一扇门:脚本门

让脚本系统知道有个 TricorderSensor

第二扇门:场景门

让场景加载器知道有个 TRICORDER_SENSOR

只有两扇门都打开,你的插件才算真正接通。


六、第三步:AddedToApplication() —— 把你的类接进脚本系统

来看第一扇门:

voidAddedToApplication(WsfApplication&aApplicationoverride{aApplication.GetScriptTypes()->Register(ut::make_unique<ScriptTricorderSensorClass>("TricorderSensor",GetApplication().GetScriptTypes()));}

这段代码的本质是在说:

把一个新的脚本类型 TricorderSensor 注册到脚本类型系统里。

为什么要做这一步?

因为 AFSIM 脚本层原本只认识通用的 WsfSensor但你现在想在脚本里做这种事情:

((TricorderSensor)aSensor).LifeFormTypeCount()

那脚本解释器就必须知道:

  • TricorderSensor 是什么

  • 它有哪些方法

  • 怎么从 WsfSensor 向下转型到 TricorderSensor

所以,AddedToApplication() 本质上不是在“注册传感器对象”,而是在:

注册传感器的脚本身份。

这个点特别容易混淆,初学者一定要分开理解。


七、第四步:ScenarioCreated() —— 把你的类接进场景解析系统

再看第二扇门:

voidScenarioCreated(WsfScenario&aScenariooverride{WsfSensorTypes::Get(aScenario).Add("TRICORDER_SENSOR",ut::make_unique<TricorderSensor>(aScenario));}

这段代码很关键。

它做的是把:

  • 场景脚本里的字符串 "TRICORDER_SENSOR"

映射到:

  • C++ 里的 TricorderSensor 类。

也就是说,当 AFSIM 在加载场景文件,看到:

add sensor tricorder TRICORDER_SENSOR

它就知道该创建哪个对象了。

如果没有 ScenarioCreated() 这一步,会发生什么?

答案很直接:

脚本里写出来的 TRICORDER_SENSOR 只是一个字符串,AFSIM 不知道该实例化什么对象。

所以请记住:

  • AddedToApplication() 解决“脚本层认不认这个类型”

  • ScenarioCreated() 解决“场景加载时能不能创建这个对象”

两者完全不是一回事。


八、到这里,插件才算“能被识别”;接下来才轮到真正的传感器类

当插件入口和注册流程走通之后,AFSIM 才会在场景中遇到 TRICORDER_SENSOR 时,真正创建 TricorderSensor 对象。

TricorderSensor.hpp 里这个类的骨架是:

class TricorderSensor : publicWsfSensor{public:explicit TricorderSensor(WsfScenarioaScenario);WsfSensor*Clone() constoverride;bool Initialize(double aSimTimeoverride;bool ProcessInput(UtInputaInputoverride;void Update(double aSimTimeoverride;bool AttemptToDetect(double aSimTime,WsfPlatform*aTargetPtr,SettingsaSettings,WsfSensorResultaResultoverride;   ...};

这说明它是一个标准的 AFSIM 传感器扩展类,核心生命周期包括:

  • ProcessInput

  • Initialize

  • Update

  • AttemptToDetect

很多初学者第一次看会觉得函数太多,其实你只要抓主线:

ProcessInput

负责吃脚本输入

Initialize

负责初始化运行前状态

Update

负责运行时更新

AttemptToDetect

负责真正做探测尝试

只要这四件事懂了,类的骨架就明白了。


九、传感器不是只有一个本体,它还有 mode、scheduler、tracker

这一步是 AFSIM 初学者最容易忽视、但最该尽早建立的概念。

在 TricorderSensor 构造函数里,做了三件事:

SetModeList(ut::make_unique<WsfSensorModeList>(newTricorderMode));SetScheduler(ut::make_unique<WsfDefaultSensorScheduler>());SetTracker(ut::make_unique<WsfDefaultSensorTracker>(aScenario));

这三句非常有代表性。

它说明一个传感器在 AFSIM 里通常不是“单函数对象”,而是由三块组成:

1. ModeList

管理多个工作模式

2. Scheduler

决定什么时候做探测尝试

3. Tracker

把探测结果变成 track 并维护 track

这意味着:

你不是在写一个“纯算法函数”,你是在接入一个“传感器子系统”。

这也是 AFSIM 和很多简化教学代码最大的不同。


十、真正的差异化逻辑,不在 Sensor 本体,而在 TricorderMode

在这个例子里,真正最有内容的地方,其实是嵌套类 TricorderMode

classTricorderMode : publicWsfSensorMode{public:bool ProcessInput(UtInputaInputoverride;bool AttemptToDetect(…) override;void UpdateTrack(…) override;double GetLifeReading(WsfPlatform*aTargetPtr);bool IsLifeForm(WsfPlatform*aTargetPtr);WsfCategoryListmLifeFormTypes;};

你可以把它理解成:

TricorderSensor 是设备壳体,TricorderMode 才是“当前工作模式下到底怎么探测”的地方。

这很符合工程直觉。

比如现实里一个雷达:

  • 搜索模式

  • 跟踪模式

  • 制导模式

它们的逻辑显然不应该全写在一个大类里胡乱分支。

AFSIM 用 mode,就是为了把这种差异性拆开。


十一、ProcessInput():把脚本命令吃进 C++ 对象

现在我们来看,自定义脚本命令 life_form_type 是怎么被处理的。

源码里是这么写的:

boolTricorderSensor::TricorderMode::ProcessInput(UtInput&aInput){bool myCommand=true;std::stringcommand(aInput.GetCommand());if (command=="life_form_type")   {WsfStringIdlifeFormType;aInput.ReadValue(lifeFormType);mLifeFormTypes.JoinCategory(lifeFormType);   }else   {myCommand=WsfSensorMode::ProcessInput(aInput);   }returnmyCommand;}

这段代码特别适合初学者学习,因为它展示了 AFSIM 扩展输入语法的标准套路:

第一步:判断是不是你自己的关键字

这里是 life_form_type

第二步:如果是,就自己读取参数

这里用 ReadValue 读入 lifeFormType

第三步:把它存进成员变量

这里存进 mLifeFormTypes

第四步:如果不是你的命令,就交给基类

这样 frame_timereports_type 这些标准 sensor mode 命令仍然能正常处理。

这就是 AFSIM 插件开发里非常常见的一个模式:

自定义命令自己吃,标准命令交给父类。


十二、脚本里的 life_form_type,最后被存到了哪里

很多初学者读完 ProcessInput() 之后,还是会问:

“好,我知道命令被读进来了,那它最后到底存在哪?”

答案就在这里:

WsfCategoryListmLifeFormTypes;

这个成员变量存的是当前 mode 可识别的“生命体类型集合”。

注意这里设计得很巧妙:

它既能存 平台类型,也能存 category所以场景里你可以这样写:

life_form_type LIFE_FORMlife_form_type klingon_life_form

一个代表类型继承体系,一个代表标签分类体系。

这意味着 TRICORDER_SENSOR 的判定逻辑很灵活:

  • 既能识别“这个目标属于某种平台类型”

  • 也能识别“这个目标打了某个 category 标签”

这正是工程系统常见的做法。


十三、AttemptToDetect():不是只返回 true/false,而是填一整套结果

接下来进入传感器最核心的流程:探测。

在这个例子里,真正的探测逻辑写在 TricorderMode::AttemptToDetect() 里。它的第一句就很关键:

booldetected=IsLifeForm(aTargetPtr);

也就是说,这个模式先判断目标是不是生命体。

而 IsLifeForm() 的逻辑是:

for (const auto&category : mLifeFormTypes.GetCategoryList()){if (aTargetPtr->IsA_TypeOf(category))   {return true;   }}if (mLifeFormTypes.Intersects(aTargetPtr->GetCategories())){return true;}

翻译成大白话就是:

  • 如果目标的平台类型属于我能识别的类型集合,算探测到

  • 如果目标的 category 和我的可识别 category 集合有交集,也算探测到。

这个判断非常适合初学者,因为它让你立刻明白:

AFSIM 里的传感器,不一定非要靠复杂物理传播,也可以靠业务规则完成识别。


十四、为什么 AttemptToDetect() 里还要计算位置和距离

很多人以为探测函数只要:

return true;

就行了,其实远远不够。

在这个例子里,即便是规则型传感器,也仍然要填好 aResult 里的几何信息:

GetSensor()->GetPlatform()->GetLocationWCS(aResult.mRcvrLoc.mLocWCS);aTargetPtr->GetLocationWCS(aResult.mTgtLoc.mLocWCS);UtVec3d::Subtract(...);aResult.mRcvrToTgt.mRange=UtVec3d::Normalize(...);

因为在 AFSIM 里,探测结果不是一个单纯布尔值,而是一套 传感器测量结果对象

所以你要建立一个正确认知:

AttemptToDetect() 做的不是“猜中没猜中”,而是“生成一次完整的探测结果”。

哪怕这个例子不复杂,它也在提醒你:AFSIM 的传感器框架是围绕 result/measurement/track 在工作的。


十五、UpdateTrack():把“探测到了”变成“轨迹里有什么信息”

如果说 AttemptToDetect() 解决的是“能不能看见”,那 UpdateTrack() 解决的就是“看见以后,往 track 里写什么”。

这个例子里写了两个非常有启发性的业务字段。

1. 生命体类型

代码会从目标的 aux_data 里找:

"LIFE_FORM_REPORTED_TYPE"

如果存在,就把它写进 measurement/typeId。

而这个 aux_data 在场景脚本里是这样定义的:

aux_data   string LIFE_FORM_REPORTED_TYPE = "Klingon"end_aux_data

或者:

aux_data   string LIFE_FORM_REPORTED_TYPE = "Vulcan"end_aux_data

这说明什么?

说明脚本层定义的属性,最后可以通过插件代码,进入 track 结果。

也就是说:

脚本负责喂数据,C++ 负责解释和组织这些数据。

2. 生命体健康度

这更有意思。代码里直接把:

1.0-aTargetPtr->GetDamageFactor()

作为生命体“健康读数”,再写到:

aTrackPtr->SetTrackQuality(...)

大白话就是:

  • damage factor 越高

  • health 越低

  • 最终 track quality 被借用成健康度

这就是插件开发里很经典的一种思路:

复用框架已有字段,承载你自己的业务语义。


十六、脚本层为什么还能调用 LifeFormTypeCount() 这些方法

这就来到插件集成的最后一环:脚本接口暴露

在 TricorderSensor.hpp 中,定义了一个脚本类:

classScriptTricorderSensorClass : publicWsfScriptSensorClass{public:UT_DECLARE_SCRIPT_METHOD(LifeFormTypeCount_1);UT_DECLARE_SCRIPT_METHOD(LifeFormTypeCount_2);UT_DECLARE_SCRIPT_METHOD(LifeFormTypeEntry_1);UT_DECLARE_SCRIPT_METHOD(LifeFormTypeEntry_2);};

在 TricorderSensor.cpp 里,又把这些方法注册进来:

SetClassName("TricorderSensor");AddMethod(ut::make_unique<LifeFormTypeCount_1>("LifeFormTypeCount"));AddMethod(ut::make_unique<LifeFormTypeCount_2>("LifeFormTypeCount"));AddMethod(ut::make_unique<LifeFormTypeEntry_1>("LifeFormTypeEntry"));AddMethod(ut::make_unique<LifeFormTypeEntry_2>("LifeFormTypeEntry"));

同时,传感器类还实现了:

const char* TricorderSensor::GetScriptClassName() const{return"TricorderSensor";}

这一步很关键。

因为脚本系统做向下转型时,必须知道:

这个对象在脚本世界里应该叫什么类名。

所以这里你可以把整个链路理解成:

  • AddedToApplication():把脚本类注册给脚本系统

  • GetScriptClassName():告诉脚本系统,这个 C++ 对象对应哪个脚本类

  • AddMethod(...):把可调用方法暴露出去

  • UT_DEFINE_SCRIPT_METHOD(...):写具体方法实现。


十七、于是,场景脚本终于能这样写

在 sensor_scenario.txt 里,有一段非常漂亮的观察者脚本:

这段脚本特别值得你反复看。

因为它把整个插件模型的价值展示得非常清楚:

1. aSensor 进来时只是 WsfSensor

这是框架的通用接口

2. 通过 (TricorderSensor)aSensor 向下转型

说明脚本已经认识你的新类型

3. 调用 LifeFormTypeCount() 和 LifeFormTypeEntry()

说明你在 C++ 中新增的方法,已经真正暴露到了脚本层

4. 读取 aTrack.TrackQuality()

说明前面 UpdateTrack() 写进去的业务信息,已经能在脚本输出里使用

换句话说:

这时候你的插件已经不是“后台扩展”,而是已经真正参与了 AFSIM 的脚本生态。


十八、把整条集成链路,用一句最通俗的话讲清楚

如果你还是觉得信息有点多,我给你压成一句最通俗的话:

AFSIM 插件模型的本质,就是把你写的 C++ 类,通过“插件入口 + 扩展注册 + 类型注册 + 脚本注册”这几层适配,变成 AFSIM 场景系统和脚本系统都能理解、都能调用的系统能力。

再翻成更接地气的话:

你不是在“写一个类”,你是在“给 AFSIM 这个大工厂新增一台设备,并把它接上电、接上控制台、接上显示屏”。

这里:

  • WsfPluginSetup 是接电

  • RegisterExtension 是报到

  • ScenarioCreated 是上生产线

  • AddedToApplication 是接控制台

  • ScriptTricorderSensorClass 是接显示屏

一旦你这么理解,插件开发就不抽象了。


十九、初学者最容易卡住的 4 个点

卡点 1:以为类写完就能用

其实还远远不够。AFSIM 先要能加载你的 DLL,再要能注册你的扩展,再要能识别你的 sensor type。

卡点 2:分不清“脚本注册”和“传感器注册”

  • 脚本注册:给脚本系统认识 TricorderSensor

  • 传感器注册:给场景系统认识 TRICORDER_SENSOR

卡点 3:把所有逻辑都写进 Sensor 本体

正确方式是:

  • 设备级逻辑在 TricorderSensor

  • 模式级逻辑在 TricorderMode

卡点 4:忽略脚本接口

很多人只做到“能运行”,但不会暴露脚本方法。结果是场景作者很难用你的新能力。


二十、给 AFSIM 初学者的一条学习路线

如果你正在学插件开发,我建议你就按下面顺序啃这几个文件:

第一步:先看 SensorPluginRegistration.cpp

只看三件事:

  • WsfPluginSetup

  • AddedToApplication

  • ScenarioCreated

第二步:再看 TricorderSensor.hpp

只认类结构,不要陷入细节:

  • TricorderSensor

  • TricorderMode

  • ScriptTricorderSensorClass

第三步:再看 TricorderSensor.cpp

重点只盯:

  • 构造函数里的 SetModeList / SetScheduler / SetTracker

  • ProcessInput

  • AttemptToDetect

  • UpdateTrack

  • GetScriptClassName

  • AddMethod

  • UT_DEFINE_SCRIPT_METHOD

第四步:最后再回头看 sensor_scenario.txt

你会突然发现,脚本不再是“纯文本配置”,而是在驱动整个插件链路。


结语:插件开发的关键,不是“功能多复杂”,而是“有没有真正接进框架”

对 AFSIM 初学者来说,插件开发最难的往往不是算法,而是“接线”。

你写一个很复杂的探测模型,如果没接进:

  • 插件加载入口

  • Application Extension

  • 传感器类型注册

  • 脚本类型注册

  • 脚本方法暴露

那它在 AFSIM 里就仍然只是“代码”,不是“能力”。

而 TricorderSensor 这个例子最有价值的地方就在于,它把这套集成过程用一个非常温和、非常适合入门的案例串了起来:

  • 脚本定义生命体

  • C++ 解析模式输入

  • 传感器完成探测

  • track 写入业务结果

  • 脚本层再把结果输出出来。

当你真正吃透这个例子后,你会发现:

AFSIM 插件开发,说到底就是把“你的业务逻辑”翻译成“AFSIM 能识别、能调度、能脚本化访问的对象体系”。

这,才是插件模型集成的本质。