LogoArcartX Doc

服务端控制器

用 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。

太刀.yml
法杖.yml
默认控制器.yml

一份控制器文件由三大块组成,下面逐块拆解:

setting:   # 控制器级别的全局设置
state:     # 声明这个控制器里所有可能用到的状态
combo:     # 连招链:定义状态之间怎么衔接

setting:控制器全局设置

setting 管的是「设上这个控制器时」以及「整个控制器共享」的东西。

setting:
  # 对接的客户端状态机 ID(动画从哪套状态机里找)
  client_controller_id: "太刀动画"
 
  # 继承另一个控制器的全部 state(可选,支持多层,有环检测)
  extends: ""
 
  # —— model 与 animation 二选一 ——
  # 设上该控制器时给玩家换模型;留空 = 不操作(不会移除玩家已有的模型)
  model: "katana_player"
  model_scale: 1.0
  # 给玩家设置动画包 ID;仅当 model 为空时才生效(玩家模型路径)
  animation: ""
 
  # 是否放行原版左右键(原版攻击 / 交互)。做动作系统一般关掉,
  # 否则点左键会同时触发连招和原版挥砍
  enable_use: false
 
  # 状态运行期间阻止快捷栏换槽和背包点击
  lock_in_state: false
 
  # 输入历史:供 AND / SEQUENCE 等组合输入回看
  input_buffer:
    enabled: true    # 是否启用
    max_size: 3      # 最多缓冲几个输入
    lifetime: 300    # 输入有效期(ms),超时丢弃
 
  # 动作脚本,可写多个;init 在「设上该控制器时」执行一次
  action:
    init: |-
      var.combo = 0

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。

action:
  init: |-
    var.combo = 0
    var.rage = 0

之后在任何 state 的 execute、conditions 里,都能读写 var.combo、var.rage。

damage -> 受伤

顾名思义,玩家受伤的时候触发


state:声明状态

state 节点下声明这个控制器里所有可能用到的状态。顺序无所谓,这里只是声明;它们之间怎么衔接由后面的 combo 决定。

先看一个状态的骨架,再逐字段展开:

state:
  轻击1:                      # 状态 ID(后续被 combo 引用,别重复)
    controller: "main"        # 客户端子控制器名
    state_name: "attack1"     # 客户端子控制器下的状态名
    speed: "1"                # 播放速度(Aria 表达式)
    duration: "500"           # 持续时长 ms(Aria 表达式)
    buffer_at: -1             # 预输入衔接时间点
    group: "攻击"             # 所属分组
    charge: false             # 是否蓄力
    move_cancel: -1           # 移动取消解锁点(ms)
    conditions:               # 进入条件(见下)
    cooldown:                 # 冷却(见下)
    windows:                  # 窗口:取消 / 派生 / 无敌 / 霸体(见下)
    execute:                  # 到点执行的脚本(见下)

基础字段: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 表达式,例如按玩家属性加速:

speed: "1 + var.rage / 100"

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() 此时返回已蓄力的毫秒数。离开该状态时会冻结这次时长,供后续状态读取;下一次进入蓄力状态重新计时。控制器切换等强制清空状态的路径也会重置蓄力记录。

典型流程由「蓄力开始 → 蓄力释放」两个状态组成,释放状态读取上一段冻结的结果:

state:
  # 蓄力开始:长按右键,进入后实时计时
  蓄力开始:
    controller: "main"
    state_name: "charge"
    duration: "3000"
    charge: true          # ← 开始计时
    buffer_at: -1         # 服务端处理松手输入后立即衔接,不等待固定预输入点
    windows:
      derive:             # 整个蓄力过程都开着派生窗口,随时能松手释放
        start: 0
        end: 3000
 
  # 蓄力释放:松开右键派生到这里,在这里读蓄了多久
  蓄力释放:
    controller: "main"
    state_name: "charge_release"
    duration: "600"
    charge: false         # ← 离开「蓄力开始」时时长被冻结
    execute:
      结算:
        at: 100
        # self.getChargeTime() 读到的就是玩家实际蓄了多久(ms),用来分档 / 乘伤害
        expression: "var.chargeDamage = 10 + self.getChargeTime() / 100"

连招部分(后文)会把「蓄力开始」的输入配成 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:进入条件

只有条件满足才能进入这个状态:

conditions:
  # Aria 布尔表达式,默认 "true"
  expression: "self.getFood() >= 6 && !self.isFlying()"
  # 玩家正处于这些组的状态时,不可进入本状态
  blocked_group:
    - "受控"
    - "特殊运动"
  • expression:一段返回布尔的 Aria 表达式,可以读玩家属性、var. 变量等。
  • blocked_group:一组组名。玩家当前状态属于其中任何一组时,本状态进不去——常用来防止「被击飞/硬直期间还能出招」。

cooldown:冷却

