服务端控制器
用 controllers/*.yml 编写状态机,从零搭出一套完整的连招武器
这一章在讲什么
服务端控制器是 Chronos 的核心。在 controllers/ 里写一份 YAML,就描述出了一整套战斗逻辑:玩家点了左键放第一段轻击,再点一下接第二段,长按右键蓄力,向后冲刺触发闪避……这些「什么输入 → 播什么动作 → 能接什么」的规则,全都写在这份文件里。
先分清两个「状态机」
服务端和客户端各有一套状态机,名字很像,务必分清楚:
- 服务端控制器(本章主角):Chronos 插件里
controllers/*.yml定义的状态机。它管的是逻辑——判定输入、跑连招、算伤害、控制冷却与窗口。 - 客户端状态机:Mod 端负责播放动画的状态机,写在插件的
client/文件夹里,详见 客户端状态机。
两者靠三个字符串对接。服务端每进入一个状态,就通知客户端「去播哪个动画」:
setting.client_controller_id:整份控制器对接的客户端状态机 ID。- 每个 state 的
controller:客户端状态机里的子控制器名(例如main)。 - 每个 state 的
state_name:该子控制器下的状态名(例如attack1)。
三者都是字符串。Chronos 在加载时会做交叉校验,拼错了会打印 WARN 提醒,但不会阻断加载——所以动作不播时,第一件事就是回来核对这三个字符串。
注册一个控制器
在插件目录的 controllers/ 文件夹里随便建一个 xxx.yml,它就会被注册成一个控制器。文件名(去掉 .yml)就是控制器 ID。
一份控制器文件由三大块组成,下面逐块拆解:
setting:控制器全局设置
setting 管的是「设上这个控制器时」以及「整个控制器共享」的东西。
model 与 animation 是二选一的
这是最容易踩的点:model 和 animation 走的是两条不同的路径。
model:给玩家设置自定义模型及model_scale,并清空原动画包。animation:切回 AX 默认玩家模型(缩放为1.0),再设置指定动画包。
规则:只有当 model 为空时,animation 才会生效。所以如果你两个都填了,animation 会被忽略。想用动画包路径,就把 model 留空。
model留空不等于「移除模型」,而是「这一项什么都不做」。如果玩家之前被别的控制器设过模型,这里留空并不会把它还原。需要还原时请显式指定要换回的模型。- 若模型、时装和动画包统一由外观系统管理,可将
model与animation都留空,由外部系统设置。填写这两个字段时,Chronos 仍会按上述规则操作玩家模型或动画包。
input_buffer:输入缓冲
input_buffer 保存输入历史,供 AND / SEQUENCE 组合输入判定使用。默认启用最多保存 3 个输入,保存时间为 300ms。
max_size:最多保存的输入数量。组合输入较长时,需要相应增大容量。lifetime:历史输入的保存时间(毫秒)。超过该时间的输入不再参与组合判定。
它与 buffer_at 的预输入衔接是两套机制:只有输入到达时已经处于派生窗口,且 buffer_at >= 0,引擎才会保存匹配到的派生候选,并在衔接点检查能否进入。派生窗口尚未开启时收到的普通输入,不会因为保存在历史队列中就在窗口开启后自动重放。
lock_in_state:状态中的物品操作
lock_in_state 默认为 false。设为 true 时,只要玩家仍有当前状态,就会取消快捷栏换槽事件和背包点击事件;无状态时不限制。它不会阻止 API 切换控制器,也不代表能拦截其他插件或所有方式造成的物品变更。
action:触发器
当前Chronos支持的触发器较少,我们逐个讲解:
init -> 初始化
实际装载一个控制器时,Chronos 会为玩家新建独立的 Aria 上下文,并执行一次 init,通常用它初始化供条件判断、伤害计算使用的 var. 变量。同一个控制器的重复路由不会重新初始化;临时覆盖结束后复用保存帧时,也会保留原上下文而不重跑 init。
之后在任何 state 的 execute、conditions 里,都能读写 var.combo、var.rage。
damage -> 受伤
顾名思义,玩家受伤的时候触发
state:声明状态
state 节点下声明这个控制器里所有可能用到的状态。顺序无所谓,这里只是声明;它们之间怎么衔接由后面的 combo 决定。
先看一个状态的骨架,再逐字段展开:
基础字段:controller / state_name / speed / duration
| 字段 | 含义 |
|---|---|
controller | 客户端子控制器名,例如 main。 |
state_name | 该子控制器下的动画状态名,例如 attack1。 |
speed | 播放速度,Aria 表达式。 |
duration | 状态持续时长(毫秒),Aria 表达式。 |
state_name 取代了旧的 stateName。 旧键名 stateName 仍能读(向下兼容),但新配置一律用 state_name。
speed 会缩放状态时长、窗口、buffer_at、移动取消时间,以及 STATE 时间域的 execute;REAL 循环不随它缩放。比如某个窗口写的是 start: 0, end: 100,当 speed 设为 2 时,实际生效区间是 0~50ms。它支持 Aria 表达式,例如按玩家属性加速:
buffer_at:做「预输入」手感
buffer_at(默认 -1)决定「在派生窗口内收到输入后,什么时候真正衔接到下一个动作」:
-1:在派生窗口内收到输入就立即衔接。实际手感稍差。>= 0:把输入先存成预输入,在状态开始后的buffer_at / speed毫秒到达衔接点,再由 Chronos 处理。
后者用来做「预输入」——玩家可以提前把下一段的键按下去,动作会卡在一个固定的节奏点才接上,让整套连段的节奏稳定、不会因玩家手速忽快忽慢而错乱。
buffer_at > 0 还会在实际运行时间超过 buffer_at / speed 后开启取消窗口,即使没有显式配置 windows.cancel。它也会影响蓄力读数:松手输入先成为预输入,最早释放时长约为 buffer_at / speed,并受服务器刻调度影响。需要在服务端处理松手输入时立即衔接,就把蓄力状态的 buffer_at 设为 -1。
group:状态分组
group 给状态贴一个分组标签。它被两个地方引用:
conditions.blocked_group:玩家正处于某些组的状态时,禁止进入本状态。cooldown.group:同组状态共享冷却。
比如把所有闪避动作的状态 group 和 cooldown.group 都设为 闪避,就能让它们共享冷却,也能被别的状态用「处于闪避组时不可进入」来排斥。只设置状态 group 不会自动改变冷却组。
charge:跨状态的蓄力
charge: true 的状态从进入时开始实时计时,self.getChargeTime() 此时返回已蓄力的毫秒数。离开该状态时会冻结这次时长,供后续状态读取;下一次进入蓄力状态重新计时。控制器切换等强制清空状态的路径也会重置蓄力记录。
典型流程由「蓄力开始 → 蓄力释放」两个状态组成,释放状态读取上一段冻结的结果:
连招部分(后文)会把「蓄力开始」的输入配成 MOUSE_HOLD RIGHT(长按右键),把「蓄力释放」的输入配成 MOUSE_HOLD_RELEASE RIGHT(松开右键)。
move_cancel:允许移动打断
move_cancel(默认 -1)控制「玩家能不能靠移动打断当前状态」:
- 小于或等于 0,或省略:该状态不可被移动打断。
- 正数 N:状态开始
N / speed毫秒后,允许玩家主动移动来打断当前状态。
move_cancel 使用整数毫秒,并受 speed 缩放。speed: 1 时,写 move_cancel: 300 表示 300ms 后允许移动取消。
配套的 move_cancel_fresh_input(默认 false)决定解锁点之后怎样算「一次移动」:
false:一直按住方向键,到解锁点即触发取消(动作游戏惯例,收招时按着方向就自然走位)。true:要求解锁点之后出现新的移动输入(先松开、再按一次方向键)才取消,避免「按住前进砍完自动往前冲」。
conditions:进入条件
只有条件满足才能进入这个状态:
expression:一段返回布尔的 Aria 表达式,可以读玩家属性、var.变量等。blocked_group:一组组名。玩家当前状态属于其中任何一组时,本状态进不去——常用来防止「被击飞/硬直期间还能出招」。
cooldown:冷却
group 是关键:同一冷却组的状态共享同一份冷却。比如四个方向的闪避都写 group: "闪避",那玩家不管往哪个方向闪,都会一起进冷却,不能瞬间四连闪。time 是 Aria 表达式,可以按玩家属性动态计算。
cooldown.group 与状态的 group 分别配置,不会自动关联。启用冷却却省略 cooldown.group 时,默认使用字面组名 "-",这些状态仍会共享该组;需要独立冷却时,为每个状态指定不同的冷却组名。
windows:四种窗口
窗口是「在状态生命周期的某段时间里,开启某种特性」。它是动作手感的灵魂——什么时候能取消、什么时候能接下一段、什么时候无敌,全部依靠这里设置。
- 时间都是相对状态开始的毫秒数,
start到end之间为该窗口生效区间。 - 所有窗口时间都会被
speed缩放(比如speed: 2时窗口时间减半)。 - 不需要的窗口直接不写即可。
| 窗口 | 作用 |
|---|---|
cancel | 取消窗口:这段时间内,玩家可以用「发起其它连招根节点的输入」来取消当前状态。常用于重击前摇——摇到一半发现要挨打,赶紧闪掉。 |
derive | 派生窗口:这段时间内接收输入,沿 combo 的 derive 分支转移到下一个状态。连段能不能接下去,就看这个窗口。 |
invincible | 无敌帧:这段时间内免疫伤害,用来做闪避。 |
super_armor | 霸体:阻止 tryEnterControlledState 这类受控状态入口;不会统一屏蔽原版击退、所有强制状态切换或控制器切换。 |
单段窗口直接写 start / end:
多段窗口用 ranges(一个状态里某种特性分成好几段生效):
invincible 窗口独有 invincible_tag_duration:伤害监听读到当前状态记录的无敌窗口时,会将标签有效期刷新到此毫秒数之后;已被其他插件取消的伤害事件也会刷新它。该值默认为 0,设为正数后,self.isInvincibleTag() 才会有一段时间持续返回 true。可以用它作为反击或处决的条件,但不能仅凭标签判断这次伤害是否原本会生效。
输入处理和窗口查询会按调用时刻重新计算窗口;伤害监听则读取当前状态已记录的窗口集合,不会自行刷新。因此,不应依赖短于一个服务器 tick 的无敌窗口来保证免伤。
下面这张时间线展示各窗口在一次闪避(duration: 600、speed: 1)里的分布:
取消窗口 vs 派生窗口,别混。 派生窗口走的是当前连招树里预先定义好的分支(接自己的下一段);取消窗口允许你跳出当前连招、从头发起另一棵连招树的根节点(比如轻击摇到一半,直接闪避取消掉)。
execute:到点执行脚本
execute 让你在状态生命周期的某个时刻执行一段 Aria 脚本——出手判定、位移都靠它。每个步骤有一个 at(毫秒)和一段 expression:
同一个 at 可以放多个具名步骤,按配置读取顺序进入状态任务队列。步骤名同时也是 execute ID,会出现在外部 handler 与 Aria 注入变量中。需要重复执行、真实时间或外部 handler 时,见 execute 循环与外部处理器。
一个状态被打断时,它还没执行到的 execute 步骤(尚未出手的伤害 / 位移)会随打断一并取消,不会补跑。
状态生命周期一览
把上面的字段放到一条时间轴上,一个状态大致是这样运转的:
combo:连招链
state 只是把状态登记下来,真正让它们「连起来」的是连招链。在链里为每个根状态定义一个触发输入,再用 derive 一层层往下挂能接的后续状态——这就构成了一棵连招树。
旧的 combo 写法仍会被加载为名为 legacy 的链:
玩家点左键 → 进「轻击1」→ 在它的派生窗口里再点左键 → 进「轻击2」→ 再点 → 「轻击3」。这就是最基础的三连击。
多条具名连招链
一个控制器可以同时声明 raw 输入链和多条技能专用链:
combo_chains.<链ID> 下直接写根状态,不需要额外的 roots 层。配置了连招链时,setting.default_combo_chain 必须指向已存在的链,默认值为 legacy;旧式 combo 会作为 legacy 链装载,因此不能再声明同名的 combo_chains.legacy。控制器也可以不配置任何连招链,此时没有普通输入入口。
技能 route 可以按职业、技能和当前控制器选择另一条链,并从该链的指定根节点启动。链内引用的每个状态仍必须在同一控制器的 state 中定义。
输入类型总表
input.type 决定这个节点被什么触发:
| 类型 | 含义 | value 取值 |
|---|---|---|
MOUSE_CLICK | 鼠标点击 | LEFT / RIGHT |
MOUSE_HOLD | 鼠标长按(≥150ms) | LEFT / RIGHT |
MOUSE_HOLD_RELEASE | 鼠标长按后释放 | LEFT / RIGHT |
KEY_PRESS | 按键点击 | keybinding 里的按键 ID,如 战技1 |
KEY_HOLD | 按键长按(≥500ms) | 同上 |
KEY_HOLD_RELEASE | 按键长按后释放 | 同上 |
ACTION | 玩家动作 | JUMP SNEAK SPRINT SPRINT_LEFT SPRINT_RIGHT SPRINT_BACK |
AUTO | 状态走正常结束流程后尝试自动衔接 | 无 |
FORCE | 强制衔接(满足条件即打断接上) | 无 |
长按阈值由客户端判定:鼠标 150ms、键盘 500ms。
ACTION 的冲刺分前后左右四个方向;但在第三人称自由相机非锁定期间,只会触发向前的 SPRINT。
组合输入:AND / OR / SEQUENCE
单个 input 只能配一种触发。想要「同时按」「多种触发」「搓招」这类,就用 mode + inputs 列表:
OR —— 任一满足即可(多种触发方式)。 让玩家往任意方向冲刺都能触发闪避:
AND:组合最近的输入事件。 所需事件须在 100ms 容差内出现,它不会持续读取按键是否仍被按住。下面的示例要求潜行输入与右键点击事件接近发生:
SEQUENCE —— 在时间窗口内按顺序完成(搓招)。 格斗游戏味的升龙拳,必须在 time_window 毫秒内依次输入:
AND 和 SEQUENCE 都依赖 setting.input_buffer——它们要在缓冲区里回看最近的几个输入才能判定,所以别把 input_buffer 关掉。这两种模式至少要写 2 个 inputs,否则加载期会告警。
AUTO 与 FORCE:两种「不用玩家输入」的衔接
AUTO 和 FORCE 都不需要玩家再按键就能进入下一段,但触发时机完全不同,这是最容易混的地方:
| 触发时机 | 典型用途 | |
|---|---|---|
AUTO | 上一状态进入正常结束流程后尝试衔接,包括时长到期或 execute 返回 END_STATE;预输入等衔接优先处理 | 收招、蓄力超时后的自动动作 |
FORCE | 状态刚启动时和运行期间每 tick 检查目标条件、冷却及可选窗口;通过后尝试立即衔接 | 血量低于阈值自动接处决、进入某种状态自动变招 |
AUTO —— 冲刺斩播完,自动接收招动作,无需玩家操作:
FORCE:条件驱动的强制衔接。 状态刚启动时会立即检查一次,之后在运行期间每 tick 检查。目标状态的 conditions、冷却和可选的 force_window 都通过后,才会尝试衔接,不必等待前一个动作结束。触发条件写在目标 state 的 conditions 中:
force_window 是 FORCE 独有的可选项:
- 不写(默认):在前一状态的整个生命周期持续判定条件,自由度最高。
- 填
derive:只在前一状态的派生窗口内判定条件。当前只识别这个值;其他值(包括cancel)不会限制判定窗口。
引擎将一次连续 FORCE 衔接限制为最多 16 层,避免递归栈溢出。配置中的环仍可能在后续 tick 继续触发,应自行避免无意义的循环。
goto 与 back:复用节点、成环
一棵连招树经常需要「回到前面某一段」形成循环(比如三连击的第三段打完又回到第一段)。硬写会无限展开,所以有两个复用手段:
goto:把同一棵连招树中已经建立的派生节点加入当前节点的派生列表。回到顶层根节点时使用 back:
back:当节点没有显式 derive 时,back: true 会把根节点加入它的派生列表:
goto 和 back 只在当前节点没有显式 derive 子节点时才接管衔接。也就是说,一旦你给节点写了 derive,goto / back 就不生效——优先走你手写的派生分支。
它们只复用派生目标,不会自动播放目标状态;是否衔接仍取决于目标节点的输入类型、窗口、条件与冷却。
派生优先级
同一层的多个派生分支按配置顺序从上往下判定,命中第一个满足条件的就进入。所以要把「更严格 / 更特殊」的分支写在前面。比如「完美闪避后的反击」要排在「普通反击」之前:
