FIELD NOTES
NOTE 02 · AUTHORITY AND WEATHER
服务器说了算,天气不说了算
为什么战斗判定放在服务端,天气为什么被严格限制在表现层,以及这两件事之间的防火墙。
以下内容是开发者的个人记录,涉及内部里程碑、技术选择与失败过程。它描述的是当前状态,不是最终形态,也不构成任何发布承诺。
一句话版本
战斗结果由服务器决定;天气只是画面。这两件事之间有一道我不允许被跨过的墙。
下面是为什么,以及这道墙具体长什么样。
一、为什么权威放在服务端
Phase 1 用 Nakama 3.39.0 做服务器权威。服务端逻辑跑在 Nakama 的 TypeScript 沙箱里,
这个沙箱没有 Node 的 fs 和 crypto——一开始我觉得这是个麻烦,后来发现它其实帮了我:
它逼着所有确定性逻辑用最朴素的方式写出来,因为客户端(GDScript)必须能算出一模一样的结果。
权威模拟用整数 XZ 坐标,y_mm 从锁死的高度场推导。为什么是整数?
因为浮点在两套运行时之间不可能逐位一致,而我需要状态哈希能对上。
TypeScript 和 GDScript 两侧的公式、随机数、协议序列化、状态哈希都有配套的黄金向量,
两边跑出来的结果必须完全一致。
P1-WP1 是这一切的实验室:一个隔离的、只做权威移动的切片,30 Hz 移动协议,
一个 realtime 会话/socket 拥有者,客户端预测与和解。它跑完之后被封存成了不可变证据。
数字是这样的(都来自封存的证据,不是我事后估的):
- 正式档 profile 的 join RTT 中位数 / P95:
252.124 / 465.155 ms; - 劣化档(
200 ms延迟、50 ms抖动、3%丢包):3554.517 / 7935.412 ms; - Snapshot 上限
5,120字节,没有为了好看去伪造一个更小的数字; - 三个 WSS profile 各跑满
610秒测量,通过 CA 校验的 WSS 交叉绑定、tick、带宽、 精确释放、清理,以及严格的零 StableError / 哈希 / 协议 / socket / 序列过期检查。
劣化档那组数字很难看。我把它留着,因为它是真的。
二、失败也要留在证据里
第一次 v2 劣化档尝试 20260716T222636Z_degraded_200_50_3_67129 跑满了 610 秒,
但两个客户端一个 Event 都没收到。终结器直接拒绝并清除了这次运行,
没有为它编造一个 StableError 或 Match 层面的原因——因为当时确实推断不出来。
同源重试通过了,但它属于更早的源快照,所以被排除在最终聚合之外。
还有一次更离奇:正式档 20260716T231707Z_formal_120_20_1_21821 两次测量都完成、
两个客户端都零退出码,然后在收尾时被一个「无法读取的释放 ACK」拒绝了。
本地只读证据让我高度怀疑是 macOS 的 com.apple.provenance 扩展属性被延迟附加,
改动了 ACK 文件的 ctime,使它晚于负载的 mtime。
但——这个泛化的失败本身证明不了那个底层原因。所以我只针对那两个已关闭的 ACK 做了最小化的加固:每个文件最多三秒,至少间隔 250 ms 的两次完整严格读取, 比较文档内容、字节、模式、哈希、设备号、inode、大小、mtime、ctime 是否全等。 缺失、不安全、部分写入或持续变化的 ACK 仍然直接失败。其余每一个 JSON 保持单次严格读取。
然后所有五个正式 profile 因为源码改过而全部重跑。
我写下这一段,是因为「我猜是这个原因所以我这样修了」和「我证明了这个原因」是两回事, 把它们混为一谈是这个项目里最危险的事。
三、天气:能做什么,不能做什么
现在有三种可以实时切换的天气表现:warm_day(默认)、rainy_day、windy_day。
它们是配置驱动的表现层档案,各自是一个 JSON,声明状态就是
additive_presentation_only。
雨天改变的东西:
- 天空、太阳颜色与强度(
sun_energy从暖天降到0.42)、环境光、雾色与雾密度; - 体积雾、曝光、亮度/对比度/饱和度调整(饱和度降到
0.8); - 近景雨线
440条、远景雨线220条、地面水花64处,都用固定随机种子104729; - 地表湿润外观:法线偏移、色调、粗糙度
0.18、镜面0.72、菲涅尔强度0.16。
风天改变的东西:
- 世界空间的风迹(近景
72条、远景36条,固定种子130363); - 树冠摆动:
12个树 MultiMesh,风向 XZ(1.0, 0.28),强度0.18 m,速度1.35, 阵风频率0.55,抖动频率2.8。
约束写在同一份 JSON 的 constraints 段里,机器可读:
"constraints": {
"default_weather_remains": "warm_day",
"gameplay_authority_affected": false,
"terrain_collision_or_navigation_affected": false,
"town_or_title_affected": false,
"tree_trunks_animated": false,
"tree_shadow_casters_animated": false
}
所以,说清楚:
- 天气不改变地形、碰撞、导航、AI 感知、伤害、战术规则或联机权威;
- 不存在自动天气循环,不存在随机天气事件,不存在 Nakama 权威天气同步;
- 树干不动,投影树也不动——只有树冠的叶子在摆;
- 当前的切换入口在开发者构建的调试抽屉里(它有完整的
zh_CN/ja/en文案,也有对应的测试),普通玩家 UI 还没有暴露它。
官网上的那个晴/雨/风切换器,是表现层预览。它诚实地就是它看起来的样子:三张同一场战场、 同一机位、同一角色布局的画面,在三种天气表现下的对比。
我知道「动态天气改变地形与战术」听起来更带劲。但那不是现在的实现,所以我不会那样写。
四、性能:达成了什么,没达成什么
P0B 的光照 V3 在 desktop_high 下:
- 密度 20:GPU 中位数 / P95
18.000 / 22.420 ms; - Boss + 19:
19.670 / 24.612 ms; - Gate-C 视觉的 Metal GPU P95 天花板是
33.3334 ms(1280×720,desktop_high)——通过。
但 Phase 1 有一条更严格的目标:<16.67 ms。这一条目前开放着。
它没有被通过,也没有被豁免——它被推迟到 P1-B,最终的整包/混合负载证明不得晚于 P1-D。 我在状态文档里把它单独列成一行,就是为了不让它被「整体性能没问题」这种说法盖过去。
同时我也没有宣称任何稳定帧率。GPU 采样是 GPU 采样,它不等于玩家感受到的流畅度。
五、战斗本身现在是什么样
- 玩家动作:移动、自动斩、突进斩、旋风斩、闪避;
- 敌人:确定性的 Goblin 与 ordinary Minotaur AI,有界的攻击槽位与分离行为;
- 密度:正式峰值
20只,压力测试30只; - Boss:三阶段,带预兆、共享攻击者槽位、坚韧值与有界增援;
- 反馈:伤害、硬直、击退、死亡、伤害数字、特效、音效、HUD;
- 池:固定的敌人/特效/音效池,五分钟浸泡测试里零耗尽、零回退、零节点/孤儿增长。
在线部分(P1-WP2)授权范围是:私有 Hub 队伍码 / 队长 / 准备 / 队伍聊天; 权威的在线单人与双人森林战斗;服务端 AI / 碰撞 / 技能 / 伤害 / 升级 / Boss / 死亡 / 观战 / 结算;60 秒内的仅观战重连(不接管操作、不中途加入); 全有或全无的锁定名册结算与回执/重启恢复。
它必须停在 P1-B。这是我给自己划的线。
战斗还在迭代。四人的部分、结算的细节、Boss 的手感都还在动, 这一篇写的是现在的状态。