Skip to content

技能系统 API 文档 ​

快速上手 ​

步骤 1 · 建「技能」预设 ​

推荐走"按预设类型新建":在预设(新建)面板里选类型 「技能」,编辑器会按该类型的模板生成一个已经接好的预设: Script(装全部配置的壳源码)+ 本地运行脚本 子节点。 属性面板会按壳源码自动生成可编辑字段(CD 时间、施法时间、指示器、充能、子技能……见 §技能预设配置属性)。 保存后得到该技能的 AssetId(map://preset/...) —— 后续装备技能用这个字符串。

手工创建方式

也可以手工建:新建 Script 单位,把 server/packages/ability_system/ability_script.lua 的源码贴进去。手工方式要自己补齐子节点(本地运行脚本等),所以优先用类型新建。

步骤 2 · 建「技能背包」预设 ​

同样按预设类型新建:选类型 「技能背包」,生成的预设含 Script(背包壳源码,装 InitAbilities 等配置)+ 本地运行脚本。 在 InitAbilities 列表里填「技能预设 + 槽位」。也可以留空,全部运行时用 AddAbility 加。

步骤 3 · 把技能背包挂到角色下 ​

技能背包是角色(Character)的子单位。挂上后服务端会自动:

  • 解析归属(写 OwnerId / PlayerId);
  • 按 InitAbilities 逐个装备技能;
  • 打 AbilityManager 标签,AddAbility 类接口靠它反查;
  • 若技能配置了子技能列表,自动建子技能组。

步骤 4 · 装备 / 施放 / 监听(服务端) ​

lua
-- 服务端脚本
local AbilityAPI = require("server.packages.ability_system.api")

local abilityScript = AbilityAPI.AddAbility(unit, "map://preset/你的技能预设key")  -- 不传槽位=自动找空槽
if not abilityScript then
	print("[demo] 装备失败")
	return
end

-- 监听技能事件:事件单位是技能单位下的 BindableEvent 子单位
abilityScript:FindFirstChild("CastStart"):Connect(function(script)
	print("[demo] 施法开始,槽位 =", script:GetAttribute("Index"))
end)

abilityScript:FindFirstChild("CDEnd"):Connect(function(script)
	print("[demo] CD 结束")
end)

-- 监听锚点(第二参数是锚点组 Id)
abilityScript:FindFirstChild("AnchorStart"):Connect(function(script, groupId)
	print("[demo] 锚点点火 groupId =", groupId)
end)

-- 主动施放(服务端权威)
AbilityAPI.CastAbility(unit, 0)          -- 按预设指示器/策略施放
AbilityAPI.StopAbility(unit, 0)          -- 打断 / 停止
AbilityAPI.AccumulateAbility(unit, 0)    -- 进入蓄力(需 EnableAccumulate=true)

步骤 5 · 用锚点做时间轴动作 ​

在该技能预设下建「锚点」子节点(见 §锚点系统),选一种锚点行为(近战攻击 / 创建特效 / 激活冷却…), 把 anchors/<行为>.lua 挂上,然后在对应事件里写反应即可。大部分常见动作不需要自己写逻辑, 先用现成的 16 种锚点行为(§16 种锚点行为)拼装。

心智模型与数据流 ​

概念一句话
技能(ability)一张配置表。属性面板里填的每个字段都会变成运行时属性,用 script:GetAttribute("字段名") 读
技能背包(ability_manager)槽位容器。只管"哪个槽位装哪个技能",不含施法逻辑
状态机CD / 施法 / 蓄力 / 充能 四套状态机由包驱动,作者只负责填配置和监听事件写反应
锚点(anchor)时间轴上的动作,自带 4 个事件(开始 / 打断 / 结束 / 停止)。服务端运行,按 Phase 在蓄力或施法窗口内点火
事件全是挂在单位下的 BindableEvent 子单位,用 单位:FindFirstChild("<事件名>"):Connect(fn) 监听
[客户端输入]  Controller → Strategy(释放策略) → Pointer(指示器)
                    │  RemoteEvent "AbilitySystem"
                    │  单参数 table:{ action, ownerId, index, args = {...} }
                    ▼
[服务端权威]  ability_logic(CD/施法/蓄力/充能)
                    ├─→ Anchor 调度器(按 StartTime/Duration 点火)
                    │        └─→ anchors/<行为>.lua(现成动作)
                    └─→ 事件单位 Fire(技能级 / 锚点级 / 管理器级)
                    │  RemoteEvent s→c 广播(OnCastStart / OnCDEnd / OnCharge / OnSwitchNext / ForbidSlot …)
                    ▼
[客户端表现]  ability_local_script(属性变化 → AbilityUIHooks)→ UIManager(技能栏 UI)

权威性:施法、CD、充能、伤害结算全在服务端。客户端只发"请求"(RequestCast 等), 服务端 canUse() 通过才真正施放。所以客户端界面转好了不代表服务端放行。

编辑器侧:三种预设 ​

两种创建方式 ​

方式做法结果
① 按预设类型新建(推荐)在预设(新建)面板选类型:技能 / 技能背包 / 锚点出来的预设已接好:根 Script(装壳源码)+ 子 本地运行脚本,只需改名 + 调参
② 手工创建新建 Script 单位,把对应壳文件源码贴进 SourceCode需要自己补齐子节点(如 本地运行脚本),容易漏

三种类型与对应壳文件 ​

预设类型(编辑器显示)根节点子节点作用对应壳文件
技能ScriptLocalScript一个具体技能,承载全部配置与时间轴server/packages/ability_system/ability_script.lua
技能背包ScriptLocalScript技能容器,挂在角色下管理槽位server/packages/ability_system/ability_manager_script.lua
锚点Script—技能时间轴上的一个动作server/packages/ability_system/anchor_script.lua + anchors/<行为>.lua

预设与包的关系

也就是说:新建出来的预设本身不写逻辑,它的根 Script 只声明可编辑属性 + require(...).Attach(script);
真正的行为在包里(ability_logic.lua / ability_manager_logic.lua / anchor_logic.lua + anchors/<行为>.lua)。
换/升级包版本时,已建好的预设无需改动。

内置结构体(common/packages/ability_system/data.lua):

lua
ability_system.AbilityInfo    = { AssetId = "", Index = 0 }   -- 初始技能一项
ability_system.SubAbilityInfo = { AssetId = "" }              -- 子技能一项

槽位规则(贯穿全部 API)

  • 槽位 0 基、无上限:0 是合法槽位,1/2/3... 同理,不存在"最大槽位数"。
  • nil = 不指定(AddAbility 会自动找第一个空槽)。
  • -1(AbilityConstants.SLOT_INDEX_UNASSIGNED)= 未入槽哨兵:子技能休眠态、未分配槽位的过渡值。 它不是合法槽位,客户端也因此不会给它绑 UI。

服务端 API 速查 ​

服务端 API 引入

local AbilityAPI = require("server.packages.ability_system.api")
模块加载时(服务端)会自动注册客户端请求监听(幂等),作者不需要手动调 RegisterEvents。

函数签名返回说明
AddAbility(unit, abilityKey, slotIndex?)abilityScript?, err?装备技能。不传槽位自动找空槽;同槽同预设复用现有实例;同槽被别的预设占用 → nil, err
GetAbility(unit, abilityIndex)abilityScript?按槽位取技能脚本单位
GetAbilityByScript(abilityScript)handler?按技能脚本反查 handler
GetAbilities(unit)abilityScript[]全部已装备技能(按槽位升序)
GetAbilitiesFromIndexRange(unit, startIndex, endIndex)abilityScript[]指定槽位区间(含两端)
CastAbility(unit, abilityIndex, releasePoint?, releaseDir?, releaseTarget?)boolean, err?服务端权威施放。返回 false 表示被拒(CD/施法中/无充能/外部条件)
StopAbility(unit, abilityIndex)boolean, err?打断施法;若还在蓄力阶段则退出蓄力
AccumulateAbility(unit, abilityIndex)boolean, err?进入蓄力(EnableAccumulate=false 或 CD/充能不满足时返回 false)
SwitchNextAbility(unit, parentSlotIndex)boolean, err?子技能组切换到下一个成员(任意 SwitchMode 都可显式调用)
SetAbilityToSlot(unit, ability, slotIndex)abilityScript?把已装备技能移动到指定槽位
RemoveAbility(unit, slotIndex)abilityScript?按槽位移除并销毁
RemoveAbilityByKey(unit, abilityKey)—按预设 Key 移除该单位所有对应技能
RegisterEvents()—内部用;模块加载时自动调用(幂等)

unit 参数是什么? 是拥有技能背包的单位(角色),也可以是技能背包本身。 实现里先按 AbilityManager 标签在 unit 自身及其子节点上找管理器,所以两种传法都成立。

失败信息:所有错误返回值都带 [ability_system] 前缀,例如 "[ability_system] AddAbility: slot is occupied by another ability"、"[ability_system] CastAbility: ability not found in slot"。

完整示例:装备 + 常驻监听 + 动态换槽

lua
local AbilityAPI = require("server.packages.ability_system.api")

local M = {}

function M.setup(unit)
	-- 1) 指定槽位装备(0 基)
	local shield = AbilityAPI.AddAbility(unit, "map://preset/盾技能预设key", 2)
	-- 2) 自动找空槽装备
	local dash = AbilityAPI.AddAbility(unit, "map://preset/冲刺预设key")

	-- 3) 升级(前提:该技能 Upgradable=true 且未到 MaxLevel,且满足 LevelRequirements)
	local handler = AbilityAPI.GetAbilityByScript(dash)
	if handler and handler.canUpgrade() then
		handler.upgrade(1)
		print("新等级 =", handler.getLevel())
	end

	-- 4) 换槽:把 2 号槽的盾挪到 5 号槽
	AbilityAPI.SetAbilityToSlot(unit, shield, 5)

	-- 5) 遍历所有技能
	for _, script in ipairs(AbilityAPI.GetAbilities(unit)) do
		print(script:GetAttribute("Index"), script:GetAttribute("CdTime"))
	end