cooldown:
  enable: false     # 是否开启冷却
  time: "5000"      # 冷却时长 ms,Aria 表达式
  group: "闪避"     # 共享这个冷却组的状态使用同一份冷却

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:

windows:
  derive:
    start: 300
    end: 450

多段窗口用 ranges(一个状态里某种特性分成好几段生效):

windows:
  invincible:
    ranges:
      - start: 0
        end: 100
      - start: 400
        end: 500
    # 伤害监听读到无敌窗口时,刷新标签的有效期(ms),供 self.isInvincibleTag() 读取
    invincible_tag_duration: 1000

invincible 窗口独有 invincible_tag_duration:伤害监听读到当前状态记录的无敌窗口时,会将标签有效期刷新到此毫秒数之后;已被其他插件取消的伤害事件也会刷新它。该值默认为 0,设为正数后,self.isInvincibleTag() 才会有一段时间持续返回 true。可以用它作为反击或处决的条件,但不能仅凭标签判断这次伤害是否原本会生效。

输入处理和窗口查询会按调用时刻重新计算窗口;伤害监听则读取当前状态已记录的窗口集合,不会自行刷新。因此,不应依赖短于一个服务器 tick 的无敌窗口来保证免伤。

下面这张时间线展示各窗口在一次闪避(duration: 600、speed: 1)里的分布:

取消窗口 vs 派生窗口,别混。 派生窗口走的是当前连招树里预先定义好的分支(接自己的下一段);取消窗口允许你跳出当前连招、从头发起另一棵连招树的根节点(比如轻击摇到一半,直接闪避取消掉)。

execute:到点执行脚本

execute 让你在状态生命周期的某个时刻执行一段 Aria 脚本——出手判定、位移都靠它。每个步骤有一个 at(毫秒)和一段 expression:

execute:
  前冲: # 这个名字随便,方便你记忆即可
    at: 50
    # 到 50ms 时给自己一个位移(水平 1.5,垂直 0.3)
    expression: "self.dash(1.5, 0.3)"
  出手:
    at: 200
    expression: "var.combo = var.combo + 1"

同一个 at 可以放多个具名步骤,按配置读取顺序进入状态任务队列。步骤名同时也是 execute ID,会出现在外部 handler 与 Aria 注入变量中。需要重复执行、真实时间或外部 handler 时,见 execute 循环与外部处理器。

一个状态被打断时,它还没执行到的 execute 步骤(尚未出手的伤害 / 位移)会随打断一并取消,不会补跑。

状态生命周期一览

把上面的字段放到一条时间轴上,一个状态大致是这样运转的:


combo:连招链

state 只是把状态登记下来,真正让它们「连起来」的是连招链。在链里为每个根状态定义一个触发输入,再用 derive 一层层往下挂能接的后续状态——这就构成了一棵连招树。

旧的 combo 写法仍会被加载为名为 legacy 的链:

combo:
  轻击1:                    # 根节点:对应一个 state ID
    input:                  # 什么输入能触发它
      type: MOUSE_CLICK
      value: LEFT
    derive:                 # 它在派生窗口里能接哪些状态
      轻击2:
        input:
          type: MOUSE_CLICK
          value: LEFT
        derive:
          轻击3:
            input:
              type: MOUSE_CLICK
              value: LEFT

玩家点左键 → 进「轻击1」→ 在它的派生窗口里再点左键 → 进「轻击2」→ 再点 → 「轻击3」。这就是最基础的三连击。

多条具名连招链

一个控制器可以同时声明 raw 输入链和多条技能专用链:

setting:
  # 玩家普通输入只从这条链的根节点匹配。
  default_combo_chain: "legacy"
 
combo:
  轻击1:
    input: { type: MOUSE_CLICK, value: LEFT }
 
combo_chains:
  skill_whirlwind:
    旋风斩准备:
      input: { type: AUTO }
      derive:
        旋风斩释放:
          input: { type: AUTO }
 
  skill_charge:
    冲锋准备:
      input: { type: AUTO }

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 —— 任一满足即可(多种触发方式)。 让玩家往任意方向冲刺都能触发闪避:

闪避:
  input:
    mode: OR
    inputs:
      - { type: ACTION, value: SPRINT }
      - { type: ACTION, value: SPRINT_LEFT }
      - { type: ACTION, value: SPRINT_RIGHT }
      - { type: ACTION, value: SPRINT_BACK }

AND:组合最近的输入事件。 所需事件须在 100ms 容差内出现,它不会持续读取按键是否仍被按住。下面的示例要求潜行输入与右键点击事件接近发生:

蓄力重击:
  input:
    mode: AND
    inputs:
      - { type: ACTION, value: SNEAK }
      - { type: MOUSE_CLICK, value: RIGHT }

