Skip to content

Repository files navigation

熔霜 · Moltenfrost

熔霜 · Moltenfrost

3D 双人元素协同解谜游戏,开发中。

  • 玩法:两名角色各具元素属性,在同一个物理世界内协同解谜,并在此基础上扩展元素反应与 PVE 内容。
  • 平台:桌面端,Windows(x86_64)与 macOS(Universal 2,支持范围为 Apple Silicon)。
  • 联机:双人各用一台设备,通过联机协作,不做单机同屏。
  • 技术:Godot 4.7.2,渲染后端 Forward+,物理引擎 Jolt,联机传输层用 ENetMultiplayerPeer

开发环境

需要 Godot 4.7.2 与该版本的导出模板。

node tools/setup-dev-env.mjs --check   # 查看模板是否齐全
node tools/setup-dev-env.mjs           # 不齐全时执行,安装本机平台的模板

各机器只导出自己平台的产物:Windows 机器出 Windows 版,Mac 机器出 macOS 版,因此默认只安装本机平台那一份模板,需要另一份时显式传 --platforms windows,macos

运行与自检

用编辑器打开工程即可运行。改动后按下面的命令做一次自检,控制台无脚本错误再交给人试玩:

godot --headless --path . --quit-after 3 -- --port 0

--port 0 表示让系统分配空闲端口:不带参数时会监听 27015,开发实例平时就占着它,自检会以"无法监听"的形式失败。

导出预设见 export_presets.cfg,产物写入 build/。两个预设都要保留,两台机器各用其中一份,否则这个文件会在两边分叉。

联机

目前没有主菜单,本机的角色由命令行参数决定:不带参数运行等于本机开局(主机,同时自己也是玩家),传 --join <地址> 则连接到远端主机。

# 主机,本机也是玩家
godot --path . -- --port 27015

# 加入方
godot --path . -- --join 192.168.1.20 --port 27015

# 专用服务端:本机不生成角色,供局域网或自建服务器使用
godot --headless --path . -- --host --port 27015

同一台机器上验证两个实例时,第二个实例连 127.0.0.1。服务端绑定的是通配地址,因此环回与局域网网卡同时生效,局域网内其他设备直接连内网 IP 即可。首次监听端口时 Windows 与 macOS 都会弹防火墙授权,要允许专用网络。

动了联机相关代码之后执行一次冒烟测试。它先跑一段不联网的插值逻辑测试,再无头起一个服务端与一个客户端,核对连接、按 peer 生成角色与专用服务端不生成角色的行为:

node tools/net-smoke.mjs

诊断

--net-stats 后每两秒输出一行总览,以及每个角色一行:

godot --path . -- --port 27015 --net-stats
[net-stats] 帧率=120  帧时=8.3~13.6 ms  物理帧率=60  RTT=14 ms  估计显示滞后≈69 ms(水位 50 + 单程 7 + 发送 8 + 本机一帧 4)
[net-stats]   远端 peer=1 每秒到达=121.0 次  最大到达间隔=17 ms  重复=50%  停顿=0%  缓冲=51/50 ms(基准 50 + 补偿 0)  最大连续间隔=17 ms  钟速=1.04×  最大单帧位移=0.06 m …
[net-stats]   本机 peer=5779398 位置更新=60.0 次/秒  最大间隔=17 ms  显示移动=6.80 m(发送方基准)

读法:

读数 含义
估计显示滞后 最有用的一项。“别人按下按键”到“我看见他动”的总时延,括号里是四项构成
RTT 实测往返时延(每秒一次 ping/pong,用发送方自己的时钟算,不需要同步两端的钟)
最大单帧位移 有没有跳。角色以 6 m/s 移动、120 帧显示时,每帧只应前进约 0.05 m;0.5 m 量级就是“卡一下再蹿过去”。自带发生时的上下文
停顿 / 缓冲 / 钟速 平滑是否跟得上:缓冲 是当前滞后/水位,括号里是水位的两个组成(见下),钟速 是显示时钟倍率(0.9~1.1)
基准 / 补偿 水位的两个组成部分:基准由链路实测得出,补偿由实际卡顿临时抬高。注意它与本机行里的“发送方基准”无关,后者指我多久产生一个新位置
最大连续间隔 定水位基准的那个值。它取「发送方时间戳间隔」与「本机到达间隔」的较大者,因此同时反映发送方产生位置、链路投递的节奏,数值越小水位越小、滞后越少
本机行 vs 远端行 本机行是“发送方基准”(我多久产生一个新位置)。与远端的 最大到达间隔 对照即可定位空档来源