end

return M

客户端 API 速查 ​

客户端 API 引入

local AbilityClientAPI = require("client.packages.ability_system.api")
客户端只能"发请求",真正的施放判定在服务端。

函数签名返回说明
GetManagerForUnit(unit)managerScript?按 AbilityManager 标签反查管理器(自身或子节点)
GetAbility(unit, abilityIndex)abilityScript?按槽位取技能(遍历管理器子节点的 Index 属性)
RequestCast(manager, abilityIndex, releasePoint?, releaseDir?, releaseTarget?)boolean, err?请求施放(client→server)
RequestStop(manager, abilityIndex)boolean, err?请求打断/停止
RequestAccumulate(manager, abilityIndex)boolean, err?请求进入蓄力
RequestSwitchNext(manager, parentSlotIndex)boolean, err?请求子技能轮转
GetUIManager()UIManager取技能栏 UI 管理器单例(懒初始化)
Register(domain, key, value)—注册自定义指示器 / 释放策略(见 §自定义指示器与释放策略)
StartClientLifecycle()—引擎内部:等 LocalPlayer、监听重生、自动挂接管理器(幂等)。作者通常无需调用

参数差异

参数差异要注意:服务端 API 收 unit(角色),客户端 API 收 manager(技能背包单位)。写反了会直接返回失败。

lua
-- 客户端:自绘快捷栏点击施法
local Players = game:GetService("Players")
local AbilityClientAPI = require("client.packages.ability_system.api")

local manager = AbilityClientAPI.GetManagerForUnit(Players.LocalPlayer.Character)
if manager then
	AbilityClientAPI.RequestCast(manager, 0)                       -- 0 号槽:走预设的指示器/策略
	AbilityClientAPI.RequestAccumulate(manager, 1)                 -- 1 号槽:进入蓄力
	AbilityClientAPI.RequestStop(manager, 1)                       -- 松开:停止/施放
	AbilityClientAPI.RequestSwitchNext(manager, 2)                 -- 2 号槽:子技能轮转
end

handler 接口(进阶) ​

handler 是"技能实例在服务端的操作句柄",从注册表反查:

lua
local AbilityRegistry = require("common.packages.ability_system.registry")

local handler        = AbilityRegistry.getAbilityHandler(abilityScript)     -- 技能 handler
local managerHandler = AbilityRegistry.getManagerHandler(managerScript)     -- 技能背包 handler
local manager2       = AbilityRegistry.findManagerForPlayer(player)         -- 按玩家反查背包 handler

handler 调用方式

这些方法内部已闭包持有脚本引用,直接用点调用 handler.getLevel() 即可(用冒号也不会报错,多余参数被忽略)。
下划线开头的方法(_getRawAttribute / _tryStartCharge …)属内部实现,不保证跨版本稳定。

技能 handler ​

分类方法说明
基础getId() / getScript() / getManager()UnitId / 技能脚本 / 所属背包
getSignals()取该技能的事件单位表(signals.CastStart 等)
ConnectEvent(eventName, func)便捷监听:handler.ConnectEvent("CastEnd", fn) → 连接句柄或 nil
_getRawAttribute(name)读原始属性(内部/筛选器用)
CDenterCD(cdTime?)进入 CD(不传则用自身 CdTime)
getLeftCD() / getCdTime() / setCurrentCD(cdTime)剩余 CD / CD 总时长 / 改 CD 并立即重进
施法canUse()是否可施放(CD、施法中、充能、外部条件综合判定)
startCast(releasePoint?, releaseDir?, releaseTarget?)开始施法(返回 boolean)
endCast(broken) / breakCast()结束 / 打断施法(作者一般用 breakCast)
蓄力startAccumulate() / endAccumulate(broken) / isAccumulating()进/退/查蓄力
getAccumulateRatio()当前蓄力比 0~1(startCast 时会被捕获进 AccumulateRatio 属性)
充能getChargeCount() / getMaxChargeCount() / getChargeInterval()当前 / 上限 / 间隔
changeChargeCount(delta)增减充能(自动夹到 [0, MaxChargeCount],满了发 ChargeFull)
onChargeTick()手动 +1 次充能
getIsInCharge() / getChargeLeftTime() / setChargeLeftTime(time)是否充能中 / 本周期剩余 / 改剩余(0 = 立刻完成本次充能)
setChargeConfig(enable, maxCount?, chargeType?, interval?, amount?)运行时改充能配置
等级getLevel() / getMaxLevel() / canUpgrade()查询
upgrade(delta?) / increaseLevel(delta?)升级(前者校验 canUpgrade,后者强制)/ 均会向背包发 AbilityUpgraded
decreaseLevel(delta?) / setLevel(l) / setMaxLevel(m)降级 / 设置(会自动夹取范围)
释放参数getReleasePoint() / getReleaseDirection() / getReleaseTarget()本次释放的点 / 方向 / 目标
setReleaseDistance(d) / setReleaseRadius(r)改施法距离 / 半径
使用限制getLimitation(type) / setLimitation(type, enable)位掩码读写(见 §状态机语义)
外部条件addUseCondition(key, func) / removeUseCondition(key)注入自定义"能不能放"的条件(返回 false 即禁止)
其它removeSelf() / canPick()从背包移除自己 / 当前恒返回 true
lua
-- 用外部条件实现"只有血量 > 50% 才能放"
local hp = 1.0
handler.addUseCondition("hp_gate", function()
	return hp > 0.5
end)
-- 之后 handler.canUse() 会连带判定;不再需要时 handler.removeUseCondition("hp_gate")

技能背包 handler ​

方法说明
getManager() / getOwner() / getOwnerId()背包脚本 / 拥有者单位 / 拥有者玩家名
getAbilities() / getAbilitiesInSlotRange(a, b)技能列表(按槽位升序)/ 区间列表
getAbility(slotIndex)槽位 → handler
getFirstAvailableSlot()第一个空槽索引
addAbility(abilityScript, slotIndex) / removeAbility(slotIndex)装 / 卸(一般用 AbilityAPI.AddAbility 更省事)
moveAbility(fromIndex, toIndex)交换两个槽位(含 Index 属性与广播)
setSlotAbility(slotIndex, abilityScript)整体替换槽位成员(子技能切换用)
stopAbility(slotIndex)打断指定槽位施法
forbidSlot(slotIndex, isForbid, grayout)禁用/启用槽位(广播给所有客户端,UI 灰显)
notifySwitchNext(slotIndex, newActiveIndex)广播子技能切换

common 注册表 ​

分类方法说明
技能getAbilityHandler(script) / registerAbility / unregisterAbility技能 handler 反查/注册
背包getManagerHandler(script) / findManagerForPlayer(player) …背包 handler 反查
组findGroup(parentScript) / findGroupByChild(childScript)子技能组反查
域register(domain, key, value) / get(domain, key) / list(domain)开放域("PointerType" / "ReleaseStrategy")
标签addTag(unit, tag) / hasTag(unit, tag) / removeTag(unit, tag) / clearTags(unit)标签读写(引擎组件缺失时走 Lua 兜底)
键unitKeyOf(scriptUnit)统一键推导(优先 UnitId,异常时回退字符串)

事件系统(怎么监听) ​

事件单位模型 ​

  • 事件 = 挂在某个单位下的 BindableEvent 子单位,名字就是事件名。
  • 监听:单位:FindFirstChild("<事件名>"):Connect(function(...) end)。
  • 生命周期不依赖父子级联销毁:所有者销毁时框架会显式销毁事件单位(技能 / 锚点 / 管理器都已处理)。 自建事件单位请在销毁时自行清理。
  • 事件是本端本地广播:服务端触发只对服务端监听者有效;客户端表现靠属性同步 + s→c 广播。

技能级事件(挂在技能单位下,共 18 个) ​

事件名载荷时机
BeforeUse(abilityScript)使用尝试开始(合法性检查前,结果未定)
Used(abilityScript)本次使用已生效(检查通过、蓄力放行、充能扣减之后)
BeforeCast(abilityScript, releasePoint, releaseDir)施法前
CastStart(abilityScript)施法开始
CastEnd(abilityScript)施法完成(正常结束)
CastBreak(abilityScript)施法被打断(技能销毁时也会发一次)
CDStart(abilityScript)进入 CD
CDEnd(abilityScript)CD 结束
Charge(abilityScript, chargeCount)充能层数变化
ChargeFull(abilityScript)充能已满
AccumulateStart(abilityScript)蓄力开始
AccumulateBreak(abilityScript)蓄力被打断(先于 AccumulateEnd)
AccumulateEnd(abilityScript)蓄力结束(任何退出路径必发)
OnDowngrade(abilityScript, newLevel)技能降级
AnchorStart(abilityScript, groupId)锚点点火(技能级镜像,带 groupId)
AnchorBreak(abilityScript, groupId)锚点中断
AnchorEnd(abilityScript, groupId)锚点结束
AnchorStop(abilityScript, groupId)锚点终结(Break/End 之后必发)

锚点级事件(挂在锚点单位下,4 个) ​

名字相同(AnchorStart / AnchorBreak / AnchorEnd / AnchorStop),但:

  • 主体是锚点自身,载荷只有 (abilityScript) —— 监听方不需要用 groupId 过滤;
  • 优势:一个技能有多个同类锚点时,可以逐个锚点单独监听。
lua
local anchor = abilityScript:FindFirstChild("你的锚点节点名")
anchor:FindFirstChild("AnchorStart"):Connect(function(abilityScript)
	-- 这个锚点点火了(不需要区分是哪个 groupId)
end)

管理器级事件(挂在技能背包单位下) ​

事件名载荷时机
AbilityAdded(abilityScript, slotIndex)技能加入背包
AbilityRemoved(abilityScript, slotIndex)技能移出背包
AbilityUpgraded(abilityScript, newLevel)技能升级

子技能组事件(挂在父技能单位下) ​

事件名载荷
OnActivated(newHandler, newIndex)
OnDeactivated(oldHandler, oldIndex)
OnSwitchNext(newHandler, newIndex)

服务端 → 客户端广播(RemoteEvent "AbilitySystem") ​

客户端已内部处理这些广播,作者通常无需关心;仅在你自绘技能栏 UI 时才需要知道:

actionargs
OnCastStart{ abilityUnitId, releasePoint, releaseDir }
OnCastEnd / OnCastBreak{ abilityUnitId }
OnCDStart{ abilityUnitId, cdTime }
OnCDEnd{ abilityUnitId }
OnCharge{ abilityUnitId, newCount }
OnSwitchNext{ ownerUnitId, slotIndex, newActiveIndex }
ForbidSlot{ ownerUnitId, slotIndex, isForbid, grayout }
AbilityAdded / AbilityRemoved / AbilityMoved槽位增删与移动的兜底通道

客户端 UI Hook(自绘 UI 用) ​

不想自己重写技能栏 UI、只想改某些表现时,可覆盖 UI hook:

lua
local AbilityUIHooks = require("common.packages.ability_system.ui_hooks")
AbilityUIHooks.setUIHooks({
	onInCDChange = function(abilityScript, isInCD, cdFinishTime) end,
	onChargeCountChange = function(abilityScript, chargeCount, maxChargeCount) end,
	onAccumulateChange = function(abilityScript, startTime) end,
	onPointerTypeChange = function(abilityScript, pointerType) end,
	onReleaseTypeChange = function(abilityScript, releaseType) end,
	onIndexChange = function(abilityScript, newIndex) end,
	onAbilityAdded = function(managerScript, abilityScript, slotIndex) end,
	onAbilityRemoved = function(managerScript, slotIndex) end,
})

可覆盖的 hook 全集(共 11 个):onInCDChange、onInCastChange、onChargeCountChange、 onAccumulateChange、onPointerTypeChange、onReleaseTypeChange、onIndexChange、onOwnerIdChange、 onAbilityAdded、onAbilityRemoved、onSwitchNext。 setUIHooks 只替换已存在且是函数的键,传未知键会被忽略。

未覆盖 hook 的默认表现

未覆盖的 hook 仍走内置技能栏 UI 的默认表现。

属性速查 ​

技能预设配置属性(编辑器可配) ​

来源:server/packages/ability_system/ability_script.lua。属性名 = 字段名,运行时用 script:GetAttribute("字段名") 读。

分组字段类型默认说明 / 显隐条件
基础CdTimenumber3.0冷却时间(秒)
CastTimenumber0.5施法时间(秒),到点自动完成施法
升级Upgradablebooleanfalse能否升级
MaxLevelinteger5技能最大等级(Upgradable==true)
LevelRequirementsInt[]{}各等级所需角色等级(下标=目标技能等级)(Upgradable==true)
蓄力EnableAccumulatebooleanfalse能否蓄力
MaxAccumulateTimenumber1.0满蓄力时间;蓄力比 = 已蓄时长 / 该值(上限 1)(EnableAccumulate==true)
限制/释放UseLimitationInt(枚举)00 无限制 / 1 施法可用 / 2 失控可用 / 4 滚动可用
ReleaseTypeInt(枚举)00 松开施放 / 1 按住持续 / 2 两段式点击 / 3 按下施放
指示器PointerTypeInt(枚举)00 无 / 1 矩形 / 2 圆形 / 3 扇形 / 4 抛物线
ReleaseDistancenumber10.0施法范围(PointerType!=0)
AffectWidthnumber1.0影响宽度(PointerType==1)
SectorAnglenumber90.0扇形广角(PointerType==3)
ReleaseRadiusnumber3.0影响半径(PointerType in {2,3})
ParabolaHorizontalSpeednumber10.0水平速度(PointerType==4)
ParabolaVerticalSpeednumber10.0垂直速度(PointerType==4)
目标筛选TargetFilterTypeString[]{}目标类型筛选(空=不筛选,多选任一命中即通过,如 "EggyUnit"/"HumanUnit"/"PhysicsUnit")
TargetTypeInt(枚举)10 无目标 / 1 方向 / 2 点 / 3 单位
TargetFilterCampInt(枚举)00 不限 / 1 敌方 / 2 友方
TargetRequiredTagsString[]{}必须标签(全部满足才命中)
TargetIgnoredTagsString[]{}忽略标签(含任一即跳过)
充能IsChargeConsumingbooleanfalse是否消耗充能
ChargeTypeInt(枚举)00 不充能 / 1 不满时充能 / 2 用完后充能
MaxChargeCountinteger5充能上限
ChargeAmountinteger1每次充能增加次数
ChargeIntervalnumber1.0充能间隔(秒)
子技能SwitchModeInt(枚举)00 不切换 / 1 施法后切换 / 2 定时切换 / 3 仅手动
SwitchOrderInt(枚举)00 顺序循环 / 1 洗牌 / 2 随机
SubAbilitiesList<ability_system.SubAbilityInfo>{}子技能列表(与父技能共用同一槽位)
SwitchIntervalnumber3.0切换间隔(SwitchMode==2)
SwitchCooldownnumber0.5切换后冷却(SwitchMode!=0)
RevertTimenumber0回归计时(SwitchMode==1;0=不回归)
SwitchRefreshbooleantrue子技能间互切是否重置回归倒计时(SwitchMode==1)

技能运行时属性(服务端写入,客户端同步可见) ​

只读使用,不要手动改(改了不会触发状态机):

属性含义
Index槽位号;-1 = 未入槽(子技能休眠)
Level当前等级(初始 1)
InCD / CdFinishTime是否 CD 中 / CD 结束的服务器时间戳
InCast / CastStartTime是否施法中 / 施法开始的服务器时间戳
AccumulateStartTime>0 = 蓄力中;退出蓄力清零
AccumulateRatio本次施法捕获的蓄力比 0~1(在 CastStart 钩子里读)
ChargeCount当前充能层数(充能型初始 = MaxChargeCount,否则 0)
ReleasePoint / ReleaseDir / ReleaseTarget本次释放的点 / 方向 / 目标
OwnerId归属玩家名(由技能背包写入;"" = 尚未解析)
AbilityName / AbilityPresetKey装备时写入的技能名 / 预设 Key

客户端 ability_local_script 只对以下 8 个属性的变化做响应式绑定(自绘 UI 可参考): InCD、InCast、ChargeCount、PointerType、ReleaseType、Index、OwnerId、AccumulateStartTime。

从视角读 UI 需要的量(客户端的通用做法):

lua
local leftCD = (script:GetAttribute("CdFinishTime") or 0) - World:GetServerTime()
local ratio  = World:GetServerTime() > 0 and (World:GetServerTime() - script:GetAttribute("AccumulateStartTime")) / script:GetAttribute("MaxAccumulateTime") or 0

技能背包属性 ​

属性编辑器可配说明
InitAbilities是List<ability_system.AbilityInfo>,初始技能列表(每项 = 技能预设 AssetId + 槽位)
OwnerId / PlayerId / ManagerReady否运行时由服务端写入(归属玩家名 / UserId / 就绪标记)

状态机语义(必须知道的规则) ​

能不能放:canUse() ​

依次判定,任一不满足即禁止:

  1. 不在 CD(InCD == false);
  2. 不在施法中(InCast == false);
  3. 若是充能消耗型:ChargeCount > 0;
  4. 所有通过 addUseCondition 注入的外部条件都返回真。

施放被拒的处理

被拒时 startCast 直接返回 false,并且如果此时正处于蓄力态,会兜底退出蓄力。

CD 起点与"锚点优先、框架兜底" ​

  • 正常结束施法(CastEnd)时才由框架自动进 CD;被打断不进 CD(可由作者在自己的反应里决定)。
  • 如果本轮使用中已经有锚点(如 【锚点】激活冷却)调用了 handler.enterCD(), 框架的兜底 CD 不会再重复进一次。
  • 这解决的是"CD 走完又走一次 / UI 环转好了还要等一会儿才能再放"的问题。 结论:想让 CD 由锚点精确控制 → 挂 active_cd 锚点;想用统一 CD → 不挂,用 CdTime 兜底。

蓄力(Accumulate) ​

  • 按住 → startAccumulate()(需 EnableAccumulate=true,且 canUse() 通过);
  • 蓄力比实时读 handler.getAccumulateRatio();
  • 转入施法时,框架把这个比值写进 AccumulateRatio 属性 → 在 CastStart 里读它做威力缩放;
  • 退出路径:AccumulateBreak(被打断,先发)→ AccumulateEnd(任何路径必发)。

充能(Charge) ​

  • ChargeType:1 不满时充能 / 2 用完后充能 / 0 不充能;
  • 每次施法扣 1 层(在 canUse 通过之后扣);
  • ⚠️ ChargeType=0 用完就永久不能放了(不可逆),要"能回复"必须把 ChargeType 设成 1 或 2。

使用限制(位掩码) ​

UseLimitation 是位掩码:1 施法中可用 / 2 失控可用 / 4 滚动可用 / 8 冰冻可用。 可用 handler.getLimitation(type) 查询、handler.setLimitation(type, enable) 动态改。

升级 ​

  • 需 Upgradable=true,且 Level < MaxLevel;
  • LevelRequirements[下一等级] 若存在,则要求 owner.CustomProperty.LevelRuleLevel >= 该值;
  • upgrade 会向背包发 AbilityUpgraded;increaseLevel 跳过所有校验强制升。

锚点系统 ​

锚点语义 ​

配置说明
Phase1 蓄力阶段 / 2 施法阶段(锚点只在其所属窗口内生效)
StartTime窗口开始后延迟多少秒点火(0 = 立即)
Duration持续时长(秒);0 = 保持到窗口结束;>0 = 到点自动结束
TrackIndex时间轴轨道序号(编辑器排版用)

事件路径(服务端):

  • Phase=2 施法阶段:CastStart → 按 StartTime 点火 → CastEnd(正常收尾)/ CastBreak(打断);
  • Phase=1 蓄力阶段:AccumulateStart → 点火 → AccumulateEnd / AccumulateBreak;
  • 点火发 AnchorStart;正常结束发 AnchorEnd;被打断发 AnchorBreak;之后必发 AnchorStop(统一收尾信号)。
  • 因此:要"做了就一定要撤销"的效果,写在 AnchorStop 里最稳(End / Break 两条路都会走到它)。

锚点预设模板(双层挂载) ​

按类型 「锚点」 新建时,生成的预设已经把锚点壳源码放在 Script 上;把壳源码最后一行换成你要用的锚点行为模块即可。 锚点预设的源码就是这套结构(两层:框架调度 + 行为逻辑):

lua
---@type number 开始时间
StartTime = 0.0
---开始时间

---@type number 持续时长
Duration = 0.0
---持续时长

---@type Int
---@style enum
---@enum [[1, "Accumulate", "蓄力"],[2, "Cast", "施法"]]
---@title 生效阶段
Phase = 2
---所属阶段

---@type integer 轨道序号
TrackIndex = 0

local RunService = game:GetService("RunService")
if RunService:IsServer() then
	local AnchorLogic = require("server.packages.ability_system.anchor_logic")
	AnchorLogic.Attach(script)                                            -- ① 框架:排程 + 创建锚点级 4 事件
	require("server.packages.ability_system.anchors.melee_hit").Attach(script)  -- ② 行为:换成本锚点类型的模块
end

锚点单位必须挂在技能单位之下(script.Parent = 技能脚本),否则框架会跳过并打印日志。

16 种锚点行为、配置键、硬约束 ​

现成行为模块位于 server/packages/ability_system/anchors/。下表配置键即属性名(在锚点预设上填); ABILITY_ANOSTATE_* 是编辑器里自动生成的配置键,Offset/Scale/Socket 等为普通键。

锚点类型关键配置键(默认值)硬约束 / 备注
active_cdInstant无(读宿主 CdTime)点火即让宿主进 CD;配合 §CD 起点与框架兜底 判重逻辑
cast_face_targetInstant无施法者朝向转到释放方向(仅水平 yaw)
destroy_abilityInstant无把宿主技能从背包移除
custom_eventInstantABILITY_ANOSTATE_EVENT_NAME触发 World.CustomEvent[名]:Fire()
play_vfxInstantABILITY_ANOSTATE_NEW_SOUND、Volume(50.0)、SoundDuration(1.0)一次性 3D 音效;CampRoleId 固定 0(所有人可听)
create_obstacleInstantABILITY_ANOSTATE_NEW_OBJ、Offset(0,0,0)、Scale、ABILITY_ANOSTATE_BIND、ABILITY_ANOSTATE_LIFE_DUR实体自管生命周期(Instant 无终结语义)
create_landmineInstantOffset(0,0,0)、ABILITY_ANOSTATE_NEW_OBJ、Scale、ABILITY_ANOSTATE_LIFE_DUR、ABILITY_ANOSTATE_NEW_SFX、ABILITY_ANOSTATE_AREA、ABILITY_ANOSTATE_HITPOWER非 Static 需转 Dynamic 才能碰撞;生成后 1s 才注册碰撞、0.8s 后解除免碰撞对
create_teleportballInstantBallDuration、ABILITY_ANOSTATE_BULLET_OBJ、Offset(0,0,0)、Scale、ABILITY_ANOSTATE_BULLET_HSPEED、ABILITY_ANOSTATE_BULLET_VSPEED命中后建传送门并传送施法者;配表时旧的 VSPEED 值填 HSPEED
create_bullet_to_castangleInstantABILITY_ANOSTATE_NEW_SFX、ABILITY_ANOSTATE_AREA、ABILITY_ANOSTATE_HITPOWER、Offset(0,1,0)、Ability.TargetType==1 分支、ABILITY_ANOSTATE_BULLET_OBJ、Scale、ABILITY_ANOSTATE_BULLET_HSPEED/VSPEED/DUR、ABILITY_ANOSTATE_COLLISION_CASTER_TIME、ABILITY_ANOSTATE_IGNORE_GRAVITATION_TIME、ABILITY_ANOSTATE_ONLYDESTROYBOOM、ABILITY_ANOSTATE_BULLET_HITDESTROY、ABILITY_ANOSTATE_ONLYHITEGGY子弹/爆炸击退;不要自己调 bullet:Destroy()(框架已在销毁回调里处理,双销毁会崩)
bungee_cordInstant + 内部状态机SPRING_ROPE_REST_LENGTH、SPRING_ROPE_STIFFNESS、SPRING_ROPE_DAMPING_COEFFICIENT、SPRING_ROPE_TENSION_FADE_IN_DURATION、SPRING_ROPE_MAX_PER_TENSION、SPRING_ROPE_DRAG_MAX_DURATION、SPRING_ROPE_TOTAL_MAX_LIFETIME、SPRING_ROPE_SPAWN_FORWARD_OFFSET、SPRING_ROPE_SPAWN_UP_OFFSET、SPRING_ROPE_MODEL_ROTATION_CORRECTION、SPRING_ROPE_PARABOLA_GRAVITY_ACC;宿主 ParabolaHorizontalSpeed(10)/ParabolaVerticalSpeed(8)绳必须转 Dynamic,否则永不碰撞;SPRING_ROPE_MAX_PER_TENSION 没有兜底默认值,必须填
add_bind_diy_modelConstantABILITY_ANOSTATE_USE_PERFAB、Socket("origin")、Offset(0,3,0)挂到骨骼插槽,终结时销毁
gravity_changeConstant-onlyABILITY_ANOSTATE_UP_ACCELERATIONDuration 必须 > 0;期间持续施力使净 Y 加速度≈配置值
speed_addConstant-onlyABILITY_ANOSTATE_WALK_SPEED(可为负)Duration 必须 > 0;终结时恢复原速
play_body_animationConstantABILITY_ANOSTATE_ANIMKEY、ABILITY_ANOSTATE_ANIM_HALF、ABILITY_ANOSTATE_LOOP、ABILITY_ANOSTATE_ANIM_STARTPOINT、ABILITY_ANOSTATE_ANIM_SPEED(1)需要施法者 Animator;半身动画固定 FilterType=30
play_sfxConstantABILITY_ANOSTATE_NEW_SFX、Offset(0,0,0)、ABILITY_ANOSTATE_SCALE(1.0)、ABILITY_ANOSTATE_BINDBind=true 时必须 Duration>0,否则无法回收
melee_hitConstantDuration(>0)、ABILITY_ANOSTATE_HITBOX_OFFSET(0,1,0)、ABILITY_ANOSTATE_HITBOX_SCALE(2,2,2)、ABILITY_ANOSTATE_BULLET_DAMAGE、ABILITY_ANOSTATE_HITPOWER、ABILITY_ANOSTATE_USE_PERFAB、ABILITY_ANOSTATE_ANIMKEY、ABILITY_ANOSTATE_BINDSOCKET("origin")、ABILITY_ANOSTATE_OFFSET、ABILITY_ANOSTATE_QUD、ABILITY_ANOSTATE_SCALE、ABILITY_ANOSTATE_FACE_SYNC(true)、ABILITY_ANOSTATE_HIT_SFX/_SCALE/_ROTATION、ABILITY_ANOSTATE_BULLET_HITSFXOFFSETDuration 必须 > 0(否则只打 WARNING 直接 return);命中盒与武器随锚点生命周期回收

锚点类监听差异

Instant 类锚点只监听 AnchorStart(点火即完成);
Constant 类锚点监听 AnchorStart + AnchorEnd/AnchorBreak/AnchorStop(保证可逆状态被撤销)。

写自己的锚点行为 ​

约定(与现有 16 个模块完全一致):

lua
-- server/packages/ability_system/anchors/my_action.lua
local M = {}

function M.Attach(anchor_script)
	local RunService = game:GetService("RunService")
	if not RunService:IsServer() then
		return
	end

	-- 1) 点火:监听锚点级 AnchorStart
	local start_sig = anchor_script:FindFirstChild("AnchorStart")
	if not start_sig then
		return
	end
	start_sig:Connect(function()
		local ability_script = anchor_script.Parent                 -- 宿主技能
		local owner = ability_script and ability_script.Parent and ability_script.Parent.Parent  -- 施法者
		local my_param = anchor_script:GetAttribute("MY_PARAM") or 0
		-- 你的表现/结算逻辑
	end)

	-- 2) 需要可逆效果时,额外监听 AnchorEnd / AnchorBreak / AnchorStop
	for _, name in ipairs({ "AnchorEnd", "AnchorBreak", "AnchorStop" }) do
		local sig = anchor_script:FindFirstChild(name)
		if sig then
			sig:Connect(function() end)   -- 收尾/清理
		end
	end
end

return M

要点:

  • 入口固定为 M.Attach(anchor_script),模块最后 return M;
  • 配置键通过 anchor_script:GetAttribute("<键>") 读取(编辑器里在锚点预设上填);
  • 需要 handler 时用 AbilityRegistry.getAbilityHandler(ability_script);
  • 需要可逆状态(加移速、加力、挂模型)就必须监听收尾三事件。

子技能组(槽位共享) ​

配置方式(编辑器,零代码):在技能预设上设 SwitchMode != 0 且 SubAbilities 非空即可 —— 装备技能时会自动创建子技能并建组。

语义:

  • 子技能与父技能共用同一槽位:切换时旧的 Index 置 -1(休眠,不打断进行中的施法),新的占据槽位;
  • 技能栏 UI 会自动跟随(走槽位增删广播);
  • 建组时子技能全部休眠(Index=-1);
  • SwitchMode:1 AfterCast(活跃成员施法结束轮转)/ 2 Timer(定时)/ 3 Manual(仅手动);
  • SwitchOrder:0 Sequential(顺序)/ 1 Shuffle(每轮洗牌,轮间尾头不重复)/ 2 Random(不重复上次);
  • SwitchCooldown:切换后新成员进 CD(回归路径不触发);
  • RevertTime(仅 AfterCast):切到非首成员后计时,到点自动回父技能;SwitchRefresh 控制子技能互切是否重置倒计时。

监听与手动切换

lua
local AbilityRegistry = require("common.packages.ability_system.registry")
local abilityScript = AbilityAPI.GetAbility(unit, 0)

-- 组级事件(挂在父技能单位下)
abilityScript:FindFirstChild("OnActivated"):Connect(function(newHandler, newIndex)
	print("激活成员", newIndex)
end)
abilityScript:FindFirstChild("OnDeactivated"):Connect(function(oldHandler, oldIndex)
	print("失活成员", oldIndex)
end)

-- 手动轮转(Manual 模式或任意模式显式触发)
AbilityAPI.SwitchNextAbility(unit, 0)

-- 直接拿到组对象做更细的操作
local group = AbilityRegistry.findGroup(abilityScript) or AbilityRegistry.findGroupByChild(abilityScript)
if group then
	group.activateNext()        -- 轮转
	group.switchTo(2)           -- 切到第 2 个成员(1 基)
	print(group.getActiveIndex(), #group.getMembers())
end

组对象接口(SubAbilityHandler.create 返回):activateNext()、switchTo(index)、getActiveIndex()、getActiveHandler()、getMembers()、getSignals()、destroy()。

手动建组(进阶)

require("server.packages.ability_system.sub_ability").create(parentHandler, parentScript, childHandlers, switchMode, switchOrder, timerDelay, options),
options 支持 shareSlot / slotIndex / switchCooldown / revertTime / switchRefresh / destroyWithMembers。

扩展:自定义指示器 / 释放策略 ​

注册入口(唯一):

lua
local AbilityClientAPI = require("client.packages.ability_system.api")
AbilityClientAPI.Register("PointerType", <整数key>, 你的类)       -- 指示器
AbilityClientAPI.Register("ReleaseStrategy", <整数key>, 你的类)   -- 释放策略

内置映射(key 与 AbilityConstants 对齐):

域key类
PointerType0 无 / 1 矩形 / 2 圆形 / 3 扇形 / 4 抛物线AbilityPointer / RectanglePointer / CirclePointer / SectorPointer / ParabolaPointer
ReleaseStrategy0 松开 / 1 按住 / 2 两段式 / 3 按下ReleaseStrategy / HoldStrategy / TwoStageStrategy / PressStrategy

查不到 key 时会静默回退到基类(AbilityPointer / ReleaseStrategy),不会报错。

自定义指示器(Pointer) ​

lua
local AbilityPointer = require("client.packages.ability_system.pointer.AbilityPointer")
local AbilityClientAPI = require("client.packages.ability_system.api")

local MyPointer = {}
setmetatable(MyPointer, AbilityPointer)
MyPointer.__index = MyPointer
MyPointer.super = AbilityPointer

-- 必须实现:接受 (技能脚本, Controller),由框架调用 cls.new(ability, uiController)
function MyPointer.new(ability, uiController)
	local obj = AbilityPointer.new(ability, uiController)
	obj.effectInfo = { "official://preset/7185", "official://preset/7186", 20.0 }  -- {特效, 取消特效, 基础长度/半径}
	obj.distanceEffectInfo = { "official://preset/7187", "official://preset/7188", 10.0 }
	setmetatable(obj, MyPointer)
	return obj
end

-- 可选覆写:Create(dirInfo) / Refresh(dirInfo) / UpdateEffect() / GetReleasePoint(dirInfo) / Clear() / Destroy()
-- 若覆写 Create 仍想要默认特效与选中环,请先调用 self.super.Create(self, dirInfo)

AbilityClientAPI.Register("PointerType", 5, MyPointer)
return MyPointer

指示器会读的技能属性:PointerCount、ReleaseDistance、PointerOffset、PointerOffsetAngle、PointerIntervalAngle、 ReleaseRadius(圆形)、ParabolaHorizontalSpeed / ParabolaVerticalSpeed(抛物线)。

自定义释放策略(Strategy) ​

lua
local BaseStrategy = require("client.packages.ability_system.strategy.BaseStrategy")
local AbilityClientAPI = require("client.packages.ability_system.api")

local MyStrategy = setmetatable({}, BaseStrategy)
MyStrategy.__index = MyStrategy

-- 可选实现:OnTouchBegin(eventData) / OnTouchMove(eventData) / OnTouchEnd(eventData) / Destroy()
-- self.ctrl 是 Controller 实例(框架注入),用它与服务端交互:
function MyStrategy:OnTouchBegin(eventData)
	self.ctrl:_requestAccumulate()          -- 按下即蓄力
end

function MyStrategy:OnTouchEnd(eventData)
	self.ctrl:_requestCast(nil, nil, nil)   -- 松开即施放
end

AbilityClientAPI.Register("ReleaseStrategy", 4, MyStrategy)
return MyStrategy

必须知道的 3 件事:

  1. 不要依赖自己的 new —— 框架不会调用它;实例创建后直接使用 self.ctrl。
  2. 触发请求用 self.ctrl:_requestCast(point, dir, target) / _requestStop() / _requestAccumulate() / _requestStopAccumulate()(下划线是内部命名,但这是策略层唯一通道,内置 4 个策略也这么用)。
  3. 若需要"点场景选点"(两段式那样):暴露 isAiming 字段与 OnSceneClick(screenPos),用 SceneInput.Connect(self, game:GetService("UserInputService")) 接场景点击。 eventData 字段为 BeganPosition / MovedPosition / EndedPosition(Vector2);从按钮点击回调进来时可能是零向量,需自行容错。

技能栏 UI 节点约定 ​

技能栏 UI 由客户端 UIManager 自动发现并绑定,识别完全基于节点自定义属性(custom_kv),不依赖节点名字:

节点用途需要的 custom_kv说明
技能槽位UIType = "AbilitySlot" + Index = <槽位号>Index 必须是数字且与技能槽位一致;0 也是合法值
蓄力进度节点UIType = "AbilityAccumulateNode"单例,不需要 Index
取消区域UIType = "AbilityCancelArea"可摆多个;不需要 Index

槽位节点的子节点名(必须拼写一致,缺谁就少哪个功能,不会报错):

子节点名作用
ImageMoveRange拖拽范围指示(也会被当作取消态变色对象)
ImageTouchPoint摇杆点
ProgressTimer 或 progress_timerCD 进度(需支持 SetPercent)
CdText剩余秒数(%.1f)
CdMask灰显遮罩(槽位禁用/灰显时显示)
ChargeBackground充能背景
ChargeProgressTimer 或 charge_progress_timer充能进度(需支持 SetPercent)
ChargeCount充能层数文本

蓄力节点的子节点:Icon(图标);进度条可以就是节点自身(只要它支持 SetPercent)。

自绘 UI 可直接用 UIManager:

lua
local UIManager = require("client.packages.ability_system.ui.UIManager")
local um = UIManager.GetInstance() or UIManager.new(manager)   -- 单例
um:BindAbility(abilityScript)      -- 手动绑定一个技能(正常由事件自动触发)
um:UnBindAbility(slotIndex)        -- 解绑
um:SetManager(manager)             -- 换背包(重生/换角色后)
um:ResetBindings()                 -- 清空所有 UI 绑定(切背包时)
um:SetCancelAreaVisible(true)      -- 显示/隐藏取消区
um:IsInCancelArea(position)        -- 判断某点是否落在取消区

道具箱(item_box,可选) ​

给技能加"道具箱表现"(技能被拾取/放置时的头顶模型、放置动画、摘除技能栏 UI 等)。

用法(两步):在技能预设下挂一个子 Script 节点,把 server/packages/ability_system/item_box/item_box_shell.lua 的源码贴进该子节点的 SourceCode,然后在属性面板按需填写字段。不需要道具箱表现的技能不挂这个子节点即可。

字段默认说明
HeadPrefab无(必填)头顶模型预设;不填会报错
HeadOffset(0, 2, 0)头顶模型偏移
HeadRotation(0, 0, 0)欧拉角(度)
HeadScale(1, 1, 1)缩放(零分量会被兜底为 1)
PlaceAnimIdofficial://animation/25205放置动画

行为概要:监听宿主技能的 CastStart / CastEnd / CastBreak(事件单位为技能单位下的 BindableEvent), 切换头顶模型的显示;技能可用次数耗尽时摘掉技能栏 UI(把 Index 置 nil)并在 50 秒后销毁技能单位; 举横批动画 official://animation/25862 硬编码;头顶模型只作展示、不参与碰撞。

附录:常见坑 FAQ ​

Q1:AddAbility / CastAbility 里的 unit 传角色还是技能背包?

服务端 API 传角色(拥有者);客户端 API 传技能背包。两者都能靠 AbilityManager 标签反查,传错会返回失败信息。

Q2:CastAbility 返回 true,但没看到表现?

服务端只负责状态机,表现要靠锚点或你自己的事件反应(CastStart 里播特效/动画)。

Q3:槽位 -1 是什么?

"未入槽"哨兵(子技能休眠态)。它不是合法槽位,客户端不会为它绑 UI。合法槽位从 0 开始,无上限。

Q4:AddAbility 同槽同预设会怎样?

直接复用现有实例并返回它,不会重复创建。同槽被别的预设占用才返回 nil, err。

Q5:技能在 CD / 正在施法,CastAbility 静默失败?

是的,canUse() 不通过就返回 false。先查 InCD / InCast / ChargeCount / 外部条件。

Q6:充能技能放完几次就再也放不出来了?

ChargeType=0(不充能)时用完即死锁,这是配置语义。要回复必须把 ChargeType 改成 1 或 2。

Q7:锚点没反应?

依次查:① 锚点是否挂在技能单位下(不是挂在管理器/角色下);② Phase 是否与实际窗口一致(蓄力锚点在施法阶段不会点火); ③ Constant 类锚点 Duration 是否 > 0;④ 服务端日志里是否有 [AnchorLogic] 锚点已挂载(没有则说明框架没跑到)。

Q8:AnchorStop 和 AnchorEnd / AnchorBreak 什么区别?

End = 正常收尾,Break = 被打断,Stop = 两者之后必发的统一收尾信号。要"包清理"就写 Stop。

Q9:事件监听拿不到事件单位(FindFirstChild 返回 nil)?

事件单位由服务端在技能 Attach 时创建;如果你在技能刚创建、还没 Attach 时就去取,会拿不到。 建议在 AddAbility 的返回值上取,或用 ChildAdded 等待。锚点事件单位由锚点运行时创建 —— 锚点单位本身也要先就绪。

Q10:客户端 UI 不显示槽位?

槽位节点要有 UIType="AbilitySlot" 与正确的 Index;技能要真正"入槽"(Index >= 0); 另外 OwnerId 为空字符串时客户端是放行的(未解析状态),若非空则要求与本地玩家一致。

Q11:子技能切换后技能栏没更新?

切换走的是"槽位替换"通道(先移除后加入的广播),UI 会自动跟随;OnSwitchNext 只是给作者钩子用的通知。

Q12:handler 的方法要用冒号还是点?

点调用即可(用冒号也能跑,多余的 self 参数会被忽略)。