从 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&aApplication) override{aApplication.GetScriptTypes()->Register(ut::make_unique<ScriptTricorderSensorClass>("TricorderSensor",GetApplication().GetScriptTypes()));}
这段代码的本质是在说:
把一个新的脚本类型 TricorderSensor 注册到脚本类型系统里。
为什么要做这一步?
因为 AFSIM 脚本层原本只认识通用的 WsfSensor。但你现在想在脚本里做这种事情:
((TricorderSensor)aSensor).LifeFormTypeCount()
那脚本解释器就必须知道:
-
TricorderSensor是什么 -
它有哪些方法
-
怎么从
WsfSensor向下转型到TricorderSensor。
所以,AddedToApplication() 本质上不是在“注册传感器对象”,而是在:
注册传感器的脚本身份。
这个点特别容易混淆,初学者一定要分开理解。
七、第四步:ScenarioCreated() —— 把你的类接进场景解析系统
再看第二扇门:
voidScenarioCreated(WsfScenario&aScenario) override{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(WsfScenario& aScenario);WsfSensor*Clone() constoverride;bool Initialize(double aSimTime) override;bool ProcessInput(UtInput& aInput) override;void Update(double aSimTime) override;bool AttemptToDetect(double aSimTime,WsfPlatform*aTargetPtr,Settings& aSettings,WsfSensorResult& aResult) override; ...};
这说明它是一个标准的 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(UtInput& aInput) override;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_time、reports_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 能识别、能调度、能脚本化访问的对象体系”。
这,才是插件模型集成的本质。
夜雨聆风