zinc-ui-async-separation
War3Lib UI 组件开发行为规范(Zinc in .j):约束 UI 本地异步行为与同步数据逻辑分离;规范 onClick/onEnter/onLeave 与 spClick/spEnter/spLeave 的使用边界;规范本地事件转同步事件时的数据打包与触发器接收。用于创建或重构 Jass/ui/composite 下的 UI(如 HeroSelector/Selector/Museum)
SKILL.md.codex/skills/zinc-ui-async-separation/SKILL.md
GitHubWar3 UI 异步分层规范
强制规则
- 保持
UI 展示层与数据/游戏状态层分离。 - 把
GetLocalPlayer()分支内的逻辑视为异步本地逻辑;禁止在其中修改同步状态(单位、计时器、全局对局数据等)。 show/hide/refresh等 UI 生命周期方法禁止做业务数据写入;尤其禁止在 UI 操作链路内执行DzAPI_Map_Store*和其他会导致数据变动的操作。- 在 UI 回调里只做三类事情:
- 本地 UI 更新(show/hide/text/texture/位置)
- 事件参数写入(
currentPosAsync/args*/eventdata.bind*) - 触发异步到同步桥接(如
syncBus.DzSyncDataEx)
Simple UI 显隐防雷(重要)
- 对
uiImage.createSimple/uiText.createSimple/uiBtn.createSimple创建的控件,禁止直接调用.show(true/false)作为显隐手段。 - 原因:
show()底层走DzFrameShow,在部分 simple 原生链路会触发本地函数报错。 - 推荐做法:显隐改为
clearPoint + setPoint,显示时放回目标锚点,隐藏时移到屏幕外(例如DzGetGameUI()左下角外)。 - 对
uiBtn.createSimple/SIMPLEBUTTON要更严格:不要用两个相对锚点依赖 image/text 去撑开点击区域,显示时优先setSize(固定宽高) + setAbsPoint(绝对坐标)。 - 隐藏
uiBtn.createSimple时不要只clearPoint + setAbsPoint,旧 hit rect 可能残留并截断原生单位面板 hover/click;先setSize(0.001, 0.001)再移出屏幕。 - 若出现“贴图/文字已经消失,但鼠标仍有点击声或挡住 UnitPanel/Attr 图标”的现象,优先检查 simpleButton 点击矩形是否没有被固定尺寸/缩小尺寸刷新。
- 初始化后必须做句柄保护:若
obj == 0或obj.ui == 0,立刻return,禁止继续setTexture/onClick/show链式调用。 - 若报错栈含
DzFrameShow -> s__uimage_show / s__uitext_show / s__uibtn_show,优先按“simple 调用了 show”定位。
开局/难度阶段 DzFrame 绑定防雷
- 不要在
onInit()、地图开局短 timer、AfterChooseDiff()的同步主链路中批量创建DzFrameBindWidget到世界单位的 UI,尤其不要在 HeroSelector 创建前创建绑定到商店/NPC/马甲单位的框。 - 休闲模式会在选难度后隐藏全图单位;如果此前已把 frame 绑定到这些单位,再叠加每帧
DzGetUnitUnderMouse()/DzFrameShow()刷新,容易导致客户端卡死或未响应。 - 把“数据初始化”和“本地 UI 绑定创建”拆开:同步数据可用短 timer 分帧初始化;
DzFrameBindWidget、商店头顶文字、世界单位跟随 UI 应在首次真正需要刷新/显示时懒创建。 - 每帧 UI 刷新函数必须先做轻量 guard:没有任何已创建 frame root 时直接
return,不要提前调用DzGetUnitUnderMouse()或遍历显隐。 - 若必须在难度确认后预热数据,每 tick 只处理少量在线玩家/对象;不要把多玩家随机表、frame 创建、单位显隐挤在同一帧。
- 排查“只在休闲模式卡死,普通难度正常”时,优先检查休闲模式隐藏单位与
DzFrameBindWidget、世界单位绑定 UI、每帧鼠标检测之间的交互,而不是只看难度数值逻辑。
事件绑定选择
- 无数据绑定的事件用
onClick/onEnter/onLeave。 - 需要携带绑定数据(frame -> eventdata -> 业务参数)时用
spClick/spEnter/spLeave。 - 使用
sp*时,先绑定uiHashTable(frame).eventdata,再在回调中解包;禁止直接依赖外层局部变量闭包。 - 不需要
frame/eventdata的回调必须优先复用具名code:例如.onLeave(function Foo.clearTooltip),避免每个控件生成一层只转发的 lambda。 - 需要
frame的sp*回调也优先传具名uiEvent:例如.spEnter(uiEvent.FooOnEnter);不要写.spEnter(function(integer frame) { Foo.onEnter(frame); })这类每个控件一份的纯转发包装。 - 若目标逻辑在 struct method 中,为兼容 vjassc/jasshelper,抽一个库级
public function FooOnEnter(integer frame)再以uiEvent.FooOnEnter传入;多个控件共享这个适配函数。 - 只有在确实需要适配参数、临时组合多步逻辑、或写入一次性参数槽时,才使用匿名 lambda;重复出现时抽成具名静态方法。
异步转同步(本地事件出网)
- 本地点击/交互不直接执行业务数据修改;先编码 payload 并发送(示例:
syncBus.DzSyncDataEx(channel, payload))。 - 在
onDataSync(channel, ...)中解码并做权限校验(owner/player/对象存在性),通过后才触发trClick/trClose/trBtn1等同步逻辑。 - payload 至少包含三段信息:
- 事件类型(如
D/C/F/L/R) - 对象标识(如
sd、英雄索引) - 位置信息(如
pos)
回调参数传递
- 为匿名回调准备静态参数槽(如
currentSDAsync/currentPosAsync/argsHeroIndex)。 - 触发器前写入参数,触发后按需清理或覆盖,避免跨回调污染。
- 提供统一读取函数(
Get*Async/Get*Pos)给外部逻辑层。
生命周期与清理
- 销毁 UI 时,补发必要的离开事件(若存在“进入未离开”状态)。
- 销毁顺序保持“子组件 -> 主组件”,并把句柄置零。
- 本地 UI 关闭仅处理本地资源;同步状态变更交给同步事件处理器。
Museum/配置型 UI 的存档字符串性能
- 禁止在点击、hover、翻页、
refresh()、refreshCard()等交互热路径中反复调用会执行完整字符串标准化/解析的 getter。 - 存档字符串只允许在两个边界转换:打开或收到同步结果时解码一次;点击保存时编码一次。收包端仍保留完整格式、点数与业务合法性校验。
- 交互阶段使用按字段拆分的本地草稿缓存,例如
localLevel[]、localDisabled[]、serverLevel[]、serverDisabled[]。不同语义优先用独立类型数组,不为形式统一强塞二维整数数组。 - 初始化草稿时一次性计算
localSpent、localPoolCount等汇总值;加减等级和开关状态时按差值增量维护,禁止每张卡重新扫描整份配置。 refresh()开头只计算一次公共状态(如总点数、是否可编辑、dirty 状态),再作为参数传给当前页卡片刷新;禁止每张卡重复获取或计算。- 分页 UI 只刷新当前页卡片。换页时隐藏上一页、显示并刷新下一页,禁止每次操作遍历所有卡片后再由卡片自行判断是否属于当前页。
- 洗点直接清空草稿数组并重置汇总缓存,保存时再构造默认配置;不要为了 UI 预览反复拼接完整字符串。
- 发现 getter 内部调用
NormalizeConfig(),且外层循环又逐卡调用该 getter 时,按嵌套全量解析处理:先解码为数组缓存,再优化 UI 原生调用数量。
推荐流程:
load/sync -> normalize once -> decode arrays -> calculate aggregates once
click -> mutate one array slot -> update aggregate deltas -> refresh current page
save -> encode arrays once -> local precheck -> sync payload -> receiver full validation
创建期 OOS 防线(换肤/外观类)
- 在“英雄创建”这类同步流程里,禁止根据
BLoaded/ 本地缓存是否已读档来走不同同步分支。 - 创建期若需要应用外观:
- 先在全端应用同一份确定参数(通常是默认皮肤/默认外观)。
- 再由本地玩家发送同步 payload。
- 最终只在
onDataSync中按 payload 统一应用真实外观。
onDataSync若依赖对象存在(如H[idx]),必须容忍“消息先到、对象后到”的时序;必要时补一次下一拍重试或在创建后再次触发同步。- 避免在创建单位的同一帧立即发同步;可延迟一拍(如
0.03s)降低竞态概率。 DzAPI_Map_GetStored*的服务器开局快照不是本地异步输入:按.codex/skills/dzapi-server-save-load/SKILL.md的规则,由所有客户端在一致时机、按固定玩家顺序直接读取到同步缓存;禁止包进GetLocalPlayer,也禁止重新发包广播读档结果。- 只有真正的本地 UI 点击、键鼠输入或本地设置缓存,在需要驱动全端同步状态时才先转成同步消息。
Dz 同步与随机数
DzSyncData/DzSyncDataImmediately的接收回调里不要直接消耗GetRandom*,也不要立刻串起会消耗随机数的业务链路;需要时先落同步状态,再用短计时器延后到稳定帧处理。- 普通伤害事件、周期战斗事件等原生同步事件里的
GetRandom*不属于 Dz 收包风险;不要为了 OOS 排查把这些热路径全局队列化。 SyncBus的 OOS 探针如果会定时调用GetRandom*,必须有条件编译模式:关闭模式不注册探针且不消耗随机数,低频模式用于常规验证,高频模式只用于短时间定位。- 排查 OOS 时优先比较双端 Trace / 日志样本;确认不是探针自身放大随机序列差异后,再扩大探测频率。
确定性伪随机
- 需要替代
GetRandom*时,优先使用纯整数确定性种子 + 16807 LCG(Schrage 法)+ Fisher-Yates 洗牌;这样概率分布接近原洗牌,同时不消耗魔兽原生随机序列。 - 不要在热路径构造动态长字符串后
StringHash("A|" + I2S(x) + "|" + name);War3 字符串拼接有额外开销,也更难审查输入稳定性。 - 可接受的输入方式:对已有稳定字符串单独
StringHash,例如GetVersion()、GetPlayerName(p)、难度文本;再把开局时间、玩家序号、刷新次数等整数通过整数混合函数并入种子。 - 种子混合要保留原本参与随机的稳定信息,例如开局时间、版本、玩家名 hash、玩家序号、难度;只改变混合方式,不随意删掉影响分布/区分度的输入。
- 对候选池选择多个结果时,用确定性 Fisher-Yates 后取前 N 个,避免连续
ModuloInteger(seed, count)造成明显线性相关。
硬性检查清单(CR Gate)
在提交 UI 代码前,逐条检查。任意 FAIL 均禁止合并。
GetLocalPlayer()分支内不修改同步状态 PASS: 仅做 UI 显示、本地参数写入、发送同步消息。 FAIL: 创建/销毁单位、改全局业务数据、改计时器/组等对局同步状态。本地 UI 回调不直改业务数据 PASS:
onClick/spClick只编码 payload 或写current*Async/args*。 FAIL: 在 UI 点击回调里直接执行奖励发放、状态切换、背包改动等。正确选择
on*vssp*PASS: 无绑定数据用onClick/onEnter/onLeave;有 frame 数据解包需求用spClick/spEnter/spLeave。 FAIL: 需要eventdata却使用on*,或无数据需求滥用sp*。 PASS:sp*直接传具名uiEvent,on*直接传具名code。 FAIL: 回调主体只是Foo.bar(frame);或Foo.clearTooltip();,仍为每个控件创建匿名 lambda 包装。sp*回调前完成 eventdata 绑定 PASS: 对应按钮存在uiHashTable(btn.ui).eventdata.bind*。 FAIL: 回调中读取eventdata但未绑定或绑定不完整(缺少 bind2/bind3)。本地事件统一走“发送 -> 接收 -> 处理”链路 PASS:
DzSyncDataEx(channel, payload)发出后,在onDataSync(channel, ...)解码并处理。 FAIL: 本地事件绕过总线直接触发同步业务逻辑。onDataSync完成安全校验 PASS: 校验对象存在性、owner/player一致性、索引范围合法性后再触发业务触发器。 FAIL: 未校验即执行trClick/trClose/...。payload 结构可逆且稳定 PASS: payload 至少含
事件类型 + 对象标识 + 位置信息,解码规则与编码规则一一对应。 FAIL: 拼接格式歧义、长度位错误、解码位置错位。异步参数槽完整可读 PASS: 提供
Get*Async/Get*Pos读取接口,并在触发前写入对应参数。 FAIL: 回调依赖外层局部变量或隐式状态,导致异步读取不稳定。UI 销毁流程完整 PASS: 销毁前补发必要 leave,按子到父销毁并置零,避免悬空句柄。 FAIL: 直接销毁主框导致子组件泄漏,或 leave 状态丢失。
外部回调职责清晰 PASS: UI 层只发事件与展示;数据层在外部触发器/总线接收端处理。 FAIL: UI 文件内混入业务数据写入分支。
createSimple控件显隐方式正确 PASS:uiImage/uiText使用“改锚点/移出屏幕”的 simple-safe 显隐方案;uiBtn.createSimple显示时固定尺寸定位,隐藏时缩到0.001后移出屏幕;都有ui!=0保护。 FAIL: 对createSimple组件直接.show();或uiBtn.createSimple只改锚点、依赖相对锚点撑点击区、无句柄保护就调用 UI 原生函数。配置型 UI 不在交互热路径重复解析存档字符串 PASS: 加载时解码一次、交互时使用数组和增量汇总、保存时编码一次;刷新只处理当前页并复用公共状态。 FAIL:
refreshCard()逐卡调用会完整NormalizeConfig()的 getter,或每次点击反复拆解、重建同一存档字符串。
快速判定
- 全部 12 项
PASS:允许合并。 - 任意 1 项
FAIL:先修复再提审。 - 若无法判定:默认按
FAIL处理并补充注释说明边界。
参考实现(按需阅读)
references/war3lib-ui-examples.md