水位由两部分相加得出,都夹在 50~350 ms 之间:

  • 基准:实测的最大连续间隔(取发送方时间戳间隔与本机到达间隔的较大者)× 1.3。 它只反映“已经见过”的最坏间隔,因此链路一变好就立刻降下来,这是“延迟最小为优先”的那一半。
  • 补偿:由实际发生的卡顿时长抬高,按 8 秒半衰期衰减回 0。 之所以需要它,是因为“最坏间隔”总是事后才知道,所以偶尔仍会被更长的一次空档击穿; 只有 30~200 ms 的卡顿计入(更短的是抖动噪声,更长的更像对端停止更新或系统级卡顿, 加大水位也不能解决),单次最多抬 60 ms。

实测响应(同机、好链路起步):插入 80 ms 到达延迟后补偿抬到 32 ms,插入 150 ms 后抬到上限 58 ms, 插入 250 ms 仍为 58 ms;之后分别按半衰期衰减,8 秒后约剩一半、16 秒后约剩四分之一。 在链路健康的环回上,补偿全程为 0、水位保持在下限 50 ms,也就是不会为健康链路白添延迟。 --interp-buffer 0.25 可临时固定它做对照。 --autopilot 让本机角色自动沿一条直线往返;--autopilot-stop 让它交替运动与静止各一秒, 专门用于复现“对方开始移动时卡一下”这个场景。 --physics-hz 120 临时覆盖物理帧率(默认沿用工程里的 60),用来对照帧率对同步流的影响。

100 ms 空档来自哪里

两台真机的日志里 最大到达间隔 稳定地接近 100 ms(从不出现 80 或 120), 而同一份日志里的 位置更新 是 17 ms —— 说明发送方是按帧均匀发出的, 空档产生在链路上。100 ms 恰好是无线网络的 beacon/DTIM 周期: 终端处于省电模式时,接入点会缓存下行数据、按该周期成簇投递。

隔离方法:在主机上再开一个客户端连 127.0.0.1,与另一台机器同时连上。 主机日志里会出现两个远端行:本机客户端(不经无线链路)应当 最大到达间隔 十几毫秒, 另一台则是 100 ms 左右。两者一旦相差明显,空档来源就确定了。 对应的缓解手段是链路层而不是代码:改用有线网、换 5 GHz、 关闭无线网卡的省电模式,或缩短与接入点的距离。

为什么成簇投递会变成画面上的“卡一下”

成簇意味着一簇里带着好几份不同的位置(实测有 60 次/秒的到达却只有 8% 是重复的, 而物理帧率是 60 Hz——那些位置只能是被一次性送达的)。 如果接收方按“到达时刻”给快照打时间戳,它们就全落在同一时刻上, 插值于是会在一帧内从簇内第一份跳到簇内最后一份,把 100 ms 的运动量一次走完, 然后等到下一簇。表现就是“平稳一下、突然跳一下、再停一下”。

这个跳与水位大小无关,所以调水位修不好它。修法是让发送方随每次同步 发出自己的时刻(Player.sync_time,见 scenes/player.tscn 的复制配置), 接收方用它排序。这样一簇里 100 ms 的运动量会被摊到 100 ms 的显示时间里。 最大单帧位移 这个读数就是它的验收指标。

静止之后重新开始移动

这里还有一对坑,专门制造“对方一开始移动就卡一下”:

  • 插值会把整段静止时长当成“从此处匀速走到彼处”。于是目标时刻取在“新数据之前一个水位”时, 它已经跳过了大部分距离,一帧就走了很远(真机实测 0.75 m)。 修法:判断这段间隔里对象是否真的在动(用位移量与速度估计比较), 若确认是“静止后重新移动”则补一个保持点,把位移摊在正常的一步之内。
  • 重新锚定显示时刻时只能向前。静止时长小于一个水位时, “新数据之前一个水位”落在旧显示时刻之前,直接赋值会让画面倒退, 再由钟速慢慢补回来(实测也是一次 0.75 m 的反向跳)。

