开发概览与技术栈
StartRide 由四部分组成:桌面启动器、游戏内模组、联机服务器、官网。这一节说明它们各自用什么写、怎么连起来。
01技术栈与版本
StartRide 由五块组成,各自用最合适的技术,边界划得很清楚。没有引入任何重型框架 —— 整套东西的依赖加起来,一只手数得完。
| 部件 | 技术 | 版本 / 运行环境 | 职责 |
|---|---|---|---|
| 桌面启动器 | C# · .NET · WPF | net8.0-windows | 账户、设置、启动游戏、装卸模组、与中继通信 |
| 游戏内模组 | Lua(BeamNG 扩展) | Lua 5.1(游戏自带解释器) | 位置同步、玩法规则、游戏内 HUD |
| 联机服务器 | Node.js | 20 及以上 | 房间中继、状态聚合,不参与任何玩法判定 |
| 官网 | 纯静态 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 计分模块的桩测试
游戏内模组会同时存在于三处:源码 Mods/、构建产物 bin/…/Mods/、用户机器上的安装目录 Mods/。改任意一处,其余两处都要跟着改,并且逐字节一致 —— 不一致时游戏跑的是旧代码,你会以为改动没生效,然后花一下午查一个不存在的 bug。
05构建、产物与同步
启动器用 dotnet CLI 构建,产物落在 bin/Release/net8.0-windows/:
dotnet build StartRide.csproj -c Release
- 改代码在
StartRide/里改 C# 或 XAML。 - 构建跑上面的命令。XAML 和 C# 一起编译进程序集。
- 同步产物把构建出来的主程序集与 3 个资源程序集复制到安装目录(覆盖同名文件)。
- 重启程序关掉正在运行的旧进程再启动,否则你看到的还是旧界面。
| 你改了什么 | 必须同步到哪 |
|---|---|
| C# / XAML | 构建产物 bin/Release/net8.0-windows/ → 安装目录 |
Mods/*.lua | 源码 → 构建产物 → 安装目录(三处逐字节一致) |
Strings.resx | 同步 Strings.cs,再重新构建 |
| 官网文件 | 直接传服务器,无构建 |
只 build 不同步,用户那边一点变化都不会有 —— 这是最常见的「我明明改了」的原因。判断方法:比对安装目录里程序集的时间戳和哈希。
06一次联机的完整链路
启动器 中继服务器 游戏内模组
│ 连接 :7777 │ │
├───────────────────────▶│ │
│ 创建 / 加入房间 │ │
├───────────────────────▶│ │
│ 拉起游戏进程(注入 SteamAppId) │
├────────────────────────────────────────────────▶│
│ │ 本地桥 :4444 握手 │
│◀────────────────────────────────────────────────┤
│ 上行:本机车辆位姿 / 输入 │
│◀────────────────────────────────────────────────┤
│ 转发给同房间其它客户端 │ │
├───────────────────────▶├───────────────────────▶│
│ 下行:别人的车辆状态 │ │
├───────────────────────▶├───────────────────────▶│
- 启动器读本地配置游戏路径、账户、内存上限、模组隔离开关等。
- 连上中继与联机服务器建立长连接,拿到自己的会话标识。
- 加入房间建房或加房,服务器把这条会话挂到对应的房间作用域。
- 拉起游戏进程注入 Steam 身份变量,游戏以为自己是 Steam 启动的。
- 模组接管游戏内的 Lua 通过本地桥接上会话,开始收发消息。
- 广播位姿每个客户端把自己的车辆状态发出去,服务器转给同房间其他人。
- 玩法推进阶段切换、就位确认、计分这些消息在玩法模块之间流转。
- 退出清理通知服务器摘除会话,并把本机状态机复位回大厅。
退出时只断开连接、忘了复位阶段,会导致局内的传送、复位、换车被永久锁死 —— 玩家看到的现象是「按键没反应」。详见 协议参考 · 会话生命周期。
07状态机与阶段
一局游戏的生命周期由几个阶段串起来。搞清楚「现在处于哪个阶段」,一半以上的联机 bug 就不用查了。
| 阶段 | 含义 | 玩家能做什么 | 输入限制 |
|---|---|---|---|
lobby | 大厅 / 自由活动 | 自由开车、聊天、选车 | 无限制 |
staging | 就位期 | 只能上车、挪到自己的出生点 | 车辆被冻结 |
round | 局内 | 正常比赛 | 按玩法规则限制 |
ended | 结算 | 等下一局 | 只读,不能移动 |
三条纪律
- 输入限制只由阶段决定。任何新的限制都要写进输入策略函数,不要在别的地方零散加判断 —— 散落的判断迟早会互相打架。
- 进入新阶段时逐项清零上一局的临时状态。漏一项,它就会跨局存活:下一局开始时还带着上一局的数据,现象诡异且极难复现。
- 会话结束时把阶段收回大厅。这是硬性要求,不是可选项。
-- 输入策略:所有限制的单一入口
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 秒」的来源。
修法
- 把上限改成自适应:按机器物理内存算出安全值,并钳在 6144 ~ 32768 MB 之间,别让低配机被钳成一个过小的值。
- 默认不启用内存限制。需要限制的场景(比如演示机、教学机)再手动开。
「进程突然消失」和「网络掉线」的现象很像,但证据在完全不同的地方:前者在本机对进程做的限制,后者在网络日志。先分清是「被谁结束的」,能省掉一半排查时间。
10从哪开始读
| 你想改的 | 去看 | 建议先读哪节 |
|---|---|---|
| 游戏内的行为、玩法、HUD | Lua 模组开发 | 两个硬上限那一节,先避坑再动手 |
| 网络、消息、房间 | 联机协议参考 | 拓扑与分帧 |
| 消息类型、字段、派发 | 数据包与事件参考 | 派发与兜底 |
| 启动器怎么被拉起、注入了什么 | 命令行参数 | 注意事项 |
| 整体怎么搭起来的 | 就是本页 | 架构分层 + 状态机 |
先读本页的架构分层和状态机,再看协议,最后进Lua。反过来读容易卡在细节里,不知道那些细节为什么长成那样。
11改完必跑的自检
每个人都会忘记跑检查。所以这里不做「建议」,直接列成表 —— 改了什么就跑什么,跑不过不许提交。
| 你改了什么 | 必须跑 | 判定标准 |
|---|---|---|
Mods/*.lua | 语法检查 + 上值计数 + 顶层作用域检查(三个脚本) | 上值 ≤ 60,顶层局部变量 ≤ 200,语法零错误 |
| 任何联机代码 | 多人回归脚本(本地;加 --online 打线上) | 输出「全部通过」,失败数为 0 |
| 跨模块的消息 / 策略 / 契约 | 三个审计门禁脚本 | 零缺口;并且用一份故意改坏的副本来确认它真的会失败 |
| 官网页面 | 页面校验脚本 + 浏览器探针(多宽度) | HTTP 200、结构齐备、无横向溢出、无禁用串 |
| 样式 / 资源字典 | 资源引用检查 + 实际跑一次程序 | 引用键全部存在,程序能启动到主界面 |
一个永远通过的门禁脚本,和没有门禁是一样的。必须先用一份故意改坏的副本跑一次,确认它会失败,这个门禁才算真的在工作。
门禁脚本本身也会过时:新增一类消息、新增一个模块,就要同步更新它的覆盖范围。改门禁的频率应该和改业务代码差不多。