SEQUENCE —— 在时间窗口内按顺序完成(搓招)。 格斗游戏味的升龙拳,必须在 time_window 毫秒内依次输入:

升龙拳:
  input:
    mode: SEQUENCE
    time_window: 500      # 500ms 内完成整个序列
    inputs:
      - { type: ACTION, value: SPRINT_BACK }   # 后退
      - { type: ACTION, value: SPRINT }        # 前冲
      - { type: MOUSE_CLICK, value: LEFT }     # 攻击

AND 和 SEQUENCE 都依赖 setting.input_buffer——它们要在缓冲区里回看最近的几个输入才能判定,所以别把 input_buffer 关掉。这两种模式至少要写 2 个 inputs,否则加载期会告警。

AUTO 与 FORCE:两种「不用玩家输入」的衔接

AUTO 和 FORCE 都不需要玩家再按键就能进入下一段,但触发时机完全不同,这是最容易混的地方:

触发时机典型用途
AUTO上一状态进入正常结束流程后尝试衔接,包括时长到期或 execute 返回 END_STATE;预输入等衔接优先处理收招、蓄力超时后的自动动作
FORCE状态刚启动时和运行期间每 tick 检查目标条件、冷却及可选窗口;通过后尝试立即衔接血量低于阈值自动接处决、进入某种状态自动变招

AUTO —— 冲刺斩播完,自动接收招动作,无需玩家操作:

combo:
  冲刺斩:
    input:
      type: ACTION
      value: SPRINT
    derive:
      冲刺斩收招:
        input:
          type: AUTO # 冲刺斩播完后自动进入

FORCE:条件驱动的强制衔接。 状态刚启动时会立即检查一次,之后在运行期间每 tick 检查。目标状态的 conditions、冷却和可选的 force_window 都通过后,才会尝试衔接,不必等待前一个动作结束。触发条件写在目标 state 的 conditions 中:

state:
  # 处决:只有当被锁定目标血量很低时才允许进入
  处决:
    controller: "main"
    state_name: "execution"
    duration: "1200"
    conditions:
      # 这段条件就是 FORCE 的触发开关:目标血量低于 20% 时为真
      expression: "条件表达式"
 
combo:
  轻击3:
    input:
      type: MOUSE_CLICK
      value: LEFT
    derive:
      处决:
        input:                # 无需输入;轻击3 期间只要条件满足就强制接处决
          type: FORCE
        force_window: derive  # 可选:只在轻击3的派生窗口内判定条件

force_window 是 FORCE 独有的可选项:

  • 不写(默认):在前一状态的整个生命周期持续判定条件,自由度最高。
  • 填 derive:只在前一状态的派生窗口内判定条件。当前只识别这个值;其他值(包括 cancel)不会限制判定窗口。

引擎将一次连续 FORCE 衔接限制为最多 16 层,避免递归栈溢出。配置中的环仍可能在后续 tick 继续触发,应自行避免无意义的循环。

goto 与 back:复用节点、成环

一棵连招树经常需要「回到前面某一段」形成循环(比如三连击的第三段打完又回到第一段)。硬写会无限展开,所以有两个复用手段:

goto:把同一棵连招树中已经建立的派生节点加入当前节点的派生列表。回到顶层根节点时使用 back:

combo:
  轻击1:
    input:
        type: MOUSE_CLICK
        value: LEFT
    derive:
      轻击2:                # 先声明要复用的派生节点
        input:
            type: MOUSE_CLICK
            value: LEFT
      重击:
        input:
            type: MOUSE_CLICK
            value: RIGHT
        goto: 轻击2          # 重击的派生窗口内,可按轻击2的左键输入回接

back:当节点没有显式 derive 时,back: true 会把根节点加入它的派生列表:

combo:
  斩击:
    input:
        type: MOUSE_CLICK
        value: LEFT
    derive:
      斩击2:
        input:
            type: MOUSE_CLICK
            value: LEFT
        back: true           # 派生列表复用根节点;仍按根节点的左键输入匹配

goto 和 back 只在当前节点没有显式 derive 子节点时才接管衔接。也就是说,一旦你给节点写了 derive,goto / back 就不生效——优先走你手写的派生分支。

它们只复用派生目标,不会自动播放目标状态;是否衔接仍取决于目标节点的输入类型、窗口、条件与冷却。

派生优先级

同一层的多个派生分支按配置顺序从上往下判定,命中第一个满足条件的就进入。所以要把「更严格 / 更特殊」的分支写在前面。比如「完美闪避后的反击」要排在「普通反击」之前:

derive:
  完美闪避反击:
    input:
      type: MOUSE_CLICK
      value: LEFT
    # 目标 state 的 conditions 里写 self.isInvincibleTag(),只有完美闪避后才进得来
  普通反击:
    input:
      type: MOUSE_CLICK
      value: LEFT
    # 条件宽松,作为兜底