# GameServer **Repository Path**: CodeTang01/game-server ## Basic Information - **Project Name**: GameServer - **Description**: 本项目基于Python实现的简易帧同步游戏服务器,使用nicegui实现仪表盘 ,使用websockets通信, 使用msgpack序列化。 服务器逻辑非常简单就是纯收发消息,故可以不同类型的帧同步游戏共用。 - **Primary Language**: Python - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-07-11 - **Last Updated**: 2026-08-31 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Game Server > 基于 Python 的轻量级帧同步(Lockstep / Frame-Sync)游戏服务器集群:WebSocket 通信、MessagePack 序列化、Redis 房间目录、Sanic 大厅服 + 内置管理后台。 --- ## 目录 - [项目简介](#项目简介) - [核心特性](#核心特性) - [技术栈](#技术栈) - [系统架构](#系统架构) - [快速开始](#快速开始) - [配置参数](#配置参数) - [性能调优](#性能调优) - [项目结构](#项目结构) - [管理后台](#管理后台) - [客户端接入](#客户端接入) - [设计哲学](#设计哲学) - [许可证](#许可证) --- ## 项目简介 `game-server` 是一个面向小型实时多人对战游戏的帧同步服务器。它本身 **不包含任何游戏业务逻辑**——服务器只负责: 1. 接收每个玩家在每一帧的输入; 2. 把同一房间内所有玩家的输入按帧聚合; 3. 在固定 tick(默认 20 Hz)将聚合后的"帧包"广播给房间内所有玩家; 4. 维护房间生命周期、玩家重连、断线清理、追帧(catch-up)等基础设施。 客户端拿到聚合后的帧包后,自行驱动本地模拟。这种"瘦服务器 + 胖客户端"的设计使得同一套服务器可以承载任意类型的帧同步游戏(IO 类、回合制、塔防、格斗……),只要客户端遵循本仓库定义的通信协议即可。 部署形态为 **多进程集群**:1 个大厅服(Lobby,Sanic)+ N 个战斗服(Battle Server),由 `launcher.py` 一键拉起;战斗服把实例注册信息与房间摘要写入 **Redis**,大厅服读 Redis 对外提供房间列表 / 匹配查询,并通过 WebSocket RPC 对战斗服执行管理操作。 > 服务器逻辑非常简单,就是纯收发消息,故可以不同类型的帧同步游戏共用。 > 我知道用 Go 或者 C++ 亦或是 Node.js 可能性能更好,作为个人小游戏 IO 的服务器,Python 足够了。 --- ## 核心特性 ### 通信与同步 - **WebSocket 长连接**:战斗服监听 `ws://0.0.0.0:8666`(可配),使用 `websockets` 库,开启 ping/keepalive(`ping_interval=20s`, `ping_timeout=10s`),明确禁用 permessage-deflate 压缩。 - **MessagePack 二进制序列化**:使用 `msgspec` 库编码,客户端消息是 tagged union Struct,一次解码直达具体类型;原生支持整型键(玩家 ID)。 - **固定 tick 帧率**:默认 20 FPS,所有房间共享一个 `game_loop` 协程,按帧聚合输入并广播。 - **环形帧历史缓冲**:每个房间保留最近 3600 帧(约 3 分钟 @ 20Hz)的历史,用于断线重连后的追帧。 ### 房间与匹配 - **自动匹配**:客户端发送 `match` 消息,服务器优先把玩家塞入同 `room_type` 下未满且未锁定的房间,否则新建房间。 - **主动创建 / 指定加入**:`create_room` 可自定义 `room_type` 与 `max_players`;`join_room` 可加入指定 `room_id` 的房间(适合好友房)。 - **可选等人齐 + 全员准备开局**:`create_room` 传入 `wait_for_players=true` 后,房间会先暂停服务器帧;`max_players` 名玩家全部进房且每个人都发送 `ready` 后才从第 0 帧开始,并锁定补位。中途掉线只允许原玩家凭 token 重连,ready 状态在重连宽限期内保留。未传该标识时保持原有追帧、随来随走行为。 - **可选分组对抗**:`create_room` 传入 `team_battle=true` 与 `team_count`(要求 `max_players` 能被组数整除,每组容量一致)后,房间按组管理玩家。`join_room` 可携带 `team_id` 指定组(组满返回 `team_full`,组不存在返回 `invalid_team`),不传则由服务器从未满组随机分配;成功响应一律回传最终 `team_id` 与分组快照,并向房间内其他玩家即时广播 `team_assigned`。可叠加 `wait_for_players`:等待阶段离线玩家保留组名额,开局后掉线只能本人重连回原组。非分组房不携带任何分组字段,旧客户端零改动。 - **房间锁定**:房间创建 `room_lock_time`(默认 180s)后自动锁定,不再接受新玩家,避免对局中途加入破坏平衡。 - **空房间自动清理**:后台任务每 7 秒扫描一次,移除无在线且无离线玩家的房间。 ### 断线重连 - **Token 机制**:玩家首次进入房间时服务器返回一个 12 位 `token`,断线后凭 `room_id + player_id + token` 三元组重连。 - **离线宽限期**:玩家断开后进入 `offline` 状态,保留 `reconnect_timeout`(默认 30s);超时未重连则真正移除并在帧包事件中广播 `player_left`。 - **追帧(Catch-up)**:重连成功后客户端可请求从某一历史帧开始的帧包,服务器从环形缓冲中切片发送;若请求的帧已被覆盖,则发送 `kicked` 消息并以 code 1008 关闭连接(`reason=frame_too_old`)。 ### 账号与认证 - **设备号免注册登录**:客户端向大厅 `POST /api/login` 提交 `device_id` + `nickname`,大厅查/建玩家(player_id 由服务器生成)并签发一次性换服 token(TTL 60s,绑定 player_id + 昵称快照)。 - **换服 token 认证**:客户端连战斗服后发送 `auth` 消息,战斗服用 Redis Lua 脚本原子"取出即销毁"(防 token 复用);昵称以大厅落库快照为准,客户端无法篡改。 - **顶号(同账号互踢)**:每次认证成功向 `auth_events` 频道发布登录事件(时间戳 + 随机 nonce),各战斗服(含本实例)据此踢掉同账号旧连接(close 4000)——新登录优先,时间戳平局时 nonce 大者优先,全服收敛到唯一存活连接。 - **向后兼容**:不带 `auth` 的旧客户端直连不受影响(无账号信息,不参与顶号)。 ### 水平扩展与管理 - **多进程集群**:`launcher.py` 一键启动 1 个大厅服 + N 个战斗服,各自独立进程、独立日志(`logs/`),Ctrl+C 统一优雅关闭(Windows / Linux 均做了进程组隔离与内核级兜底,不留孤儿进程)。 - **Redis 注册中心**:战斗服启动时自注册 `instance:{id}`(TTL 70s,每 30s 心跳全量刷新),房间变更时批量写入 `room:{room_id}` 摘要(TTL 3000s)。进程崩溃后脏数据由 Redis TTL 自动清理。 - **统一管理后台**:大厅服内置 `/admin/` 管理页面(Jinja2 + htmx,零前端构建),可查看实例 / 房间 / 玩家详情,执行踢人、销毁房间等操作——读数据走 Redis,写操作走 WebSocket RPC。 --- ## 技术栈 | 层 | 技术 | 说明 | | --- | --- | --- | | 通信层 | `websockets >= 12.0` | 异步 WebSocket 服务器(游戏连接 + 管理 RPC 通道) | | 序列化 | `msgspec >= 0.18.0` | 高性能 MessagePack 编码,tagged union Struct 解码 | | 大厅服 | `sanic >= 24.0` | HTTP API + 管理后台宿主 | | 注册中心 | `redis >= 5.0` | 实例注册 / 房间目录(RESP2 协议,兼容 Redis < 6.0) | | 管理后台 | `jinja2 >= 3.1` + htmx | 服务端渲染片段 + htmx 轮询,零前端构建 | | JSON | `orjson >= 3.10` | 大厅 API 响应与 Redis 房间摘要解析 | | 事件循环 | `uvloop >= 0.19.0` | Linux/macOS 下替换默认 asyncio 事件循环(Windows 自动跳过) | | 语言 | Python 3.10+ | 使用了 `slots=True` dataclass、`list[int]` 等新语法 | | ~~仪表盘~~ | `nicegui >= 1.4.0` | **已停用**:旧的单进程内嵌仪表盘,保留在 `dashboard/` 与 `shared/event_bus.py` 中,当前启动路径不使用 | --- ## 系统架构 ``` ┌──────────────────────────────────────────┐ │ launcher.py │ │ 启动 / 监控 / 统一关闭所有子进程 │ └───────┬──────────────────────┬───────────┘ │ │ ▼ ▼ ┌───────────────────────┐ ┌───────────────────────┐ 浏览器 ──────► │ lobby_main.py │ │ main.py(× N) │ ◄────── 游戏客户端 /admin/ │ Sanic 大厅服 :8080 │ │ GameServer 战斗服 │ ws://...:8666 /api/login │ │ │ │ /api/rooms │ HTTP API: │ │ WS 服务 :8666(+) │ │ /api/login │ │ RPC 服务 :8787(+) │ │ /api/rooms │ │ │ │ /api/match_server │ │ game_loop / 房间管理 │ │ /api/stats │ │ │ │ admin_bp /admin/ │ │ 断线重连 / 追帧 │ └───────┬───────┬───────┘ └───────┬───────────────┘ │ │ RPC (rpclite) │ 自注册 / 心跳 │ │ kick/destroy/... │ 房间摘要写入 ▼ ▼ ▼ ┌────────────────────────────────────┐ │ Redis │ │ instance:{id} 实例注册(TTL70s) │ │ room:{room_id} 房间摘要(TTL3000s)│ └────────────────────────────────────┘ ``` ### 关键设计:战斗服不感知大厅 - **战斗服 → Redis(单向写)**:战斗服只把"我是谁"(`instance:*`)和"我有哪些房间"(`room:*`)写进 Redis,不导入大厅的任何代码。 - **大厅 → Redis(读)+ RPC(写)**:大厅服启动时按 Redis 中的 `instance:*` 自动建立到各战斗服的 RPC 连接(`shared.rpclite.RpcClientPool`),之后由后台对账任务(每 10s)自动增删;踢人、销毁房间、查玩家列表等管理操作通过 RPC 调用战斗服的 `kick` / `destroy_room` / `room_players` / `tick` 方法完成。 - 这套契约让大厅、管理后台、战斗服三者可以独立演进:战斗服换掉 RPC 实现、大厅换掉 UI 框架,互不影响。 > 注:`shared/event_bus.py` 与 `dashboard/`(NiceGUI 仪表盘)是旧的**单进程内嵌**监控方案,现已停用;相关发布者任务仍保留在代码中,但无订阅者时为空转(零开销),当前统一使用大厅内置的管理后台。 --- ## 快速开始 ### 环境要求 - Python 3.10 或更高版本 - **Redis**(本地开发默认 `127.0.0.1:6379`;Windows 可用 Memurai 或 WSL 中的 Redis) - 推荐使用 Linux / macOS 以启用 uvloop(Windows 也可运行,但使用标准 asyncio) ### 安装 ```bash # 克隆仓库 git clone https://gitee.com/CodeTang01/game-server.git cd game-server # 建议使用虚拟环境 python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate # 安装依赖 pip install -r requirements.txt ``` ### 启动(推荐:一键启动器) ```bash python launcher.py ``` 启动后: - **大厅服**:`http://localhost:8080`(HTTP API) - **管理后台**:`http://localhost:8080/admin/`(浏览器访问) - **战斗服**:`ws://localhost:8666`、`ws://localhost:8667`(客户端连接地址,可在 `launcher.py` 的 `BATTLE_SERVERS` 中增减) 各进程日志独立写入 `logs/lobby_8080.log`、`logs/battle_8666.log` 等。 ### 启动(手动:单进程调试) ```bash # 终端 1:大厅服 python lobby_main.py # 终端 2:战斗服(环境变量覆盖默认端口) WS_PORT=8666 RPC_PORT=8787 python main.py # Windows: set 或 $env: ``` ### 停止 在 launcher 控制台按 `Ctrl + C` 即可优雅关闭全部进程。launcher 对子进程做了进程组隔离,关闭时先统一发优雅退出信号——Linux 发 `SIGTERM`,Windows 发 `CTRL_BREAK_EVENT`(子进程已注册 `SIGBREAK` 处理器,效果与 SIGTERM 一致)——战斗服收到后会在退出前清理 Redis 中的房间摘要与实例注册信息,超时未退出的进程才会被强杀。此外 Linux 上通过 `prctl(PR_SET_PDEATHSIG)` 注册了内核级兜底:即使 launcher 被 `kill -9`,子进程也会收到 SIGTERM 随之退出,不会残留孤儿进程。 --- ## 配置参数 ### 战斗服(`game_server/config.py` 的 `Settings`) 所有可调参数集中在 `Settings` 数据类中(`frozen=True` 不可变,可在协程间安全共享)。默认值在 `main.py` 中被同名环境变量覆盖: | 参数 | 默认值 | 环境变量 | 说明 | | --- | --- | --- | --- | | `tick_rate` | `20` | — | 服务器 tick 频率(Hz),即每秒推进的帧数 | | `schedule_reset_threshold` | `1.0` | — | tick 落后多少秒后重置调度(避免雪崩) | | `max_players` | `4` | — | 单房间最大玩家数 | | `room_lock_time` | `180.0` | — | 房间创建多少秒后锁定(停止接受新玩家) | | `reconnect_timeout` | `30.0` | — | 离线玩家保留多少秒,超时则真正移除 | | `max_catch_up_frames` | `600` | — | 单次追帧请求最多返回多少帧 | | `max_history_frames` | `3600` | — | 帧历史环形缓冲区容量(帧),约 3 分钟 @ 20Hz | | `max_msg_size` | `1048576` | — | 单条 WebSocket 消息最大字节数(1 MiB) | | `first_msg_timeout` | `3.0` | — | 连接建立后等待首条消息的秒数,超时则关闭连接 | | `ping_interval` | `20` | — | WebSocket 保活 ping 间隔(秒) | | `ping_timeout` | `10` | — | WebSocket 保活 ping 超时(秒) | | `cleanup_interval` | `7.0` | — | 空房间清理任务的执行间隔(秒) | | `stats_report_interval` | `15.0` | — | 统计日志行的输出间隔(秒) | | `heartbeat_interval` | `30.0` | — | 实例注册心跳间隔(秒),必须小于 Redis 实例 TTL(70s) | | `ws_port` | `8666` | `WS_PORT` | 游戏 WebSocket 端口(客户端连接) | | `rpc_port` | `8787` | `RPC_PORT`(旧名 `DASHBOARD_PORT` 仍兼容) | RPC 端口(大厅服通过 rpclite 连接) | | `public_ip` | `localhost` | `PUBLIC_IP` | 外网地址,用于自注册 `ws_url` / `rpc_url` 与客户端直连 | | `redis_host` / `redis_port` / `redis_db` | `127.0.0.1` / `6379` / `0` | `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` | Redis 连接 | | `tick_interval`(计算属性) | `1/tick_rate` | — | 每个 tick 的实际间隔(秒) | ### 大厅服(`lobby_main.py`) | 环境变量 | 默认值 | 说明 | | --- | --- | --- | | `LOBBY_PORT` | `8080` | HTTP 监听端口 | | `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` | 同战斗服 | Redis 连接 | 代码内常量:`HEARTBEAT_TIMEOUT = 60`(实例心跳超时时长)、`SYNC_INTERVAL = 10.0`(RPC 连接对账周期)。 ### 启动器(`launcher.py`) - `BATTLE_SERVERS`:战斗服实例列表,每项 `{"ws_port", "rpc_port"}`,增删条目即可水平扩缩容; - `LOBBY_PORT`:大厅端口; - `PUBLIC_IP`:外网地址(也可用环境变量覆盖)。 --- ## 性能调优 ### GC 手动控制 Python 默认分代 GC 在长生命周期服务中会产生 stop-the-world 抖动。本服务器采用手动策略: ```python import gc gc.disable() # 关闭自动 GC # 主循环每 tick gc.collect(0) # 快速回收临时对象,通常 < 0.5ms # 闲时 / 无房间时 gc.collect(2) # 全量回收,此时无对局,卡一下无影响 ``` `cleanup_empty_rooms` 任务会判断到下一次 tick 的余量:余量 > 20ms 才做 `gc.collect(1)`;全部房间清空时兜底做 `gc.collect(2)`。 实测效果: - 700 连接 / 100 房间:tick 处理 10ms,无抖动 - 1000 玩家预估:单进程可稳定承载 - 2000 连接:单进程仍可扛住(处理一轮 < 50ms) - 超出单进程上限时,直接在 `launcher.py` 中增加战斗服实例即可水平扩展 ### 无锁架构 服务器运行在 **单线程事件循环** 中,所有状态变更由单个协程串行处理,无需显式锁: - `RoomManager` 直接操作 `dict`,依赖事件循环保证线程安全; - `Room` 的 tick 推进、输入缓冲、玩家名单变更均在 `game_loop` 单协程内完成; - 管理操作(踢人 / 销毁房间)经 RPC 进入同一事件循环执行。 --- ## 项目结构 ``` game-server/ ├── launcher.py # 一键启动器:拉起大厅 + N 个战斗服,统一监控与关闭 ├── main.py # 战斗服进程入口(读环境变量 → Settings → GameServer) ├── lobby_main.py # 大厅服进程入口(Sanic:HTTP API + 管理后台宿主) ├── client_sim.py # 模拟客户端 + 集成测试(登录/匹配/重连/顶号全链路自检) ├── requirements.txt # Python 依赖 ├── LICENSE # MIT ├── README.md # 本文件 ├── Game_Server_API_Documentation.md/.pdf # 客户端协议 + 接口详细文档 │ ├── game_server/ # 战斗服核心包 │ ├── __init__.py │ ├── server.py # 顶层编排器 GameServer │ ├── config.py # 不可变配置 Settings │ ├── connection.py # WebSocket 连接生命周期(含可选 auth 前置握手) │ ├── message_handler.py # 入站消息路由 │ ├── messaging.py # 出站消息辅助函数 │ ├── models.py # 领域模型:PlayerSession / Room │ ├── frame_history.py # 帧历史环形缓冲区(Room 委托的独立组件) │ ├── room_manager.py # 房间仓库与生命周期(含 Redis 摘要同步) │ ├── background.py # 后台任务:game_loop / 清理 / 统计 / 注册与心跳 │ ├── stats.py # 全局统计 │ ├── protocol.py # msgspec 序列化 + 消息类型 / 关闭码常量 │ ├── auth.py # 换服 token 认证 + 顶号登录事件发布 │ ├── rpc_service.py # 战斗服 RPC 方法(ping / tick / kick / destroy_room / room_players) │ └── logging_config.py # 日志配置 │ ├── lobby_server/ # 大厅服包(入口为根目录 lobby_main.py) │ ├── __init__.py # 应用工厂 create_app:中间件 / 蓝图 / 启动钩子 │ ├── admin.py # 管理后台蓝图(Jinja2 + htmx,挂在 /admin 前缀) │ ├── bp_public.py # 公开接口:/api/rooms /api/match_server /api/stats /api/health │ ├── bp_auth.py # 认证接口:POST /api/login 签发一次性换服 token │ ├── bp_admin_api.py # 管理 API(IP 白名单 / Token 守卫) │ ├── config.py # 配置与常量(心跳超时 / 对账周期 / 白名单 / 管理 token) │ ├── db.py # 玩家数据库(SQLite) │ ├── middleware.py # CORS / 白名单守卫 / cookie 种植 │ ├── redis_data.py # Redis 数据访问助手:实例/房间扫描、存活判断、共享 ojson │ ├── settle.py # 对局结算 RPC:排名 / 幂等存档 / 发奖转发 │ ├── settle_plugin.py # 结算插件:SettlePlugin 基类 + JSON 配置实现(业务端自定义奖励) │ └── sync.py # 实例同步:按 Redis 注册信息维护 RPC 连接(启动全量 + 周期对账) │ ├── shared/ # 各进程共享的契约 │ ├── __init__.py │ ├── event_bus.py # 异步发布-订阅事件总线(旧内嵌 dashboard 用,已停用) │ ├── redis_client.py # Redis 客户端工厂(RESP2,兼容 Redis < 6.0) │ └── rpclite.py # WebSocket RPC 框架:协议 + RpcServer + RpcClientPool │ ├── dashboard/ # NiceGUI 仪表盘(已停用,保留参考) │ ├── __init__.py │ └── page.py │ ├── templates/ # 管理后台模板(Jinja2) │ ├── index.html # 整页骨架 │ ├── _stats.html # 顶部统计条片段 │ ├── _servers.html # 战斗服卡片片段 │ ├── _rooms_rows.html # 房间表行片段 │ ├── _players.html # 房间玩家详情片段 │ └── _rewards.html # 奖励规则表片段 │ ├── static/ # 管理后台静态资源 │ ├── admin.css │ └── htmx.min.js │ └── logs/ # 各进程独立日志(launcher 生成) ``` ### 模块职责 | 模块 | 职责 | | --- | --- | | `launcher.py` | 一键拉起 / 监控 / 关闭所有进程;子进程进程组隔离 + 防孤儿兜底 | | `server.py` | 创建所有子系统、注册 RPC 方法、启动后台任务与两个 WebSocket 服务 | | `connection.py` | 单个 WebSocket 连接的完整生命周期:首条消息分流(匹配/加入/创建/重连)→ 接收循环 → 清理 | | `message_handler.py` | 把解码后的消息按类型分派到对应处理函数(input / catch_up) | | `models.py` | 纯领域对象 `Room` 和 `PlayerSession`:输入存储、tick 推进、环形历史缓冲 | | `room_manager.py` | 房间注册表;实例自注册 / 心跳;房间摘要批量同步 Redis | | `background.py` | 长期运行的协程:`game_loop`、`cleanup_empty_rooms`、`stats_reporter`、`self_register`、`heartbeat_to_lobby` | | `protocol.py` | `msgspec` 编码/解码 + 所有消息类型字符串常量 | | `rpclite.py` | 轻量级 WebSocket RPC 框架:`RpcServer`(战斗服侧)与 `RpcClientPool`(大厅侧) | | `rpc_service.py` | 战斗服对外暴露的 RPC 方法:`ping`、`tick`、`kick`、`destroy_room`、`room_players` | | `auth.py` | 换服 token 认证(Lua 原子"取出即销毁");发布登录事件支撑顶号 | | `lobby_main.py` | 大厅服:读 Redis 提供 HTTP API,维护到各战斗服的 RPC 连接 | | `bp_public.py` | 公开 HTTP 接口:房间列表 / 匹配服务器查询 / 统计 / 健康检查 | | `bp_auth.py` | 设备号免注册登录,签发一次性换服 token(TTL 60s) | | `bp_admin_api.py` | 管理 API 蓝图:踢人等管理操作的 HTTP 入口(守卫保护) | | `sync.py` | 按 Redis 实例注册信息维护到战斗服的 RPC 连接(启动全量 + 周期对账) | | `redis_data.py` | 大厅侧 Redis 读取助手:实例/房间扫描、存活判断、共享 `ojson` | | `redis_client.py` | Redis 客户端工厂:统一 RESP2 / `decode_responses` 构造 | | `lobby_server/admin.py` | 管理后台蓝图:页面与片段路由,读 Redis / 写操作走 RPC | | `client_sim.py` | 模拟客户端与集成测试:登录 / 匹配 / 断线重连 / 顶号全链路自检 | --- ## 管理后台 启动后浏览器访问 `http://localhost:8080/admin/`。 ### 功能 1. **顶部统计条**:在线实例数 / 实例总数、房间总数、在线玩家数(htmx 每 2s 轮询)。 2. **战斗服卡片**:每个实例的 `ws_url`、`rpc_url`、最后心跳时间、在线状态、名下房间数、tick 平均耗时(实际均耗 / tick 预算,经 RPC `tick` 实时查询)。 3. **房间表**:房间 ID、类型、人数(在线/总数)、状态(可加入 / 已满 / 已锁定)、创建时间、所属实例;支持按状态筛选;每行提供 **详情**(展开玩家列表)与 **销毁** 操作。 4. **玩家详情**:展开某房间后显示所有玩家(含离线)的 PID、状态、远端地址,在线玩家可一键 **踢出**。 5. **结算奖励(业务端插件)**:结算奖励不由管理后台配置,改由业务端提供 `reward_config.json` 或自定义 `SettlePlugin` 子类(见"结算插件"一章);两者都没有时结算抛 `NotImplementedError`,无内置兜底。 6. **操作反馈**:踢人 / 销毁成功后页面角落弹出 toast 提示,并就地刷新对应片段。 ### 实现要点 - **读写分离**:读数据(实例 / 房间 / 统计)全部来自 Redis;写操作(踢人 / 销毁 / 查玩家)走 WebSocket RPC 打到房间所属的战斗服实例(按房间摘要的 `ws_url` 归属解析)。 - **零前端构建**:Jinja2 渲染 HTML 片段 + htmx 轮询替换 DOM。 - **片段去重(204)**:每个片段按内容 SHA1 哈希去重,内容没变返回 `204 No Content`,htmx 跳过 swap,DOM 不动、不闪烁。 --- ## 客户端接入 客户端可以使用任何支持 WebSocket 和 MessagePack 的语言实现(JavaScript / TypeScript / C# / Unity / Go / Rust ……)。 ### 大厅 HTTP 接口(房间列表 / 匹配查询) 房间列表查询通过 **HTTP** 接口完成,无需建立 WebSocket 连接。大厅服是独立进程(`lobby_main.py`,默认端口 `8080`),房间与实例数据全部读自 Redis。 | 方法 | 路径 | 说明 | | --- | --- | --- | | `POST` | `/api/login` | 设备号免注册登录。请求体 `{"device_id": "...", "nickname": "..."}`,返回 `player_id` / `nickname` / `resources`(资源余额,含 gold)/ 一次性 `token`(TTL 60s)。 | | `GET` | `/api/rooms?room_type=xxx` | 查询房间列表。`room_type` 可选,不传则返回所有类型的房间。 | | `GET` | `/api/match_server` | 随机返回一个存活战斗服的 `ws_url`,供客户端直连。 | | `GET` | `/api/stats` | 查询实例数与房间数。 | | `GET` | `/api/health` | 健康检查,返回 `{"status": "ok"}`。 | **响应示例**(`GET /api/rooms`): ```json { "rooms": [ { "room_id": "a1b2c3d4", "room_type": "default", "max_players": 4, "online_count": 2, "offline_count": 0, "total_count": 2, "locked": false, "wait_for_players": false, "game_started": true, "waiting_for_players": false, "ready_count": 0, "team_battle": false, "created_ts": 1785000000.123, "ws_url": "ws://localhost:8666", "available": true } ], "count": 1 } ``` ### 连接流程 ``` 客户端 服务器 │ │ │── HTTP POST /api/login ──────────►│ (可选:设备号登录,获取 player_id + 一次性 token) │◄── {"player_id", "token", ...} ──│ │ │ │── HTTP GET /api/match_server ────►│ (大厅:获取一个战斗服地址) │◄── {"ws_url": "ws://..."} ────────│ │ │ │── WebSocket 握手 ─────────────────►│ (对局:直连战斗服务器) │ │ │── auth(可选,携带 login token)─►│ (换服认证;旧客户端可跳过) │◄── auth_ok / auth_fail ──────────│ │ │ │── 会话建立消息(3 秒内)─────────►│ │ ├─ match │ → 自动匹配(match_or_create) │ ├─ create_room │ → 创建新房间 │ ├─ join_room │ → 加入指定房间 │ └─ reconnect │ → 重连 │ │ │◄── match_success / create_success │ │ / join_success / reconnect_* │ │ (含 player_id, token, ...) │ │ │ │── input(每帧)──────────────────►│ │── input(每帧)──────────────────►│ │ ... │ │◄── frame(每 tick 广播)──────────│ │◄── frame ─────────────────────────│ │ ... │ │ │ │ 断线后: │ │── reconnect(带 token)──────────►│ │◄── reconnect_success ─────────────│ │── catch_up(from_frame=N)───────►│ │◄── catch_up(历史帧批量)─────────│ │ ... │ ``` ### 关键约定 - **首条消息决定连接性质**:连接建立后必须在 `first_msg_timeout`(默认 3 秒)内发送首条消息,否则连接被关闭(code 1008)。首条消息必须是协议定义的六种类型之一(`match` / `create_room` / `join_room` / `reconnect` / `input` / `catch_up`),其他类型会因 Schema 校验失败被关闭(code 1003)。 - **可选 auth 前置握手**:首条消息也可以是 `auth`(携带 `/api/login` 签发的 token)——认证成功回 `auth_ok`,随后需再发一条会话建立消息(`match` / `create_room` / `join_room` / `reconnect`);认证失败回 `auth_fail` 并以 code 4001 关闭。token 一次性,服务器取出即销毁,不可复用。不带 `auth` 直接发会话建立消息的旧流程仍然可用。 - **顶号(同账号互踢)**:同账号再次认证登录时,旧连接被以 code 4000(`account logged in elsewhere`)关闭——客户端收到 4000 不应自动重连;新登录优先,全服收敛到唯一存活连接。 - **显式匹配**:自动匹配通过发送 `{"type": "match", "room_type": "default"}` 触发——不再支持"任意首条消息隐式触发匹配"。 - **房间列表走 HTTP**:`list_rooms` 已从 WebSocket 协议中移除,客户端应通过 `GET /api/rooms` 拉取房间列表。 - **每帧一条 input**:客户端应在每个本地 tick 发送一条 `input` 消息。同一帧重复发送会被忽略(基于 `_input_submitted` 标志)。本帧无输入时可以不发消息——帧包 `inputs` 中按 pid 查不到即视为 noop。 - **token 必须持久化**:客户端收到 `match_success` / `join_success` / `create_success` 后,必须把 `room_id`、`player_id` 和 `token` 持久化保存(如 localStorage),否则断线后无法重连。 - **追帧策略**:重连成功后,客户端应根据 `reconnect_success.earliest_frame` 和本地最后收到的帧号,发起 `catch_up` 请求拉取缺失的帧。 ### 输入数据格式(原始字节透传) 服务器作为"瘦服务器" **不解析玩家输入的具体内容**,只负责按帧聚合并广播。为了最大化性能,输入数据以 **MessagePack 原始字节** 在客户端与服务器之间流转: **客户端发送 input 消息**: ```python # 客户端将游戏输入打包为 bytes,放入 data 字段 game_input = {"frame": 42, "move_x": 0.5, "move_y": -0.3, "buttons": [1,0,1,0]} data_bytes = msgspec.msgpack.encode(game_input) # 使用 msgspec 编码 msg = msgspec.msgpack.encode({"type": "input", "data": data_bytes}) websocket.send(msg) ``` **服务器处理**(零解析): 1. 解包外层信封,取出 `type` 和 `data`(`data` 已是 bytes) 2. 直接存储 bytes 到 `frame_inputs[pid]` 3. tick 时将 `{pid: bytes}` 拼入 frame 消息广播 **客户端接收 frame 消息**: ```python frame_msg = msgspec.msgpack.decode(raw) # 帧号在 id 字段;inputs / events 仅在非空时才出现,需用 get 兜底 frame_no = frame_msg["id"] inputs = frame_msg.get("inputs", {}) my_input = msgspec.msgpack.decode(inputs[my_pid]) if my_pid in inputs else None # 如果 pid 不在 inputs 中(或 inputs 整个缺省),表示该玩家本帧无输入(noop) ``` **优势**: - 服务器无需解包-重打包玩家输入的嵌套结构,tick 打包速度提升约 2 倍 - 没有输入的玩家不会出现在 `inputs` 中(不再填充 `noop` 占位),广播体积在部分玩家静止时缩小 60%+ - 全房间都无输入 / 无事件时,`inputs` / `events` 字段整个省略,空载帧包只剩 `type` + `id` 几个字节 - 历史缓冲区存储 bytes,内存更紧凑 **兼容性**:旧客户端发送 `data` 为 dict 的格式仍然兼容——服务器会将其重新编码为 bytes 存储。新客户端建议直接发送 bytes 以获得最佳性能。空 bytes 表示显式 noop:标记本帧已提交但不存储数据。 详细的协议字段、消息格式、错误码请参阅 **API 文档**(`Game_Server_API_Documentation.pdf`)。 --- ## 设计哲学 ### 1. 纯领域模型 `Room` 和 `PlayerSession` 是纯领域对象,**从不导入全局状态**。会影响全服指标的方法(如 `remove_player`)只返回调用方需要的信息(如 pid),由服务层(`MessageHandler` / `ConnectionHandler`)负责更新 `Stats`。这样领域逻辑可独立测试,不被基础设施污染。 ### 2. 进程间单向契约 战斗服与大厅服 **互不导入、互不感知**: - 战斗服只向 Redis 写实例注册信息与房间摘要(带 TTL,崩溃自愈); - 大厅服只从 Redis 读数据,管理操作通过 `rpclite` RPC 打到战斗服; - 房间 → 实例的归属靠房间摘要里的 `ws_url` 与实例注册的 `ws_url` 匹配解析。 这让任一端都可以独立替换、独立扩缩容。 ### 3. 不可变配置 `Settings` 是 `frozen=True` 的 dataclass,单个实例可在所有协程间安全共享,无需加锁。 ### 4. 无锁协程架构 服务器运行在 **单线程事件循环** 中,所有状态变更由单个协程串行处理,无需显式锁。GC 采用手动控制策略(`gc.disable()` + 每 tick `gc.collect(0)`),消除 stop-the-world 抖动。 ### 5. 瘦服务器,胖客户端 服务器**不解析玩家输入的具体内容**,只负责按帧聚合 MessagePack bytes 并广播。客户端拿到帧包后自行驱动本地物理模拟。这使得同一套服务器可以承载任意类型的帧同步游戏(IO、格斗、塔防……),只要客户端遵循通信协议即可。 --- ## 结算插件(业务端自定义奖励) 结算奖励规则与发放通道由业务端自定义,大厅不再内置奖励规则系统 (原 `reward_rule` 表、管理后台奖励配置页已移除;老库残留的 `reward_rule` 表无害,可手动 DROP)。两种用法二选一: 1. **JSON 配置**:在大厅工作目录放 `reward_config.json`(参考 `reward_config.example.json`):`{房型: {名次: {资源: 数量}}}`, `"*"` 为通用房型、`"0"` 为保底名次;每次结算实时读文件,改配置 无需重启。 2. **Python 实现**:继承 `lobby_server.settle_plugin.SettlePlugin`, 重写 `compute_rewards`(算奖)/ `grant_rewards`(发奖)两个虚方法, 并在大厅启动前执行 `settle_plugin.plugin = MySettlePlugin()` (参考 `settle_plugin.example.py`)。只接管发奖就继承 `JsonSettlePlugin`,只重写 `grant_rewards`。 两者都未提供时,结算直接抛 `NotImplementedError`——大厅不提供内置 保底;异常发生在 MatchRecord 存档前,不产生半截状态,补齐配置后 战斗服重试即可。详见 `lobby_server/settle_plugin.py` 模块文档。 ## 许可证 本项目基于 MIT License 开源,版权所有 © 2026 唐振强。 可自由复制、修改、商用,但请保留原始许可证声明。