这两处合起来的效果:走走停停下(--autopilot-stop)实测最大单帧位移 0.05 m, 与连续运动时一致。测试里有对应的正反两个用例,见 tests/remote_interpolator_test.gd

延迟的构成与可优化空间

估计显示滞后 的四项里,只有第一项是代码推出来的,后三项由链路与帧率决定:

典型值 能不能再小
水位(平滑缓冲) 50~150 ms 。基准按实测最大连续间隔自动定(夹在 50~350 ms),补偿只在卡顿后临时抬高 60 ms 以内;链路好则自动降到 50 ms
单程时延(RTT/2) 局域网 5~10 ms 能,但要换链路:有线、5 GHz、关网卡省电
发送方物理步长(半个) 8 ms @ 60 Hz 能。物理帧率提到 120 Hz 可减半(实测省 4 ms,代价是物理开销翻倍)
本机一帧(半个) 8 ms @ 60 fps 能。显示帧率提高可减半

估计显示滞后 只统计正在移动的远端角色。若对端一直静止,它不会给出数值: 那种角色不产生水位样本,水位停在初始值上,不代表链路。

顺序上应当先把链路改好:水位会从按 100 ms 空档定出的 ~130 ms 降到下限 50 ms,直接省下约 80 ms, 而且这是自动的——不必改任何配置。若不做这件事,水位会一直吊在链路的空档上。

水位不参与任何判定(节点的 position 始终是收到的原值),所以它只影响“看别人的动作有多晚”, 不影响自己的操作手感。

帧率用到什么程度算够

同机环回、显示帧率 120 fps 下的实测对照(链路时延固定为 7 ms,只有帧率在变):

发送方物理帧率 流里的重复快照 最大连续间隔 估计显示滞后
60 Hz(默认) 50% 17 ms 66 ms(水位 50 + 单程 4 + 发送 8 + 本机一帧 4)
120 Hz 0% 9 ms 62 ms(水位 50 + 单程 4 + 发送 4 + 本机一帧 4)
  • 重复的那 50% 不携带信息:位置只在物理帧变化,因此显示帧率高于物理帧率时, 约一半的快照与上一份位置相同。接收端会丢弃它们(只有丢弃后插值才是匀速的), 所以显示不受影响,但这些流量没换来任何位置信息。
  • 把物理帧率提到与显示帧率相同,只省下 4 ms(发送项 8 → 4), 代价是物理开销与快照量翻倍。收益小、代价明确,所以现在保持 60 Hz; 等引入推箱与机关、需要服务端权威判定时再连同 tick 一起决定。
  • 显示帧率不是限制项也不是浪费:它决定插值的取样密度,越高越平滑; 但它不改变信息的更新率,后者由发送方的物理帧率决定。 所以 60 fps 的机器把显示帧率提到 120,只会让运动更平滑,不会让别人的动作更早出现。

当前边界

玩家移动由本机权威判定。远端角色的显示由 RemoteInterpolator 平滑:丢弃位置未变的重复快照, 按发送方发出的时刻把快照排到时间轴上(而不是按到达时刻), 再用一个可变速的显示时钟在快照之间插值,并留一段缓冲盖住链路抖动。 缓冲 = 链路实测得出的基准 + 卡顿后抬高的补偿(两者相加后夹在 50~350 ms),因此链路变好时会自动变短; 节点的 position 始终是收到的原值,判定用的是它。 代价有两条:远端角色整体晩一个缓冲(实测同机环回 50 ms、真机无线链路约 130 ms), 且钟速在 0.9~1.1 之间变化,因此显示的运动速度与实际最多差一成。 位置按网络帧同步,既没有预测也没有回滚;推箱与机关引入时要改为服务端权威。 手感与延迟的结论必须来自网络损伤注入(本机限速或丢包工具),不能由局域网测试代替。 详细的机制与坑记在 memory/networking.md

目录

路径 内容
docs/ 设计文档、命名与合规调研
memory/ 开发笔记,按主题一个文件
scenes/scripts/ 场景与代码
tools/ 跨平台开发工具,以 Node 实现
build/ 导出产物,不入库

许可

本仓库不是开源项目。代码、美术、音频与文档均保留所有权利,授权范围限于 LICENSE 第 1 条列出的在线浏览、平台内复制与引用。名称与 Logo 的用法另见 TRADEMARK.md。不接受外部 PR。

About

熔霜 Moltenfrost

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages