164 lines
5.9 KiB
Markdown
164 lines
5.9 KiB
Markdown
# 08 · 本地局域网联机
|
||
|
||
## 做什么
|
||
|
||
用户要求做联机对战,但**不是部署到云服务器**,而是**本地局域网**(用户机器上跑 server,同一 LAN 内的其他设备/浏览器通过 ws://[lan-ip]:8000 连接)。
|
||
|
||
目标:
|
||
1. Server 能跑起来(tsx 模式即可,不一定要 Docker)
|
||
2. Web 客户端有"本地" / "联机" 模式切换
|
||
3. 联机模式下输入 server URL + 房间 ID + 玩家凭证
|
||
4. 两台浏览器开同一房间,互相操作能实时同步
|
||
|
||
## 遇到什么问题
|
||
|
||
### 问题 1:指挥官方向错了
|
||
|
||
最初按 plan 走 P2.1"部署到公共域名",写了 Dockerfile + docker-compose.yml + 生产配置。但**用户没有公共域名的服务器访问权限给我**,也**不想在云上部署**。需要先在本地 LAN 跑起来验证。
|
||
|
||
**解决**:撤回云部署路径,专注本地开发。Dockerfile 作为 v2 部署的素材保留,但当前目标是:
|
||
- server 用 `pnpm --filter @tts-like/server dev` 跑(tsx watch)
|
||
- 客户端配置 `http://localhost:8000`(同一机器)或 `http://192.168.x.x:8000`(LAN 内其他机器)
|
||
|
||
### 问题 2:SocketIO URL 协议搞错
|
||
|
||
最初我用 `ws://localhost:8000`,浏览器报错 `ERR_NAME_NOT_RESOLVED` for `http://ws/socket.io/...`。
|
||
原因:SocketIO 接受 HTTP URL,内部自动升级到 WebSocket。`ws://` 是浏览器用来直接连 WebSocket 的协议,不是 SocketIO 期望的。
|
||
|
||
**解决**:改成 `http://localhost:8000`。
|
||
|
||
### 问题 3:player 1 调 move 被拒
|
||
|
||
点击 收牌(player 1 操作)时报 `disallowed move: collect`。
|
||
原因:boardgame.io 默认 `currentPlayer = '0'`,SocketIO 传输层只允许 currentPlayer 调 move。War 没配置 turn,所以永远是 player 0 active。
|
||
|
||
**解决**:在 War 加 `turn: { activePlayers: ActivePlayers.ALL }`:
|
||
```ts
|
||
import { ActivePlayers } from 'boardgame.io/core';
|
||
// ...
|
||
turn: { activePlayers: ActivePlayers.ALL },
|
||
```
|
||
|
||
> ⚠️ `boardgame.io`(主入口)导出的是 type `ActivePlayers`,常量对象要从 `boardgame.io/core` 导入。
|
||
|
||
### 问题 4:ActivePlayers 类型/值混淆
|
||
|
||
`import { ActivePlayers } from 'boardgame.io'` 拿到的是 type alias(接口),不是 const 对象。编译报错:`'ActivePlayers' only refers to a type, but is being used as a value`。
|
||
|
||
**解决**:从 `boardgame.io/core` 导入:
|
||
```ts
|
||
import { ActivePlayers } from 'boardgame.io/core';
|
||
```
|
||
|
||
### 问题 5:Docker 镜像跑不起来(与本地无关)
|
||
|
||
试过 `node packages/server/dist/index.js` 启动 Docker 镜像,报错:
|
||
```
|
||
ERR_UNSUPPORTED_DIR_IMPORT: Directory import '...boardgame.io/server'
|
||
```
|
||
原因:ESM 模式下 Node 拒绝目录导入(即便 `package.json` 里有 `main` 字段)。
|
||
|
||
**解决**:暂时不用 Docker 镜像(撤回)。Task #8 标题改为"本地局域网联机"。Dockerfile 保留,v2 部署时再修。
|
||
|
||
> 📝 Docker 镜像运行的方案(留作未来):
|
||
> - 方案 A:保持 ESM,在 server/package.json 加 `"main": "./dist/index.js"` + `babel-plugin-transform-modules-commonjs` 编译产物
|
||
> - 方案 B:改 server 为 CJS(移除 `"type": "module"`、tsconfig 改 `"module": "CommonJS"`、`moduleResolution: "Node16"`)——但 `boardgame.io/server` 是 CJS,会让 server 内部 import 反向(from ESM to CJS,里 ESM 调用 CJS 通常 OK,但 `import.meta` 之类用不了)
|
||
|
||
## 怎么解决
|
||
|
||
### 步骤 1:让 server 跑起来
|
||
|
||
```bash
|
||
pnpm --filter @tts-like/server dev
|
||
# tsx watch src/index.ts
|
||
# 🎲 tts-like server listening on :8000
|
||
```
|
||
|
||
加 `/healthz` 端点 + 注册 3 个游戏 + 环境变量读 ALLOWED_ORIGINS。
|
||
|
||
### 步骤 2:web App 加 Online 模式
|
||
|
||
顶部加"本地 / 联机"切换按钮。联机模式下显示一个 `OnlineConfigBar`:
|
||
- Server URL(默认 `http://localhost:8000`)
|
||
- Room ID(默认 `room1`)
|
||
- Player(0 / 1)
|
||
- Secret(默认 `p0` / `p1`)
|
||
|
||
用 `localStorage` 持久化。
|
||
|
||
### 步骤 3:3 个游戏分别有 Online 视图
|
||
|
||
```tsx
|
||
import { SocketIO } from 'boardgame.io/multiplayer';
|
||
|
||
Client({
|
||
game,
|
||
board: ...,
|
||
multiplayer: SocketIO({ server: config.serverUrl }),
|
||
debug: false,
|
||
});
|
||
|
||
// 使用时
|
||
<OnlineClient
|
||
matchID={config.matchID}
|
||
playerID={config.playerID}
|
||
credentials={config.credentials}
|
||
/>
|
||
```
|
||
|
||
### 步骤 4:end-to-end 验证
|
||
|
||
用 Playwright 打开 2 个 tab:
|
||
- Tab 0:player 0, secret p0
|
||
- Tab 1:player 1, secret p1
|
||
- 同一 room "room1"
|
||
|
||
操作:
|
||
- Tab 0 点 翻牌 → 双方 deck 26→25,双方 pile 各 1 张
|
||
- Tab 1 点 收牌 → 7♣ 赢,p0-deck 25→27
|
||
- 两个 tab 状态完全同步
|
||
|
||
## 成果
|
||
|
||
- Commit `585930c feat(multiplayer): LAN/local online mode via SocketIO`
|
||
- 7 文件改动,+426/-92 行
|
||
- 服务器 tsx 跑通:
|
||
- `pnpm --filter @tts-like/server dev` → 监听 :8000
|
||
- `/healthz` 返回 `{"status":"ok","port":8000,"uptime":3.57}`
|
||
- 2 客户端 SocketIO 状态实时同步验证通过
|
||
|
||
## 当前测试方式(你接下来怎么用)
|
||
|
||
### 同一台机器(最简单)
|
||
```bash
|
||
# 终端 1
|
||
pnpm --filter @tts-like/server dev
|
||
|
||
# 终端 2
|
||
pnpm --filter @tts-like/web dev
|
||
|
||
# 浏览器开 2 个 tab (或 2 个隐身窗口) → 都进联机模式
|
||
# Tab 1: Player 0, Secret p0
|
||
# Tab 2: Player 1, Secret p1
|
||
# 同一 Room ID (默认 room1)
|
||
```
|
||
|
||
### 跨设备(LAN)
|
||
- 看 server 机器的局域网 IP(如 `ifconfig` 或 `ipconfig` 看 `192.168.x.x`)
|
||
- 客户端的 Server URL 填 `http://192.168.x.x:8000`
|
||
- 防火墙放行 8000 端口
|
||
|
||
### 用户验证
|
||
|
||
- Open 2 browser tabs to same `http://localhost:5173`
|
||
- Switch to War → 联机 → 默认配置(player 0 / room1 / p0)
|
||
- 在 tab 1 改成 Player 1, Secret p1
|
||
- 在 tab 0 点 翻牌 → 两边都看到翻牌结果
|
||
- 在 tab 1 点 收牌 → 两边都看到收牌结果
|
||
|
||
## 关联
|
||
|
||
- [03-monorepo-scaffold.md](./03-monorepo-scaffold.md) — 之前 query 错误方向,撤回 Docker 部署
|
||
- [lessons-learned.md §23](./lessons-learned.md) — 这一轮的踩坑(SocketIO URL protocol / ActivePlayers 导入 / disallowed move)
|
||
- `docs/decisions.md` — 决策记录更新
|