Overview · 文档

开发概览与技术栈

StartRide 由四部分组成:桌面启动器、游戏内模组、联机服务器、官网。这一节说明它们各自用什么写、怎么连起来。

约 7 分钟 · 最后更新 2026-10-25

01技术栈与版本

StartRide 由五块组成,各自用最合适的技术,边界划得很清楚。没有引入任何重型框架 —— 整套东西的依赖加起来,一只手数得完。

部件技术版本 / 运行环境职责
桌面启动器C# · .NET · WPFnet8.0-windows账户、设置、启动游戏、装卸模组、与中继通信
游戏内模组Lua(BeamNG 扩展)Lua 5.1(游戏自带解释器)位置同步、玩法规则、游戏内 HUD
联机服务器Node.js20 及以上房间中继、状态聚合,不参与任何玩法判定
官网纯静态 HTML / CSS / JS浏览器直开文档、状态页、投稿与反馈
构建与分发dotnet CLI + 脚本Windows编译、打包安装器、生成更新清单

依赖原则

  • 启动器不引入 UI 框架。界面用原生 WPF + XAML,样式集中在几个 .xaml 资源字典里。
  • 游戏内模组只依赖游戏自带的 API,不打包任何第三方 Lua 库 —— 多一个依赖就多一个加载失败的理由。
  • 服务器零第三方依赖(除了 WebSocket 这一层),能自己写的不引包。依赖越少,线上越不容易因为一次 `npm update` 出事。
  • 官网没有构建步骤:改完文件直接传,不存在「本地是好的、线上是旧的」这种问题。
一句话原则

服务器只转发,不做游戏逻辑。所有判定都在客户端 —— 这是为了让服务器极简、几乎不会成为瓶颈,也让改玩法不需要动线上代码。

02架构分层

从游戏里的车到别人的屏幕,数据要穿过四层。每层只依赖它下面一层,上层改了不会影响下层。

游戏内 Lua ──┬─ 本地桥 127.0.0.1:4444 ─→ 启动器 ─┬─ 中继 TCP :7777 ─→ 服务器 ─→ 同房间其它客户端
             │                                    └─ 中继 WS  /relay/ ─┘
             └─ 直接读写本机车辆状态(物理、HUD、输入策略)
层职责关键约束
传输层连接、分帧、收发原始消息只保证原样转发,不解析内容
会话层房间生命周期、加入 / 离开、心跳会话结束时必须复位状态机
同步层车辆位姿、输入、事件广播只发变化量,别每帧全量发
玩法层规则、计分、回合切换与同步层解耦,只通过消息交互
依赖方向不能反

服务器只做到「传输层 + 会话层」。它不知道有几种玩法、不知道谁在哪、不知道谁赢 —— 一旦把玩法判断写进服务器,客户端版本和服务器就再也无法独立发布了。

03进程与线程模型

启动器同时至少有三条并发路径在跑,它们碰到的东西不一样,混用就会出难查的问题。

路径谁在跑可以碰什么
UI 线程WPF 消息循环所有控件;只有它能改界面
网络线程中继长连接读写网络缓冲、会话状态;不能直接改 UI 集合
游戏侧回调本地桥进来的消息分发会话状态、玩法状态;同样不能直接改 UI 集合

共享状态怎么处理

  • 玩家列表是跨线程读写的。改动它必须加锁,读它必须先取快照 —— 直接遍历共享集合,轻则界面闪一下,重则抛异常崩掉。
  • 取快照要复制成新表再交给 UI 线程,不要传引用。
  • 帧回调里不要做文件 IO 和阻塞网络请求。一次磁盘卡顿就是一次可见的掉帧。
// 后台线程:先加锁取快照,再回 UI 线程写控件
List<Player> snap;
lock (_playersLock) { snap = CopyPlayers(); }
Dispatcher.Invoke(() => {
    Players.Clear();
    foreach (var q in snap) Players.Add(q);
});
跨线程改控件必崩

WPF 的控件不是线程安全的。后台线程拿到数据后,必须通过 Dispatcher 回到 UI 线程再写 —— 偷懒的直接赋值在测试机上可能侥幸不炸,在用户机器上必炸。

04仓库与目录结构

整个项目分成四个互不干扰的目录,改哪块就在哪块里动手:

StartRide/                        启动器(C# / .NET 8 / WPF)
  App.xaml / AppSettings.cs       入口与本地设置读写
  Services/                       启动游戏、模组安装、Steam 时长同步、中继客户端
  Views/  ViewModels/             页面与视图模型
  Styles/                         XAML 资源字典(颜色、圆角、控件模板)
  Setup/                          安装 / 卸载程序(打包进安装器)
  Mods/                           随包分发的游戏内 Lua 模组
  Strings.resx / Strings.cs       全部界面文案(两者必须同步)
  bin/Release/net8.0-windows/     构建产物 —— 用户实际运行的那一份

_sr_server_ref/relay-server.js    联机服务器源码(线上跑的就是它)
startride-top/                    官网(纯静态,无构建)
_sr_shots/                        探针与截图脚本
_sr_test_score.py                 计分模块的桩测试
Lua 有三份副本

游戏内模组会同时存在于三处:源码 Mods/、构建产物 bin/…/Mods/、用户机器上的安装目录 Mods/。改任意一处,其余两处都要跟着改,并且逐字节一致 —— 不一致时游戏跑的是旧代码,你会以为改动没生效,然后花一下午查一个不存在的 bug。

05构建、产物与同步

启动器用 dotnet CLI 构建,产物落在 bin/Release/net8.0-windows/:

dotnet build StartRide.csproj -c Release
  1. 改代码在 StartRide/ 里改 C# 或 XAML。
  2. 构建跑上面的命令。XAML 和 C# 一起编译进程序集。
  3. 同步产物把构建出来的主程序集与 3 个资源程序集复制到安装目录(覆盖同名文件)。
  4. 重启程序关掉正在运行的旧进程再启动,否则你看到的还是旧界面。
你改了什么必须同步到哪
C# / XAML构建产物 bin/Release/net8.0-windows/ → 安装目录
Mods/*.lua源码 → 构建产物 → 安装目录(三处逐字节一致)
Strings.resx同步 Strings.cs,再重新构建
官网文件直接传服务器,无构建
用户跑的是安装目录那份

只 build 不同步,用户那边一点变化都不会有 —— 这是最常见的「我明明改了」的原因。判断方法:比对安装目录里程序集的时间戳和哈希。

06一次联机的完整链路

启动器                 中继服务器                游戏内模组
  │  连接 :7777            │                        │
  ├───────────────────────▶│                        │
  │  创建 / 加入房间        │                        │
  ├───────────────────────▶│                        │
  │  拉起游戏进程(注入 SteamAppId)                  │
  ├────────────────────────────────────────────────▶│
  │                        │   本地桥 :4444 握手     │
  │◀────────────────────────────────────────────────┤
  │  上行:本机车辆位姿 / 输入                        │
  │◀────────────────────────────────────────────────┤
  │  转发给同房间其它客户端  │                        │
  ├───────────────────────▶├───────────────────────▶│
  │  下行:别人的车辆状态    │                        │
  ├───────────────────────▶├───────────────────────▶│
  1. 启动器读本地配置游戏路径、账户、内存上限、模组隔离开关等。
  2. 连上中继与联机服务器建立长连接,拿到自己的会话标识。
  3. 加入房间建房或加房,服务器把这条会话挂到对应的房间作用域。
  4. 拉起游戏进程注入 Steam 身份变量,游戏以为自己是 Steam 启动的。
  5. 模组接管游戏内的 Lua 通过本地桥接上会话,开始收发消息。
  6. 广播位姿每个客户端把自己的车辆状态发出去,服务器转给同房间其他人。
  7. 玩法推进阶段切换、就位确认、计分这些消息在玩法模块之间流转。
  8. 退出清理通知服务器摘除会话,并把本机状态机复位回大厅。
最后一步最容易被漏

退出时只断开连接、忘了复位阶段,会导致局内的传送、复位、换车被永久锁死 —— 玩家看到的现象是「按键没反应」。详见 协议参考 · 会话生命周期。

07状态机与阶段

一局游戏的生命周期由几个阶段串起来。搞清楚「现在处于哪个阶段」,一半以上的联机 bug 就不用查了。

阶段含义玩家能做什么输入限制
lobby大厅 / 自由活动自由开车、聊天、选车无限制
staging就位期只能上车、挪到自己的出生点车辆被冻结
round局内正常比赛按玩法规则限制
ended结算等下一局只读,不能移动

三条纪律

  1. 输入限制只由阶段决定。任何新的限制都要写进输入策略函数,不要在别的地方零散加判断 —— 散落的判断迟早会互相打架。
  2. 进入新阶段时逐项清零上一局的临时状态。漏一项,它就会跨局存活:下一局开始时还带着上一局的数据,现象诡异且极难复现。
  3. 会话结束时把阶段收回大厅。这是硬性要求,不是可选项。
-- 输入策略:所有限制的单一入口
local function inputPolicy(player)
  if not S.sessionAlive then return false end   -- 会话没了,什么都别动
  if S.phase == "staging"  then return false end -- 就位期冻结
  if S.phase == "ended"    then return false end
  if player.wrecked        then return false end -- 车已经报废
  return true
end
为什么要单一入口

输入限制如果分散在多处,加一条新规则时你永远不知道还漏了哪一处。集中到一个函数之后,「这个人现在能不能动」变成一个可以一眼看懂的问题。

08静默失败清单

这个项目里最难查的一类问题不是报错,而是什么都不发生。下面每一行都是真实踩过的,而且它们的共同点是:不报错、不进日志。

症状真因怎么查
改了 Lua 完全没反应三处副本不一致,游戏跑的是旧代码逐字节比对源码 / 构建产物 / 安装目录
模组整块功能失效,无报错函数捕获的上值超过 60 个数一遍函数里捕获的外部局部变量
整个文件加载不上顶层 local 超过 200 个统计单文件顶层局部变量个数
玩法包的消息收不到消息派发没有兜底出口,未识别类型被静默丢弃检查派发分支末尾有没有 else / default
新加的规则滑块调了没用新键没进配置读取白名单,或落点没人读顺着配置读取的第 4 个参数找到落点
改 C# 一点变化都没有只构建没同步安装目录比对安装目录程序集时间戳与哈希
首页样式改了无效改到了共享样式表,首页样式全在页面内联块里确认改动落在首页的 <style> 内
主题色 / 图标显示成黑色引用了不存在的资源键,引用不报错、主题退回默认检查资源字典里该键是否真的存在
这类问题的通用解法

先问一句:「我怎么确认这段代码真的被执行了?」 如果答不上来,就先加一行日志把它变成可观测的,再去查别的原因。凭感觉猜,是这类 bug 唯一确定查不出来的方式。

09一个真实案例:玩几分钟就崩

这个案例值得单独写一节,因为它演示了「按现象 → 假设 → 用证据排除」的完整过程,而且结论和直觉完全相反。

现象

  • 联机进去以后一切正常,但玩到 40 ~ 60 秒左右,游戏突然消失。
  • 不是掉线,是整个游戏进程没了,没有报错窗口。
  • 单人模式完全正常,只有联机才会。

排错过程

假设验证方式结论
网络把游戏踢了看游戏日志里有没有掉线记录没有 —— 进程是被本地杀掉的
模组冲突清空模组再进依旧复现,排除
游戏本身内存泄漏观察内存曲线内存在涨,但涨得不夸张
它是被外部强制结束的查启动器给游戏进程设的限制命中

真因

启动器把游戏进程放进了一个作业对象(Job Object),并且给它设了内存上限。默认值 4096 MB。BeamNG 联机时内存占用轻松超过这个数,于是被作业对象判定超限、整个进程被直接终止 —— 这就是「活 40 ~ 60 秒」的来源。

修法

  1. 把上限改成自适应:按机器物理内存算出安全值,并钳在 6144 ~ 32768 MB 之间,别让低配机被钳成一个过小的值。
  2. 默认不启用内存限制。需要限制的场景(比如演示机、教学机)再手动开。
这个案例的教训

「进程突然消失」和「网络掉线」的现象很像,但证据在完全不同的地方:前者在本机对进程做的限制,后者在网络日志。先分清是「被谁结束的」,能省掉一半排查时间。

10从哪开始读

你想改的去看建议先读哪节
游戏内的行为、玩法、HUDLua 模组开发两个硬上限那一节,先避坑再动手
网络、消息、房间联机协议参考拓扑与分帧
消息类型、字段、派发数据包与事件参考派发与兜底
启动器怎么被拉起、注入了什么命令行参数注意事项
整体怎么搭起来的就是本页架构分层 + 状态机
建议的阅读顺序

先读本页的架构分层和状态机,再看协议,最后进Lua。反过来读容易卡在细节里,不知道那些细节为什么长成那样。

11改完必跑的自检

每个人都会忘记跑检查。所以这里不做「建议」,直接列成表 —— 改了什么就跑什么,跑不过不许提交。

你改了什么必须跑判定标准
Mods/*.lua语法检查 + 上值计数 + 顶层作用域检查(三个脚本)上值 ≤ 60,顶层局部变量 ≤ 200,语法零错误
任何联机代码多人回归脚本(本地;加 --online 打线上)输出「全部通过」,失败数为 0
跨模块的消息 / 策略 / 契约三个审计门禁脚本零缺口;并且用一份故意改坏的副本来确认它真的会失败
官网页面页面校验脚本 + 浏览器探针(多宽度)HTTP 200、结构齐备、无横向溢出、无禁用串
样式 / 资源字典资源引用检查 + 实际跑一次程序引用键全部存在,程序能启动到主界面
负向验证是门禁的命门

一个永远通过的门禁脚本,和没有门禁是一样的。必须先用一份故意改坏的副本跑一次,确认它会失败,这个门禁才算真的在工作。

别让门禁变成摆设

门禁脚本本身也会过时:新增一类消息、新增一个模块,就要同步更新它的覆盖范围。改门禁的频率应该和改业务代码差不多。