first commit

This commit is contained in:
邹方成 2026-01-01 02:21:09 +08:00
commit 9bb5e9da9c
46 changed files with 7827 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

1
.cookie Normal file
View File

@ -0,0 +1 @@
<EFBFBD>2甍N*F)<29>J挧4UJK

View File

@ -0,0 +1,40 @@
# Nakama 服务端集成规划 (修订版4人自动匹配)
根据您的补充需求4人自动匹配、无需手动管理房间我调整了规划如下。我们将继续基于“动物扫雷大作战”项目进行开发。
## 1. 核心流程:自动匹配 (Auto-Matchmaking)
我们将完全屏蔽“房间”概念,采用类似《王者荣耀》或《英雄联盟》的匹配机制:
1. **点击“开始游戏”**: 客户端调用 Nakama Matchmaker API参数设定为 `min_count=4, max_count=4`
2. **服务端排队**: Nakama 自动将请求的玩家放入匹配池。
3. **匹配成功**: 当凑齐 4 人后,服务端自动创建一个权威比赛 (Authoritative Match)。
4. **自动入场**: 客户端收到匹配成功通知 (Match Found),携带 Token 自动加入比赛,进入游戏画面。
## 2. 系统架构与实施
### 第一阶段:基础设施 (不变)
* **动作**: 配置 Docker (Nakama + CockroachDB)。
* **动作**: 前端安装 `nakama-js`
### 第二阶段:服务端逻辑 (TypeScript)
* **Match Handler (比赛控制器)**:
* **硬性限制**: 仅当凑齐 4 人时游戏逻辑才正式开始(或等待超时添加 AI目前先按纯真人规划
* **回合制逻辑**: 维护 4 人行动顺序P1 -> P2 -> P3 -> P4 -> P1...)。
* **状态同步**: 广播 100 个格子的状态、4 名玩家的血量/道具/Buff。
### 第三阶段:前端改造 (针对 4 人匹配)
* **UI 调整**:
* 主界面增加“开始匹配 (4人)”大按钮。
* 增加“匹配中...”的等待状态提示。
* 游戏内固定显示 4 个玩家的头像槽位(如 `App.tsx` 中已有的布局,需确保能动态映射 P1-P4
* **逻辑对接**:
* **Socket 监听**: 监听 `onmatchmakermatched` 事件 -> 自动执行 `joinMatch`
* **游戏开始**: 收到服务端 `OP_GAME_START` 信号后,解锁棋盘交互。
## 3. 下一步执行计划
1. **环境搭建**: 启动 Nakama 服务端。
2. **前端集成**: 实现“点击匹配 -> 等待 -> 自动进入游戏”的完整链路。
3. **逻辑迁移**: 将现有的 Mock 数据替换为服务端数据。
请确认是否开始执行环境搭建?

140
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,140 @@
# 扫雷游戏部署教程
## 项目结构
```
game/
├── app/ # React 前端(用户玩游戏)
├── server/ # Nakama 游戏服务器逻辑
├── docker-compose.yml
└── 游戏逻辑文档.txt
```
---
## 1. 部署 Nakama 游戏服务器
### 1.1 服务器准备
```bash
# 安装 Docker 和 Docker Compose
apt install docker.io docker-compose
```
### 1.2 启动服务
```bash
cd /path/to/game
# 构建并启动
docker-compose up -d --build
# [推荐] 方式2分别部署 (业务后端 + 游戏服)
# 1. 启动业务后端 (在 bindbox_game 目录)
# 2. 启动游戏服 (使用 docker-compose.cloud.yml, 需确保网络互通)
# docker-compose -f docker-compose.cloud.yml up -d
# [推荐] 方式3全量合并部署 (业务后端 + 游戏服 + 数据库)
# docker-compose -f docker-compose.all.yml up -d
# 云端部署 (由于是云端,通常使用拉取的镜像)
# docker-compose -f docker-compose.cloud.yml up -d
# 查看日志
docker-compose logs -f nakama
```
### 1.3 验证服务
- Nakama API: http://your-server:7350
- Nakama Console: http://your-server:7351
- CockroachDB UI: http://your-server:8081
---
## 2. 部署前端游戏
### 2.1 构建前端
```bash
cd /path/to/game/app
# 安装依赖
npm install
# 构建生产版本
npm run build
```
### 2.2 配置 Nginx
```nginx
server {
listen 80;
server_name game.1024tool.vip;
root /var/www/game;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
### 2.3 部署文件
```bash
# 将构建产物复制到服务器
scp -r dist/* root@server:/var/www/game/
# 重载 Nginx
nginx -s reload
```
---
## 3. 配置小程序对接
### 3.1 后端配置bindbox_game
`system_configs` 表添加:
```sql
INSERT INTO system_configs (key, value) VALUES
('nakama_server', 'wss://game.1024tool.vip:7350'),
('nakama_key', 'defaultkey');
```
### 3.2 前端跳转
小程序扫雷入口已配置跳转到:
```
https://game.1024tool.vip?ticket={ticket_token}
```
---
## 4. 完整流程
```
小程序点击「进入游戏」
调用 /api/app/games/enter
获取 ticket_token
跳转 webview: game.1024tool.vip?ticket=xxx
游戏前端连接 Nakama 服务器
开始对战
```
---
## 5. 常用命令
```bash
# 查看服务状态
docker-compose ps
# 重启服务
docker-compose restart nakama
# 查看实时日志
docker-compose logs -f
# 停止服务
docker-compose down
```

5
app/.env.production Normal file
View File

@ -0,0 +1,5 @@
# Production environment configuration
VITE_NAKAMA_HOST=game.1024tool.vip
VITE_NAKAMA_PORT=443
VITE_NAKAMA_SERVER_KEY=defaultkey
VITE_NAKAMA_USE_SSL=true

24
app/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
app/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

BIN
app/dist.zip Normal file

Binary file not shown.

23
app/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
app/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4109
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
app/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@heroiclabs/nakama-js": "^2.8.0",
"@types/uuid": "^11.0.0",
"@types/ws": "^8.18.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"uuid": "^13.0.0",
"ws": "^8.18.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.0",
"vite": "^5.4.11"
}
}

6
app/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
app/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,53 @@
import { Client } from '@heroiclabs/nakama-js';
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
// Polyfill WebSocket for Node.js
global.WebSocket = WebSocket as any;
const startBot = async (idx: number) => {
const client = new Client('defaultkey', 'localhost', '7350', false);
const deviceId = `bot-player-${idx}-${uuidv4()}`;
try {
const session = await client.authenticateDevice(deviceId, true, `Bot-${idx}`);
console.log(`[Bot ${idx}] Authenticated: ${session.user_id}`);
const socket = client.createSocket(false, false);
await socket.connect(session, true);
console.log(`[Bot ${idx}] Socket Connected`);
// Listen for match
socket.onmatchmakermatched = async (matched) => {
// The matched object might have different property names depending on SDK version or response
console.log(`[Bot ${idx}] Matchmaker Matched:`, matched);
const matchId = matched.match_id || (matched as any).matchId;
const token = matched.token;
if (matchId) {
console.log(`[Bot ${idx}] Joining Match ID: ${matchId}`);
const match = await socket.joinMatch(matchId, token);
console.log(`[Bot ${idx}] Joined Match: ${match.match_id}`);
} else {
console.error(`[Bot ${idx}] Match ID missing in matched data`);
}
};
// Start Matchmaking
console.log(`[Bot ${idx}] Joining Matchmaker...`);
await socket.addMatchmaker('*', 4, 4);
} catch (err) {
console.error(`[Bot ${idx}] Error:`, err);
}
};
// Start 3 Bots
console.log('Starting 3 Bots to fill the match...');
for (let i = 1; i <= 3; i++) {
startBot(i);
}
// Keep script running
setInterval(() => {}, 10000);

View File

@ -0,0 +1,52 @@
import { Client } from '@heroiclabs/nakama-js';
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
// Polyfill WebSocket for Node.js
global.WebSocket = WebSocket as any;
const startBot = async (idx: number) => {
const client = new Client('defaultkey', 'localhost', '7350', false);
const deviceId = `bot-player-${idx}-${uuidv4()}`;
try {
const session = await client.authenticateDevice(deviceId, true, `Bot-${idx}`);
console.log(`[Bot ${idx}] Authenticated: ${session.user_id}`);
const socket = client.createSocket(false, false);
await socket.connect(session, true);
console.log(`[Bot ${idx}] Socket Connected`);
// Use matchmaker to find existing matches
socket.onmatchmakermatched = async (matched) => {
console.log(`[Bot ${idx}] Matchmaker Matched:`, matched);
const matchId = matched.match_id || (matched as any).matchId;
const token = matched.token;
if (matchId) {
console.log(`[Bot ${idx}] Joining Match ID: ${matchId}`);
const match = await socket.joinMatch(matchId, token);
console.log(`[Bot ${idx}] Joined Match: ${match.match_id}`);
} else {
console.error(`[Bot ${idx}] Match ID missing in matched data`);
}
};
// Start Matchmaking - this will find existing matches
console.log(`[Bot ${idx}] Joining Matchmaker...`);
await socket.addMatchmaker('*', 4, 4);
} catch (err) {
console.error(`[Bot ${idx}] Error:`, err);
}
};
// Start 3 Bots
console.log('Starting 3 Bots to fill the match...');
for (let i = 1; i <= 3; i++) {
startBot(i);
}
// Keep script running
setInterval(() => {}, 10000);

View File

@ -0,0 +1,56 @@
import { Client } from '@heroiclabs/nakama-js';
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
// Polyfill WebSocket for Node.js
global.WebSocket = WebSocket as any;
const startBot = async (idx: number) => {
const client = new Client('defaultkey', 'localhost', '7350', false);
const deviceId = `bot-player-${idx}-${uuidv4()}`;
try {
const session = await client.authenticateDevice(deviceId, true, `Bot-${idx}`);
console.log(`[Bot ${idx}] Authenticated: ${session.user_id}`);
const socket = client.createSocket(false, false);
await socket.connect(session, true);
console.log(`[Bot ${idx}] Socket Connected`);
// Use matchmaker to find matches with our custom handler
socket.onmatchmakermatched = async (matched) => {
console.log(`[Bot ${idx}] Matchmaker Matched:`, matched);
const matchId = matched.match_id || (matched as any).matchId;
const token = matched.token;
if (matchId) {
console.log(`[Bot ${idx}] Joining Match ID: ${matchId}`);
const match = await socket.joinMatch(matchId, token);
console.log(`[Bot ${idx}] Joined Match: ${match.match_id}`);
// Request game state after joining
console.log(`[Bot ${idx}] Requesting game state...`);
socket.sendMatchState(match.match_id, { action: 'getState' });
} else {
console.error(`[Bot ${idx}] Match ID missing in matched data`);
}
};
// Start Matchmaking with query for our custom match
console.log(`[Bot ${idx}] Joining Matchmaker for animal_minesweeper...`);
await socket.addMatchmaker('label:animal_minesweeper', 4, 4);
} catch (err) {
console.error(`[Bot ${idx}] Error:`, err);
}
};
// Start 3 Bots
console.log('Starting 3 Bots to fill the match...');
for (let i = 1; i <= 3; i++) {
startBot(i);
}
// Keep script running
setInterval(() => {}, 10000);

View File

@ -0,0 +1,56 @@
// Test script to simulate 4 players joining matchmaker
const { Client } = require('@heroiclabs/nakama-js');
const { v4: uuidv4 } = require('uuid');
async function simulatePlayer(playerId) {
const client = new Client('defaultkey', 'localhost', '7350', false);
const deviceId = uuidv4();
console.log(`Player ${playerId}: Authenticating with deviceId ${deviceId}`);
const session = await client.authenticateDevice(deviceId, true, deviceId);
console.log(`Player ${playerId}: Authenticated as ${session.user_id}`);
const socket = client.createSocket(false, false);
await socket.connect(session, true);
console.log(`Player ${playerId}: Socket connected`);
socket.onmatchmakermatched = async (matched) => {
console.log(`Player ${playerId}: MATCHED! Match ID: ${matched.match_id}`);
try {
const match = await socket.joinMatch(matched.match_id, matched.token);
console.log(`Player ${playerId}: Joined match ${match.match_id}`);
} catch (e) {
console.error(`Player ${playerId}: Failed to join match`, e);
}
};
socket.onmatchdata = (data) => {
console.log(`Player ${playerId}: Received match data`, data.op_code);
};
const ticket = await socket.addMatchmaker('*', 4, 4);
console.log(`Player ${playerId}: Added to matchmaker with ticket ${ticket.ticket}`);
return socket;
}
async function main() {
console.log('Starting 4 player simulation...');
const promises = [];
for (let i = 1; i <= 4; i++) {
promises.push(simulatePlayer(i));
}
try {
await Promise.all(promises);
console.log('All 4 players added to matchmaker. Waiting for match...');
// Keep the script running to receive match events
await new Promise(resolve => setTimeout(resolve, 30000));
} catch (error) {
console.error('Error:', error);
}
}
main();

View File

@ -0,0 +1,56 @@
// Test script to simulate 4 players joining matchmaker
import { Client } from '@heroiclabs/nakama-js';
import { v4 as uuidv4 } from 'uuid';
async function simulatePlayer(playerId) {
const client = new Client('defaultkey', 'localhost', '7350', false);
const deviceId = uuidv4();
console.log(`Player ${playerId}: Authenticating with deviceId ${deviceId}`);
const session = await client.authenticateDevice(deviceId, true, deviceId);
console.log(`Player ${playerId}: Authenticated as ${session.user_id}`);
const socket = client.createSocket(false, false);
await socket.connect(session, true);
console.log(`Player ${playerId}: Socket connected`);
socket.onmatchmakermatched = async (matched) => {
console.log(`Player ${playerId}: MATCHED! Match ID: ${matched.match_id}`);
try {
const match = await socket.joinMatch(matched.match_id, matched.token);
console.log(`Player ${playerId}: Joined match ${match.match_id}`);
} catch (e) {
console.error(`Player ${playerId}: Failed to join match`, e);
}
};
socket.onmatchdata = (data) => {
console.log(`Player ${playerId}: Received match data`, data.op_code);
};
const ticket = await socket.addMatchmaker('*', 4, 4);
console.log(`Player ${playerId}: Added to matchmaker with ticket ${ticket.ticket}`);
return socket;
}
async function main() {
console.log('Starting 4 player simulation...');
const promises = [];
for (let i = 1; i <= 4; i++) {
promises.push(simulatePlayer(i));
}
try {
await Promise.all(promises);
console.log('All 4 players added to matchmaker. Waiting for match...');
// Keep the script running to receive match events
await new Promise(resolve => setTimeout(resolve, 30000));
} catch (error) {
console.error('Error:', error);
}
}
main();

42
app/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

704
app/src/App.tsx Normal file
View File

@ -0,0 +1,704 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { nakamaManager } from './lib/nakama';
import './Explosion.css';
// Types for Game State (Synced with Server)
interface Player {
userId: string;
sessionId: string;
username: string;
avatar: string;
hp: number;
maxHp: number;
character: string;
// Status Flags (synced from server)
shield: boolean;
poisoned: boolean;
curse: boolean;
revive: boolean;
timeBombTurns: number;
skipTurn: boolean;
}
interface GridCell {
type: 'empty' | 'bomb' | 'item';
itemId?: string;
revealed: boolean;
neighborBombs?: number; // Added for classic Minesweeper logic
}
interface GameState {
players: { [userId: string]: Player };
grid: GridCell[];
gridSize: number;
turnOrder: string[];
currentTurnIndex: number;
round: number;
winnerId: string | null;
gameStarted: boolean;
}
interface LogEntry {
id: string;
type: 'system' | 'action' | 'effect';
content: React.ReactNode;
}
interface FloatingLabel {
id: string;
x: number;
y: number;
text: string;
type: 'heal' | 'damage' | 'item';
cellIndex?: number;
targetUserId?: string;
}
const App: React.FC = () => {
// Game State
const [gameState, setGameState] = useState<GameState | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [isMatching, setIsMatching] = useState(false);
const [matchId, setMatchId] = useState<string | null>(null);
const [myUserId, setMyUserId] = useState<string | null>(null);
const [floatingLabels, setFloatingLabels] = useState<FloatingLabel[]>([]);
const [showGuide, setShowGuide] = useState(false);
const [debugMode, setDebugMode] = useState(false);
const [matchingTimer, setMatchingTimer] = useState(0);
const [matchPlayerCount, setMatchPlayerCount] = useState(2); // 动态匹配人数
const [turnTimer, setTurnTimer] = useState(15); // 回合倒计时
const [screenShaking, setScreenShaking] = useState(false); // 屏幕抖动
const [damagedPlayers, setDamagedPlayers] = useState<string[]>([]); // 受伤玩家
const [healedPlayers, setHealedPlayers] = useState<string[]>([]); // 治疗玩家
const [isRefreshing, setIsRefreshing] = useState(false); // 正在刷新token
const logsEndRef = useRef<HTMLDivElement>(null);
const prevGameStateRef = useRef<GameState | null>(null);
const myUserIdRef = useRef<string | null>(null);
// Auto-scroll logs
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// Helpers
const addLog = useCallback((type: 'system' | 'action' | 'effect', content: React.ReactNode) => {
setLogs(prev => [...prev.slice(-49), { id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type, content }]);
}, []);
const spawnLabel = useCallback((x: number, y: number, text: string, type: 'heal' | 'damage' | 'item', cellIndex?: number, targetUserId?: string) => {
const id = `${Date.now()}-${Math.random()}`;
setFloatingLabels(prev => [...prev, { id, x, y, text, type, cellIndex, targetUserId }]);
setTimeout(() => {
setFloatingLabels(prev => prev.filter(l => l.id !== id));
}, 1000);
}, []);
const describeCellContent = (cell: GridCell): string => {
if (cell.type === 'bomb') return '💣 炸弹!';
if (cell.type === 'item') {
const itemNames: { [key: string]: string } = {
'medkit': '💊 医疗包',
'bomb_timer': '⏰ 定时炸弹',
'poison': '☠️ 毒药',
'shield': '🛡️ 护盾',
'skip': '⏭️ 跳过',
'magnifier': '🔍 放大镜',
'knife': '🔪 匕首',
'revive': '💖 复活',
'lightning': '⚡ 闪电',
'chest': '📦 宝箱',
'curse': '👻 诅咒',
};
return itemNames[cell.itemId || ''] || '🎁 道具';
}
return '✅ 安全';
};
const getItemIcon = (itemId?: string): string => {
const icons: { [key: string]: string } = {
'medkit': '💊', 'bomb_timer': '⏰', 'poison': '☠️', 'shield': '🛡️',
'skip': '⏭️', 'magnifier': '🔍', 'knife': '🔪', 'revive': '💖',
'lightning': '⚡', 'chest': '📦', 'curse': '👻'
};
return icons[itemId || ''] || '🎁';
};
const renderHeart = (hp: number, maxHp: number) => {
return (
<div className="flex gap-0.5 flex-wrap">
{Array.from({ length: maxHp }).map((_, i) => (
<span key={i} className={`text-xs lg:text-sm ${i < hp ? 'text-rose-500' : 'text-slate-600'}`}>
{i < hp ? '❤️' : '🤍'}
</span>
))}
</div>
);
};
const renderStatusIcons = (player: Player) => {
const icons: React.ReactNode[] = [];
if (player.shield) icons.push(<span key="shield" title="护盾" className="text-blue-400">🛡</span>);
if (player.poisoned) icons.push(<span key="poison" title="中毒" className="text-purple-400"></span>);
if (player.curse) icons.push(<span key="curse" title="诅咒" className="text-gray-400">👻</span>);
if (player.revive) icons.push(<span key="revive" title="复活" className="text-pink-400">💖</span>);
if (player.timeBombTurns > 0) icons.push(<span key="bomb" title={`炸弹 ${player.timeBombTurns}回合`} className="text-red-500 animate-pulse">{player.timeBombTurns}</span>);
if (player.skipTurn) icons.push(<span key="skip" title="跳过回合" className="text-yellow-400"></span>);
return icons.length > 0 ? <div className="flex gap-1 mt-1 text-sm">{icons}</div> : null;
};
const handleCellClick = (index: number) => {
if (!gameState || !matchId || !gameState.gameStarted) return;
const currentPlayerId = gameState.turnOrder[gameState.currentTurnIndex];
if (currentPlayerId !== myUserIdRef.current) return;
if (gameState.grid[index].revealed) return;
nakamaManager.getSocket()?.sendMatchState(matchId, 3, JSON.stringify({ index }));
};
const startMatchmaking = async () => {
if (matchId) {
console.warn('Already in a match');
return;
}
try {
setIsMatching(true);
addLog('system', `开始匹配 (${matchPlayerCount}人)...`);
const socket = nakamaManager.getSocket();
if (socket) {
await socket.addMatchmaker('*', matchPlayerCount, matchPlayerCount);
addLog('system', '正在寻找匹配...');
}
} catch (error) {
console.error('Matchmaking error:', error);
setIsMatching(false);
const errorMsg = error instanceof Error ? error.message : '网络连接异常';
addLog('system', `❌ 匹配失败: ${errorMsg}`);
addLog('system', '💡 请检查网络连接后重试,或稍后再试');
}
};
// 再来一局: 获取新token并重新匹配
const refreshAndPlayAgain = async () => {
setIsRefreshing(true);
addLog('system', '🔄 正在获取新的游戏凭证...');
try {
const backendUrl = 'https://game.1024tool.vip';
// 从小程序环境获取用户token
let userToken = '';
if ((window as any).uni && (window as any).uni.getStorageSync) {
userToken = (window as any).uni.getStorageSync('token') || '';
}
if (!userToken) {
addLog('system', '❌ 无法获取用户凭证,请返回小程序重新进入');
setIsRefreshing(false);
// 回退到小程序
if ((window as any).uni) {
(window as any).uni.navigateBack();
} else if ((window as any).wx?.miniProgram) {
(window as any).wx.miniProgram.navigateBack();
}
return;
}
const resp = await fetch(`${backendUrl}/api/app/games/enter`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`
},
body: JSON.stringify({ game_code: 'minesweeper' })
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
addLog('system', `❌ 获取游戏凭证失败: ${errData.message || '游戏次数不足'}`);
setIsRefreshing(false);
return;
}
const data = await resp.json();
if (data.game_token) {
setCurrentGameToken(data.game_token);
addLog('system', '✅ 获取新凭证成功!');
// 重置游戏状态
setGameState(null);
setMatchId(null);
setIsMatching(false);
// 重新初始化 Nakama 并开始匹配
nakamaManager.initClient(data.nakama_server || 'wss://nakama.1024tool.vip', data.nakama_key || 'defaultkey');
await nakamaManager.authenticateWithGameToken(data.game_token);
setIsConnected(true);
addLog('system', '✅ 重新连接成功');
// 自动开始匹配
setTimeout(() => startMatchmaking(), 500);
} else {
addLog('system', '❌ 获取游戏凭证失败');
}
} catch (err) {
console.error('Refresh token error:', err);
addLog('system', '❌ 网络错误,请稍后重试');
}
setIsRefreshing(false);
};
const GuideModal = () => (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-md p-4 animate-in fade-in duration-300">
<div className="bg-slate-800 border border-white/10 rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col relative shadow-2xl">
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-slate-800/50 backdrop-blur-xl">
<h2 className="text-2xl font-black bg-gradient-to-r from-emerald-400 to-blue-400 bg-clip-text text-transparent"> (How to Play)</h2>
<button onClick={() => setShowGuide(false)} className="w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center transition-all"></button>
</div>
<div className="p-6 overflow-y-auto space-y-8 scrollbar-thin scrollbar-thumb-slate-700">
<section>
<h3 className="text-emerald-400 font-black mb-4 flex items-center gap-2"><span>🛡</span> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ i: '💊', n: '医疗包', d: '回复1点HP并清除中毒状态' },
{ i: '🛡️', n: '护盾', d: '抵挡下次受到的伤害' },
{ i: '🔍', n: '放大镜', d: '随机窥视1个未翻开格子的内容' },
{ i: '🔪', n: '匕首', d: '对随机一个对手造成1点伤害' },
{ i: '⚡', n: '闪电', d: '对所有玩家包括自己造成1点伤害' },
{ i: '⏭️', n: '好人卡', d: '获得护盾,但跳过本回合' },
{ i: '💖', n: '复活', d: 'HP归零时立即以1HP状态复活' },
{ i: '⏰', n: '定时炸弹', d: '3回合后爆炸对携带者造成伤害' },
{ i: '☠️', n: '毒药', d: '使随机对手中毒每步扣除HP' },
{ i: '👻', n: '诅咒', d: '获得诅咒状态,特定操作会触发扣血' },
{ i: '📦', n: '宝箱', d: '神秘大奖,游戏结束后结算' },
].map(item => (
<div key={item.n} className="bg-white/5 p-3 rounded-xl border border-white/5 flex gap-3 items-start">
<span className="text-2xl">{item.i}</span>
<div>
<div className="font-bold text-slate-200">{item.n}</div>
<div className="text-xs text-slate-400">{item.d}</div>
</div>
</div>
))}
</div>
</section>
<section>
<h3 className="text-blue-400 font-black mb-4 flex items-center gap-2"><span>🐾</span> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ i: '🐶', n: '小狗', d: '忠诚:每移动一定步数必触发放大镜效果' },
{ i: '🐘', n: '大象', d: '执拗无法回复生命但基础HP更高' },
{ i: '🐯', n: '虎哥', d: '猛攻:匕首进化为全屏范围伤害' },
{ i: '🐵', n: '猴子', d: '敏锐:每次点击都有概率发现香蕉(回血)' },
{ i: '🦥', n: '树懒', d: '迟缓:翻到炸弹时伤害减半(扣1点)' },
{ i: '河马', n: '河马', d: '大胃:无法直接捡起道具卡' },
].map(char => (
<div key={char.n} className="bg-white/5 p-3 rounded-xl border border-white/5 flex gap-3 items-center">
<span className="text-2xl">{char.i}</span>
<div><span className="font-bold text-slate-200">{char.n}</span><span className="text-xs text-slate-400">{char.d}</span></div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
// Initialize Nakama with GameToken
useEffect(() => {
const initNakama = async () => {
try {
// Get game token from URL parameters (passed from mini-program)
const urlParams = new URLSearchParams(window.location.search);
const gameToken = urlParams.get('game_token');
const nakamaServer = urlParams.get('nakama_server') || import.meta.env.VITE_NAKAMA_SERVER || 'ws://localhost:7350';
const nakamaKey = urlParams.get('nakama_key') || import.meta.env.VITE_NAKAMA_SERVER_KEY || 'defaultkey';
if (!gameToken) {
addLog('system', '❌ 缺少游戏令牌');
addLog('system', '💡 请从小程序正确入口进入游戏,或检查URL参数是否包含game_token');
return;
}
// Fetch game config to get match_player_count (使用同域避免 CORS)
try {
const backendUrl = 'https://game.1024tool.vip'; // 通过 Nginx 反向代理到后端
const configResp = await fetch(`${backendUrl}/api/internal/game/minesweeper/config`, {
headers: { 'X-Internal-Key': 'bindbox-internal-secret-2024' }
});
if (configResp.ok) {
const config = await configResp.json();
if (config.match_player_count && config.match_player_count >= 2) {
setMatchPlayerCount(config.match_player_count);
console.log('Loaded match_player_count from config:', config.match_player_count);
}
}
} catch (err) {
console.warn('Failed to fetch game config:', err);
addLog('system', '⚠️ 无法加载游戏配置,使用默认设置(2人匹配)');
}
// Initialize client with server info
nakamaManager.initClient(nakamaServer, nakamaKey);
// Authenticate with game token
const session = await nakamaManager.authenticateWithGameToken(gameToken);
const userId = session.user_id || null;
setMyUserId(userId);
myUserIdRef.current = userId;
setIsConnected(true);
addLog('system', '✅ 已连接到服务器');
const socket = nakamaManager.getSocket();
if (socket) {
socket.onmatchmakermatched = async (matched) => {
console.log('🎯 Matchmaker matched!', { matchId: matched.match_id, users: matched.users?.length });
addLog('system', `✅ 匹配成功!找到 ${matched.users?.length || matchPlayerCount} 名玩家`);
setIsMatching(false);
try {
const match = await nakamaManager.joinMatch(matched.match_id, matched.token);
setMatchId(match.match_id);
addLog('system', `加入房间: ${match.match_id}`);
setTimeout(() => {
socket.sendMatchState(match.match_id, 100, JSON.stringify({ action: 'getState' }));
}, 100);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '未知错误';
addLog('system', `❌ 加入房间失败: ${errorMsg}`);
addLog('system', '💡 可能是网络问题或房间已满,请重新匹配');
}
};
socket.onmatchdata = (matchData) => {
const opCode = matchData.op_code;
try {
const data = JSON.parse(new TextDecoder().decode(matchData.data)) as GameState;
const prevState = prevGameStateRef.current;
if (opCode === 1) { // GAME_START
setGameState(data);
prevGameStateRef.current = data;
setFloatingLabels([]); // Reset floating labels on new game
addLog('system', '🎮 游戏开始!');
const isMe = data.turnOrder[0] === myUserIdRef.current;
addLog('system', isMe ? '🎯 你是先手,请点击格子!' : '⏳ 对手先手,请等待...');
} else if (opCode === 2) { // UPDATE_STATE
if (prevState) {
// Cell Reveals
for (let i = 0; i < data.grid.length; i++) {
if (data.grid[i].revealed && !prevState.grid[i].revealed) {
const prevPlayerId = prevState.turnOrder[prevState.currentTurnIndex];
const playerName = prevPlayerId === myUserIdRef.current ? '你' : (prevState.players[prevPlayerId]?.username?.substring(0, 8) || '对手');
const contentDesc = describeCellContent(data.grid[i]);
const width = data.gridSize || 10;
addLog('action', `${playerName} 点击了格子 [${Math.floor(i / width) + 1}, ${i % width + 1}]`);
addLog('effect', `发现: ${contentDesc}`);
// Explicit HP change labels on cells
let cellLabel = contentDesc.split(' ')[0];
let labelType: 'heal' | 'damage' | 'item' = data.grid[i].type === 'bomb' ? 'damage' : 'item';
if (data.grid[i].type === 'bomb') {
cellLabel = '-2'; // standard bomb dmg
} else if (data.grid[i].itemId === 'medkit') {
cellLabel = '+1';
labelType = 'heal';
}
spawnLabel(50, 50, cellLabel, labelType, i);
}
}
// HP Changes - 追踪伤害和治疗特效
Object.keys(data.players).forEach(uid => {
const prevHp = prevState.players[uid]?.hp || 0;
const newHp = data.players[uid]?.hp || 0;
if (newHp < prevHp) {
const pName = uid === myUserIdRef.current ? '你' : (data.players[uid]?.username?.substring(0, 8) || '对手');
addLog('effect', `💔 ${pName} 受到 ${prevHp - newHp} 点伤害!(${newHp}/${data.players[uid].maxHp})`);
spawnLabel(50, 20, `-${prevHp - newHp}`, 'damage', undefined, uid);
// 添加受伤特效
setDamagedPlayers(prev => [...prev, uid]);
setTimeout(() => setDamagedPlayers(prev => prev.filter(id => id !== uid)), 600);
// 如果是自己受伤,屏幕抖动
if (uid === myUserIdRef.current) {
setScreenShaking(true);
setTimeout(() => setScreenShaking(false), 400);
}
} else if (newHp > prevHp) {
const pName = uid === myUserIdRef.current ? '你' : (data.players[uid]?.username?.substring(0, 8) || '对手');
addLog('effect', `🌿 ${pName} 回复了 ${newHp - prevHp} 点生命!(${newHp}/${data.players[uid].maxHp})`);
spawnLabel(50, 20, `+${newHp - prevHp}`, 'heal', undefined, uid);
// 添加治疗特效
setHealedPlayers(prev => [...prev, uid]);
setTimeout(() => setHealedPlayers(prev => prev.filter(id => id !== uid)), 600);
}
});
// Turn Changes
if (data.currentTurnIndex !== prevState.currentTurnIndex) {
if (data.turnOrder[data.currentTurnIndex] === myUserIdRef.current) {
addLog('system', '🎯 轮到你了!');
}
}
}
setGameState(data);
prevGameStateRef.current = data;
} else if (opCode === 6) { // GAME_OVER
const winnerName = data.winnerId === myUserIdRef.current ? '你' : '对手';
addLog('system', `🏆 游戏结束!${winnerName}获胜!`);
setGameState(prev => prev ? { ...prev, ...data, gameStarted: false } : null);
} else if (opCode === 5) { // GAME_EVENT
// Special event from server (character abilities, item effects)
const event = data as unknown as { type: string; playerId: string; playerName: string; targetId?: string; targetName?: string; itemId?: string; value?: number; message: string };
const isMe = event.playerId === myUserIdRef.current;
const prefix = isMe ? '你' : (event.playerName?.substring(0, 8) || '对手');
addLog('effect', `${prefix}: ${event.message}`);
// Show floating damage/heal for affected players
if (event.type === 'damage' || event.type === 'item') {
const isGlobalDamage = event.itemId === 'lightning' || (event.itemId === 'knife' && event.message.includes('所有'));
if (isGlobalDamage && event.value) {
// Global damage - show on my screen
spawnLabel(50, 50, `-${event.value}`, 'damage', undefined, myUserIdRef.current || undefined);
} else if (event.targetId === myUserIdRef.current && event.value) {
// I'm the target of damage
spawnLabel(50, 50, `-${event.value}`, 'damage', undefined, myUserIdRef.current || undefined);
}
} else if (event.type === 'ability' && event.message.includes('香蕉') && event.playerId === myUserIdRef.current) {
// Monkey banana heal
spawnLabel(50, 50, '+1', 'heal', undefined, myUserIdRef.current || undefined);
}
}
} catch (err) {
console.error('Match data error:', err);
}
};
}
} catch (error) {
addLog('system', '连接服务器失败');
}
};
initNakama();
}, [addLog, spawnLabel]);
// 匹配计时器
useEffect(() => {
let timer: ReturnType<typeof setInterval>;
if (isMatching) {
setMatchingTimer(0);
timer = setInterval(() => {
setMatchingTimer(prev => prev + 1);
}, 1000);
}
return () => {
if (timer) clearInterval(timer);
setMatchingTimer(0);
};
}, [isMatching]);
// 回合倒计时器 - 与服务皂15秒同步
useEffect(() => {
if (!gameState?.gameStarted) return;
// 每次回合切换时重置为15秒
setTurnTimer(15);
const timer = setInterval(() => {
setTurnTimer(prev => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [gameState?.currentTurnIndex, gameState?.gameStarted]);
// Render Views
if (!isConnected) {
return (
<div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">
<div className="text-center">
<div className="text-2xl mb-4">...</div>
<div className="animate-spin text-4xl"></div>
</div>
</div>
);
}
if (!gameState) {
return (
<div className="min-h-screen bg-slate-900 text-white flex flex-col items-center justify-center p-4">
<h1 className="text-4xl font-bold bg-gradient-to-r from-emerald-400 to-blue-500 bg-clip-text text-transparent mb-8"></h1>
{isMatching ? (
<div className="bg-slate-800 p-8 rounded-2xl border border-slate-700 shadow-xl text-center space-y-4">
<div className="text-xl font-bold text-emerald-400 animate-pulse">...</div>
<div className="text-6xl animate-spin">🌀</div>
{/* 匹配状态信息 */}
<div className="space-y-2 text-sm">
<div className="flex items-center justify-center gap-2 text-slate-300">
<span className="text-2xl"></span>
<span className="text-lg font-mono">{matchingTimer}</span>
</div>
<div className="text-emerald-400 font-bold">
🎮 {matchPlayerCount}
</div>
{matchingTimer > 3 && (
<div className="text-yellow-400 text-xs animate-pulse mt-3">
...1-5
</div>
)}
{matchingTimer > 8 && (
<div className="text-orange-400 text-xs mt-2">
...
</div>
)}
</div>
</div>
) : (
<button onClick={startMatchmaking} className="px-8 py-4 bg-emerald-600 hover:bg-emerald-500 rounded-xl font-bold text-xl transition-all active:scale-95">🚀 ({matchPlayerCount})</button>
)}
<div className="mt-8 w-full max-w-md bg-black/20 rounded-xl p-4 h-48 overflow-y-auto font-mono text-xs text-slate-400">
{logs.map(log => <div key={log.id}>{log.content}</div>)}
<div ref={logsEndRef} />
</div>
</div>
);
}
const myPlayer = gameState.players[myUserId!] || null;
const opponents = Object.values(gameState.players).filter(p => p.userId !== myUserId);
const isMyTurn = gameState.turnOrder[gameState.currentTurnIndex] === myUserId;
return (
<div className={`min-h-screen bg-slate-900 text-white flex flex-col items-center p-2 lg:p-4 overflow-y-auto lg:overflow-hidden relative ${screenShaking ? 'screen-shake' : ''}`}>
<div className="w-full max-w-6xl flex justify-between items-center mb-2 lg:mb-6 p-2 lg:p-4 bg-slate-800/50 backdrop-blur-md rounded-xl border border-slate-700 shrink-0">
<div className="bg-emerald-500/20 text-emerald-400 px-3 py-1 rounded-full text-xs font-bold">Round {gameState.round}</div>
<h1 className="text-sm lg:text-xl font-bold bg-gradient-to-r from-emerald-400 to-blue-500 bg-clip-text text-transparent"></h1>
<button onClick={() => setShowGuide(true)} className="px-4 py-1.5 bg-emerald-500 hover:bg-emerald-400 text-white rounded-full text-xs font-black transition-all shadow-lg shadow-emerald-500/20 flex items-center gap-2">
<span>📜</span>
</button>
<button onClick={() => setDebugMode(!debugMode)} className={`px-3 py-1 rounded-full text-xs font-bold transition-all ${debugMode ? 'bg-red-500 text-white' : 'bg-slate-700 text-slate-400'}`}>
🐛 {debugMode ? '关闭明牌' : '开启明牌'}
</button>
</div>
<div className="w-full max-w-6xl flex flex-col lg:grid lg:grid-cols-12 gap-2 lg:gap-6 flex-1 min-h-0">
<div className="lg:col-span-3 flex lg:flex-col gap-2 shrink-0 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{opponents.map(p => (
<div key={p.userId} className={`bg-slate-800 p-2 lg:p-4 rounded-xl border ${gameState.turnOrder[gameState.currentTurnIndex] === p.userId ? 'border-yellow-500' : 'border-slate-700'} relative shrink-0 ${damagedPlayers.includes(p.userId) ? 'player-damaged' : ''} ${healedPlayers.includes(p.userId) ? 'player-healed' : ''}`}>
<div className="flex items-center gap-2">
<div className="text-2xl">{p.avatar}</div>
<div className="relative">
<div className="text-xs font-bold truncate max-w-[80px]">{p.username || 'Opponent'}</div>
{renderHeart(p.hp, p.maxHp)}
{renderStatusIcons(p)}
</div>
</div>
</div>
))}
</div>
<div className="lg:col-span-6 flex flex-col items-center justify-center bg-slate-800/30 rounded-2xl border border-slate-700/30 p-2 lg:p-6 relative shrink-0 lg:flex-1 min-h-0">
{/* 回合指示器和倒计时 */}
<div className="flex items-center gap-3 mb-2">
<div className={`px-4 py-1 rounded-full text-xs lg:text-sm font-bold ${isMyTurn ? 'bg-emerald-500 text-white' : 'bg-slate-700 text-slate-400'}`}>
{isMyTurn ? '🎯 你的回合' : '⏳ 等待对手'}
</div>
<div className={`flex items-center gap-1 px-3 py-1 rounded-full ${turnTimer <= 5 ? 'bg-red-500/30 timer-urgent' : 'bg-slate-700/50'}`}>
<span className="text-lg"></span>
<span className={`text-lg font-mono font-bold ${turnTimer <= 5 ? 'text-red-400' : 'text-yellow-400'}`}>{turnTimer}</span>
<span className="text-xs text-slate-400"></span>
</div>
</div>
{/* 倒计时进度条 */}
<div className="w-full max-w-[200px] h-1 bg-slate-700 rounded-full mb-3 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ${turnTimer <= 5 ? 'bg-red-500' : turnTimer <= 10 ? 'bg-yellow-500' : 'bg-emerald-500'}`}
style={{ width: `${(turnTimer / 15) * 100}%` }}
/>
</div>
<div className="grid gap-0.5 lg:gap-1 w-full max-w-[500px] aspect-square" style={{ gridTemplateColumns: `repeat(${gameState.gridSize || 10}, 1fr)` }}>
{gameState.grid.map((cell, idx) => {
const showContent = cell.revealed || debugMode;
return (
<div key={idx} onClick={() => handleCellClick(idx)} className={`aspect-square rounded-sm relative flex items-center justify-center text-sm lg:text-2xl border border-white/5 transition-all
${showContent ? (cell.type === 'bomb' ? 'bg-rose-900/50' + (cell.revealed ? ' explosion' : '') : cell.type === 'item' ? `bg-blue-900/40${cell.revealed ? ` item-${cell.itemId || 'generic'}` : ''}` : 'bg-slate-800/50') : 'bg-emerald-900/20 hover:bg-emerald-800/40 cursor-pointer'}
${debugMode && !cell.revealed ? 'opacity-60 border-dashed border-yellow-500/50' : ''}`}>
{showContent && (
cell.type === 'bomb' ? '💣' :
cell.type === 'item' ? getItemIcon(cell.itemId) :
(cell.neighborBombs ? (
<span style={{
color: [
'', '#3b82f6', '#22c55e', '#ef4444', '#a855f7', '#f97316', '#06b6d4', '#ec4899', '#64748b'
][cell.neighborBombs] || 'white',
fontWeight: '900',
textShadow: '0 0 5px rgba(0,0,0,0.5)'
}}>
{cell.neighborBombs}
</span>
) : (debugMode ? '✅' : ''))
)}
{floatingLabels.filter(l => l.cellIndex === idx).map(l => (
<div key={l.id} className="float-label" style={{ color: l.type === 'heal' ? '#10b981' : l.type === 'damage' ? '#ef4444' : '#fbbf24', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>{l.text}</div>
))}
</div>
);
})}
</div>
</div>
<div className="lg:col-span-3 flex flex-col gap-2 lg:gap-4 shrink-0 lg:overflow-hidden">
{myPlayer && (
<div className={`bg-slate-800 p-4 rounded-2xl border ${isMyTurn ? 'border-emerald-500' : 'border-slate-700'} relative ${damagedPlayers.includes(myPlayer.userId) ? 'player-damaged' : ''} ${healedPlayers.includes(myPlayer.userId) ? 'player-healed' : ''}`}>
<div className="flex items-center gap-4">
<div className="text-4xl">{myPlayer.avatar}</div>
<div className="relative">
<div className="font-bold text-lg"></div>
{renderHeart(myPlayer.hp, myPlayer.maxHp)}
{renderStatusIcons(myPlayer)}
</div>
</div>
</div>
)}
<div className="h-32 lg:flex-1 bg-black/20 rounded-xl p-4 overflow-hidden flex flex-col">
<h3 className="text-slate-500 text-xs font-bold uppercase mb-2"></h3>
<div className="flex-1 overflow-y-auto text-xs space-y-1">
{logs.map(log => <div key={log.id} className={log.type === 'system' ? 'text-yellow-400' : log.type === 'action' ? 'text-blue-300' : 'text-emerald-400'}>{log.content}</div>)}
<div ref={logsEndRef} />
</div>
</div>
</div>
</div>
{!gameState.gameStarted && gameState.winnerId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="bg-slate-800 p-8 rounded-3xl text-center max-w-sm w-full">
<div className="text-6xl mb-4">{gameState.winnerId === myUserId ? '🏆' : '💀'}</div>
<h2 className="text-3xl font-bold mb-4">{gameState.winnerId === myUserId ? '胜利!' : '失败'}</h2>
<button
onClick={refreshAndPlayAgain}
disabled={isRefreshing}
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-600 text-white font-bold py-3 rounded-xl transition-all"
>
{isRefreshing ? '🔄 正在准备...' : '🚀 再来一局'}
</button>
<button
onClick={() => {
if ((window as any).uni) {
(window as any).uni.navigateBack();
} else if ((window as any).wx?.miniProgram) {
(window as any).wx.miniProgram.navigateBack();
} else {
window.location.href = 'about:blank';
}
}}
className="w-full mt-2 bg-slate-700 hover:bg-slate-600 text-white font-bold py-2 rounded-xl text-sm"
>
</button>
</div>
</div>
)}
{showGuide && <GuideModal />}
</div>
);
};
export default App;

343
app/src/Explosion.css Normal file
View File

@ -0,0 +1,343 @@
/* Base Animations */
@keyframes explode {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.8;
box-shadow: 0 0 20px #ef4444, 0 0 40px #fb923c;
}
100% {
transform: scale(1);
opacity: 1;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.4);
}
}
@keyframes itemReveal {
0% {
transform: scale(0.5) rotate(-20deg);
opacity: 0;
}
60% {
transform: scale(1.2) rotate(10deg);
}
100% {
transform: scale(1) rotate(0);
opacity: 1;
}
}
@keyframes pulseGlowGreen {
0%,
100% {
box-shadow: 0 0 5px #10b981;
}
50% {
box-shadow: 0 0 20px #10b981, 0 0 30px #34d399;
}
}
@keyframes pulseGlowBlue {
0%,
100% {
box-shadow: 0 0 5px #3b82f6;
}
50% {
box-shadow: 0 0 20px #3b82f6, 0 0 30px #60a5fa;
}
}
@keyframes pulseGlowPurple {
0%,
100% {
box-shadow: 0 0 5px #8b5cf6;
}
50% {
box-shadow: 0 0 20px #8b5cf6, 0 0 30px #a78bfa;
}
}
@keyframes pulseGlowRed {
0%,
100% {
box-shadow: 0 0 5px #ef4444;
}
50% {
box-shadow: 0 0 20px #ef4444, 0 0 30px #f87171;
}
}
@keyframes pulseGlowGold {
0%,
100% {
box-shadow: 0 0 5px #f59e0b;
}
50% {
box-shadow: 0 0 20px #f59e0b, 0 0 30px #fbbf24;
}
}
@keyframes glint {
0% {
filter: brightness(1);
}
50% {
filter: brightness(1.8) contrast(1.2);
}
100% {
filter: brightness(1);
}
}
@keyframes shake {
0%,
100% {
transform: translate(0, 0);
}
25% {
transform: translate(-2px, -2px);
}
50% {
transform: translate(2px, 2px);
}
75% {
transform: translate(-2px, 2px);
}
}
@keyframes floating {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
/* Item Classes */
.explosion {
animation: explode 0.5s ease-out;
}
/* Category: Healing */
.item-medkit {
animation: itemReveal 0.5s ease-out, pulseGlowGreen 2s infinite;
background-color: rgba(16, 185, 129, 0.2) !important;
}
.item-revive {
animation: itemReveal 0.5s ease-out, pulseGlowGreen 1.5s infinite;
background-color: rgba(16, 185, 129, 0.3) !important;
border: 1px solid #10b981;
}
/* Category: Attack */
.item-knife {
animation: itemReveal 0.5s ease-out, glint 1s infinite;
background-color: rgba(239, 68, 68, 0.1) !important;
}
.item-lightning {
animation: itemReveal 0.4s ease-out, glint 0.5s infinite;
background-color: rgba(239, 68, 68, 0.2) !important;
}
.item-poison {
animation: itemReveal 0.6s ease-out, pulseGlowPurple 3s infinite;
background-color: rgba(139, 92, 246, 0.2) !important;
}
/* Category: Utility */
.item-shield {
animation: itemReveal 0.5s ease-out, pulseGlowBlue 2s infinite;
background-color: rgba(59, 130, 246, 0.2) !important;
border-radius: 50% !important;
}
.item-skip {
animation: itemReveal 0.5s ease-out, pulseGlowGold 2s infinite;
background-color: rgba(245, 158, 11, 0.2) !important;
opacity: 0.8;
}
.item-magnifier {
animation: itemReveal 0.5s ease-out, floating 2s ease-in-out infinite;
background-color: rgba(59, 130, 246, 0.1) !important;
}
/* Category: Risk/Meta */
.item-bomb_timer {
animation: itemReveal 0.5s ease-out, pulseGlowRed 0.5s infinite, shake 0.2s infinite;
background-color: rgba(239, 68, 68, 0.3) !important;
}
.item-curse {
animation: itemReveal 0.7s ease-out, pulseGlowPurple 1s infinite;
background-color: rgba(139, 92, 246, 0.3) !important;
filter: grayscale(0.5) contrast(1.5);
}
.item-chest {
animation: itemReveal 0.5s ease-out, pulseGlowGold 3s infinite;
background-color: rgba(245, 158, 11, 0.3) !important;
}
@keyframes floatUp {
0% {
transform: translateY(0);
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
transform: translateY(-40px);
opacity: 0;
}
}
.float-label {
position: absolute;
pointer-events: none;
font-weight: 900;
font-size: 1.5rem;
z-index: 100;
animation: floatUp 1s ease-out forwards;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8), 2px 2px 0 #000;
white-space: nowrap;
}
.text-heal {
color: #10b981;
}
.text-damage {
color: #ef4444;
}
.text-item {
color: #fbbf24;
}
/* Generic Item Reveal */
.item-generic {
animation: itemReveal 0.5s ease-out;
}
/* HP Damage Pulse */
.hp-damage {
animation: hpPulse 0.4s ease-in-out;
}
@keyframes hpPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
color: #ef4444;
}
}
/* 屏幕抖动效果 - 受到伤害时触发 */
@keyframes screenShake {
0%, 100% { transform: translate(0); }
10% { transform: translate(-8px, 6px); }
20% { transform: translate(8px, -6px); }
30% { transform: translate(-6px, -6px); }
40% { transform: translate(6px, 6px); }
50% { transform: translate(-6px, 4px); }
60% { transform: translate(6px, -4px); }
70% { transform: translate(-4px, 6px); }
80% { transform: translate(4px, -6px); }
90% { transform: translate(-2px, 2px); }
}
.screen-shake {
animation: screenShake 0.4s ease-in-out;
}
/* 玩家受伤闪烁效果 */
@keyframes damageFlash {
0%, 100% {
background: transparent;
box-shadow: none;
}
25%, 75% {
background: rgba(239, 68, 68, 0.4);
box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 15px rgba(239, 68, 68, 0.3);
}
50% {
background: rgba(239, 68, 68, 0.6);
box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), inset 0 0 20px rgba(239, 68, 68, 0.5);
}
}
.player-damaged {
animation: damageFlash 0.6s ease-in-out;
}
/* 玩家治疗效果 */
@keyframes healGlow {
0%, 100% {
box-shadow: none;
}
50% {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.6), inset 0 0 10px rgba(16, 185, 129, 0.3);
}
}
.player-healed {
animation: healGlow 0.6s ease-in-out;
}
/* 回合倒计时进度条 */
.turn-timer-bar {
transition: width 1s linear;
}
/* 倒计时紧急状态 */
@keyframes urgentPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.timer-urgent {
animation: urgentPulse 0.5s ease-in-out infinite;
color: #ef4444 !important;
}

1
app/src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

9
app/src/index.css Normal file
View File

@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0f172a; /* Dark theme base */
color: white;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}

134
app/src/lib/nakama.ts Normal file
View File

@ -0,0 +1,134 @@
import { Client, Session } from '@heroiclabs/nakama-js';
import type { Socket } from '@heroiclabs/nakama-js';
// Game token info received from backend
export interface GameTokenInfo {
game_token: string;
expires_at: string;
nakama_server: string;
nakama_key: string;
remaining_times: number;
}
class NakamaManager {
private client: Client | null = null;
private session: Session | null = null;
private socket: Socket | null = null;
private useSSL: boolean = false;
private gameToken: string | null = null;
/**
* Initialize the Nakama client with server info from backend
* @param serverUrl WebSocket URL (e.g., "wss://nakama.example.com" or "ws://localhost:7350")
* @param serverKey Server key (default: "defaultkey")
*/
initClient(serverUrl: string, serverKey: string = 'defaultkey') {
// Parse server URL
const url = new URL(serverUrl.replace('wss://', 'https://').replace('ws://', 'http://'));
const host = url.hostname;
const port = url.port || (serverUrl.startsWith('wss') ? '443' : '7350');
this.useSSL = serverUrl.startsWith('wss');
console.log(`Initializing Nakama client: ${this.useSSL ? 'wss' : 'ws'}://${host}:${port}`);
this.client = new Client(serverKey, host, port, this.useSSL);
}
/**
* Authenticate with a game token from the backend
* @param gameToken JWT game token from /api/app/game/enter
*/
async authenticateWithGameToken(gameToken: string) {
if (!this.client) {
throw new Error('Client not initialized. Call initClient() first.');
}
// Return existing session if already authenticated with same token
if (this.session && this.socket && this.gameToken === gameToken) {
console.log('Already authenticated with this token, returning existing session');
return this.session;
}
this.gameToken = gameToken;
// Use authenticateCustom with the game token as the custom ID
// This creates a unique Nakama user per game token
// The actual user verification happens in MatchJoinAttempt on the server
const customId = `game_${Date.now()}_${Math.random().toString(36).substring(7)}`;
console.log('Authenticating with game token...');
this.session = await this.client.authenticateCustom(customId, true);
if (this.session.user_id) {
console.log('Nakama User ID:', this.session.user_id);
}
// Connect Socket
this.socket = this.client.createSocket(this.useSSL, false);
await this.socket.connect(this.session, true);
console.log('Socket connected');
return this.session;
}
/**
* Find a match using matchmaker
* The game_token is passed as metadata for server-side validation
*/
async findMatch(minCount: number = 2, maxCount: number = 2) {
if (!this.socket) throw new Error('Socket not connected');
if (!this.gameToken) throw new Error('No game token set');
const query = '*';
// Pass game_token as string properties for matchmaker
const stringProperties = { game_token: this.gameToken };
console.log(`Searching for match: ${minCount}-${maxCount} players`);
return this.socket.addMatchmaker(query, minCount, maxCount, stringProperties);
}
/**
* Join a match with the game token
* @param matchId Match ID from matchmaker
* @param token Matchmaker token (if from matchmaker)
*/
async joinMatch(matchId: string, token?: string) {
if (!this.socket) throw new Error('Socket not connected');
if (!this.gameToken) throw new Error('No game token set');
// Pass game_token in metadata for server validation
const metadata = { game_token: this.gameToken };
return this.socket.joinMatch(matchId, token, metadata);
}
async sendMatchState(matchId: string, opCode: number, data: string) {
if (!this.socket) throw new Error('Socket not connected');
return this.socket.sendMatchState(matchId, opCode, data);
}
getSocket() {
return this.socket;
}
getSession() {
return this.session;
}
getGameToken() {
return this.gameToken;
}
/**
* Disconnect and cleanup
*/
async disconnect() {
if (this.socket) {
await this.socket.disconnect(false);
this.socket = null;
}
this.session = null;
this.gameToken = null;
}
}
export const nakamaManager = new NakamaManager();

10
app/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

19
app/tailwind.config.js Normal file
View File

@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'slate-900': '#0f172a',
'slate-800': '#1e293b',
'emerald-500': '#10b981',
'rose-500': '#f43f5e',
'blue-500': '#3b82f6',
}
},
},
plugins: [],
}

33
app/tsconfig.app.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}

7
app/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

29
app/tsconfig.node.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": [
"ES2023"
],
"module": "ESNext",
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}

13
app/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
})

97
docker-compose.all.yml Normal file
View File

@ -0,0 +1,97 @@
# 全量服务部署 (后端 + 游戏服 + 数据库)
# 使用方法: docker-compose -f docker-compose.all.yml up -d
services:
# ----------------------------------------------------
# 1. 业务后端 (Bindbox Game Backend)
# ----------------------------------------------------
bindbox-game:
image: zfc931912343/bindbox-game:v1.12
container_name: bindbox-game
restart: always
ports:
- "9991:9991"
volumes:
- ../bindbox_game/logs:/app/logs
# - ../bindbox_game/configs:/app/configs # 指向 bindbox_game 目录下的配置
environment:
- ACTIVE_ENV=pro
- TZ=Asia/Shanghai
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 2. 游戏数据库 (CockroachDB for Nakama)
# ----------------------------------------------------
nakama-db:
image: cockroachdb/cockroach:latest-v23.1
container_name: nakama-db
command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/ --cache=.25 --max-sql-memory=.25
restart: always
volumes:
- nakama-db-data:/var/lib/cockroach
ports:
- "26257:26257"
- "8081:8080"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: 3s
timeout: 3s
retries: 5
environment:
- TZ=Asia/Shanghai
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 3. 游戏服务器 (Nakama)
# ----------------------------------------------------
nakama:
image: zfc931912343/bindbox-saolei:v1.5
container_name: nakama-server
environment:
# 直接使用服务名访问后端
- MINESWEEPER_BACKEND_URL=http://bindbox-game:9991/api/internal
- TZ=Asia/Shanghai
entrypoint:
- "/bin/sh"
- "-ecx"
- "/nakama/nakama migrate up --database.address root@nakama-db:26257 && exec /nakama/nakama --name nakama1 --database.address root@nakama-db:26257 --logger.level DEBUG --session.token_expiry_sec 7200 --metrics.prometheus_port 9100 --runtime.path /nakama/modules --matchmaker.interval_sec 1 --matchmaker.max_intervals 5"
restart: always
depends_on:
nakama-db:
condition: service_healthy
bindbox-game:
condition: service_started
volumes:
- nakama-data:/nakama/data
ports:
- "7350:7350"
- "7351:7351"
- "9100:9100"
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://bindbox-game:9991/" ]
interval: 10s
timeout: 5s
retries: 5
networks:
- bindbox_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
nakama-db-data:
nakama-data:
networks:
bindbox_net:
name: bindbox_net
driver: bridge

55
docker-compose.cloud.yml Normal file
View File

@ -0,0 +1,55 @@
# 云端部署专用 - 扫雷游戏服务
# 使用方法: docker-compose -f docker-compose.cloud.yml up -d
services:
nakama-db:
image: cockroachdb/cockroach:latest-v23.1
container_name: nakama-db
command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/
restart: always
volumes:
- nakama-db-data:/var/lib/cockroach
ports:
- "26257:26257"
- "8081:8080"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: 3s
timeout: 3s
retries: 5
nakama:
image: zfc931912343/bindbox-saolei:v1.3
container_name: nakama-server
environment:
# 使用 Docker 内部网络访问后端服务 (需确保在同一网络下)
- MINESWEEPER_BACKEND_URL=http://blindbox-mms-api:9991/api/internal
entrypoint:
- "/bin/sh"
- "-ecx"
- "/nakama/nakama migrate up --database.address root@nakama-db:26257 && exec /nakama/nakama --name nakama1 --database.address root@nakama-db:26257 --logger.level DEBUG --session.token_expiry_sec 7200 --metrics.prometheus_port 9100 --runtime.path /nakama/modules"
restart: always
depends_on:
nakama-db:
condition: service_healthy
volumes:
- nakama-data:/nakama/data
ports:
- "7350:7350"
- "7351:7351"
- "9100:9100"
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7350/" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
nakama-db-data:
nakama-data:
# 必须加入后端服务所在的网络才能通过 service_name 访问
networks:
default:
name: ${DOCKER_NETWORK_NAME:-bindbox_default}
external: true

52
docker-compose.yml Normal file
View File

@ -0,0 +1,52 @@
services:
cockroachdb:
image: cockroachdb/cockroach:latest-v23.1
command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/
restart: "no"
volumes:
- data:/var/lib/cockroach
expose:
- "8080"
- "26257"
ports:
- "26257:26257"
- "8081:8080"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: 3s
timeout: 3s
retries: 5
nakama:
build:
context: ./server
dockerfile: Dockerfile
container_name: wuziqi-nakama-1
entrypoint:
- "/bin/sh"
- "-ecx"
- >
/nakama/nakama migrate up --database.address root@cockroachdb:26257 && exec /nakama/nakama --name nakama1 --database.address root@cockroachdb:26257 --logger.level DEBUG --session.token_expiry_sec 7200 --metrics.prometheus_port 9100 --runtime.path /nakama/modules
restart: "no"
links:
- "cockroachdb:db"
depends_on:
cockroachdb:
condition: service_healthy
volumes:
- nakama-data:/nakama/data
expose:
- "7348"
ports:
- "7350:7350"
- "7351:7351"
- "9100:9100"
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7350/" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
data:
nakama-data:

27
nginx-cors-config.conf Normal file
View File

@ -0,0 +1,27 @@
# CORS 配置:允许游戏前端访问配置 API
# 将此配置添加到 mini-chat.1024tool.vip 的 Nginx 配置中
location /api/internal/game/minesweeper/config {
# OPTIONS 预检请求处理
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://game.1024tool.vip' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'X-Internal-Key, Content-Type, Accept' always;
add_header 'Access-Control-Max-Age' 3600;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
# 实际请求的 CORS 头
add_header 'Access-Control-Allow-Origin' 'https://game.1024tool.vip' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'X-Internal-Key, Content-Type, Accept' always;
# 反向代理到后端服务
proxy_pass http://127.0.0.1:9991;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

16
server/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM heroiclabs/nakama-pluginbuilder:3.21.1 AS builder
ENV GO111MODULE=on
ENV CGO_ENABLED=1
WORKDIR /backend
COPY go.mod .
COPY go.sum .
COPY main.go .
RUN go build --trimpath --mod=mod --buildmode=plugin -o ./backend.so
FROM heroiclabs/nakama:3.21.1
COPY --from=builder /backend/backend.so /nakama/modules/

BIN
server/backend.so Normal file

Binary file not shown.

17
server/build/main.js Normal file
View File

@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var match_handler_1 = require("./match_handler");
var InitModule = function (ctx, logger, nk, initializer) {
logger.info('Nakama Server Module Loaded (TypeScript)');
// Register Match Handler
initializer.registerMatch('animal_minesweeper', {
matchInit: match_handler_1.matchInit,
matchJoinAttempt: match_handler_1.matchJoinAttempt,
matchJoin: match_handler_1.matchJoin,
matchLeave: match_handler_1.matchLeave,
matchLoop: match_handler_1.matchLoop,
matchTerminate: match_handler_1.matchTerminate,
matchSignal: match_handler_1.matchSignal
});
logger.info('Match Handler Registered: animal_minesweeper');
};

View File

@ -0,0 +1,170 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.matchSignal = exports.matchTerminate = exports.matchLoop = exports.matchLeave = exports.matchJoin = exports.matchJoinAttempt = exports.matchInit = void 0;
var types_1 = require("./types");
var MAX_PLAYERS = 4;
var GRID_SIZE = 100; // 10x10
var BOMB_COUNT = 30;
var ITEM_COUNT_MIN = 5;
var ITEM_COUNT_MAX = 10;
var matchInit = function (ctx, logger, nk, params) {
logger.info('Match initialized');
// Generate Grid
var grid = Array(GRID_SIZE).fill(null).map(function () { return ({ type: 'empty', revealed: false }); });
// Place Bombs
var bombsPlaced = 0;
while (bombsPlaced < BOMB_COUNT) {
var idx = Math.floor(Math.random() * GRID_SIZE);
if (grid[idx].type === 'empty') {
grid[idx].type = 'bomb';
bombsPlaced++;
}
}
// Place Items
var itemCount = Math.floor(Math.random() * (ITEM_COUNT_MAX - ITEM_COUNT_MIN + 1)) + ITEM_COUNT_MIN;
var itemsPlaced = 0;
while (itemsPlaced < itemCount) {
var idx = Math.floor(Math.random() * GRID_SIZE);
if (grid[idx].type === 'empty') {
grid[idx].type = 'item';
grid[idx].itemId = types_1.ItemTypes[Math.floor(Math.random() * types_1.ItemTypes.length)];
itemsPlaced++;
}
}
var state = {
players: {},
grid: grid,
turnOrder: [],
currentTurnIndex: 0,
round: 1,
winnerId: null,
gameStarted: false
};
return {
state: state,
tickRate: 1, // 1 tick per second is enough for turn-based, but maybe higher for responsiveness
label: 'Animal Minesweeper'
};
};
exports.matchInit = matchInit;
var matchJoinAttempt = function (ctx, logger, nk, dispatcher, tick, state, presence, metadata) {
if (state.gameStarted) {
return { state: state, accept: false, rejectMessage: 'Game already started' };
}
if (Object.keys(state.players).length >= MAX_PLAYERS) {
return { state: state, accept: false, rejectMessage: 'Match full' };
}
return { state: state, accept: true };
};
exports.matchJoinAttempt = matchJoinAttempt;
var matchJoin = function (ctx, logger, nk, dispatcher, tick, state, presences) {
presences.forEach(function (presence) {
// Random character assignment for now
var character = types_1.CharacterTypes[Math.floor(Math.random() * types_1.CharacterTypes.length)];
state.players[presence.userId] = {
userId: presence.userId,
sessionId: presence.sessionId,
username: presence.username,
avatar: '🦁', // Placeholder, map character to emoji later
hp: 4, // Default HP
maxHp: 4,
status: [],
character: character
};
state.turnOrder.push(presence.userId);
logger.info("Player joined: ".concat(presence.userId));
});
// Check if full to start game
// Fix: Use >= to handle potential race condition where multiple players join in same tick
if (Object.keys(state.players).length >= MAX_PLAYERS && !state.gameStarted) {
state.gameStarted = true;
logger.info('Game Started! Broadcasting GAME_START');
// Broadcast Game Start
dispatcher.broadcastMessage(types_1.OpCode.GAME_START, JSON.stringify(state));
}
else {
logger.info("Player joined. Count: ".concat(Object.keys(state.players).length, ". Broadcasting UPDATE_STATE"));
// Broadcast updated state (new player joined)
dispatcher.broadcastMessage(types_1.OpCode.UPDATE_STATE, JSON.stringify(state));
}
return { state: state };
};
exports.matchJoin = matchJoin;
var matchLeave = function (ctx, logger, nk, dispatcher, tick, state, presences) {
presences.forEach(function (presence) {
delete state.players[presence.userId];
var idx = state.turnOrder.indexOf(presence.userId);
if (idx > -1) {
state.turnOrder.splice(idx, 1);
// Adjust turn index if necessary
if (idx < state.currentTurnIndex) {
state.currentTurnIndex--;
}
if (state.currentTurnIndex >= state.turnOrder.length) {
state.currentTurnIndex = 0;
}
}
});
// Broadcast updated state (player left)
if (state.gameStarted && Object.keys(state.players).length > 0) {
dispatcher.broadcastMessage(types_1.OpCode.UPDATE_STATE, JSON.stringify(state));
}
// If game in progress and players drop, handle logic (end game or continue)
if (Object.keys(state.players).length < 2 && state.gameStarted) {
state.winnerId = Object.keys(state.players)[0] || null;
dispatcher.broadcastMessage(types_1.OpCode.GAME_OVER, JSON.stringify({ winnerId: state.winnerId }));
return null; // End match
}
// Broadcast updated state (player left)
if (state.gameStarted) {
dispatcher.broadcastMessage(types_1.OpCode.UPDATE_STATE, JSON.stringify(state));
}
return { state: state };
};
exports.matchLeave = matchLeave;
var matchLoop = function (ctx, logger, nk, dispatcher, tick, state, messages) {
if (!state.gameStarted)
return { state: state };
messages.forEach(function (message) {
if (message.opCode === types_1.OpCode.MOVE) {
var data = JSON.parse(nk.binaryToString(message.data));
handleMove(state, message.sender.userId, data.index, logger, dispatcher);
}
});
return { state: state };
};
exports.matchLoop = matchLoop;
var matchTerminate = function (ctx, logger, nk, dispatcher, tick, state, graceSeconds) {
return { state: state };
};
exports.matchTerminate = matchTerminate;
var matchSignal = function (ctx, logger, nk, dispatcher, tick, state, data) {
return { state: state, data: data };
};
exports.matchSignal = matchSignal;
// --- Helper Functions ---
function handleMove(state, userId, cellIndex, logger, dispatcher) {
// Validate Turn
var currentUserId = state.turnOrder[state.currentTurnIndex];
if (userId !== currentUserId) {
return; // Not your turn
}
var cell = state.grid[cellIndex];
if (cell.revealed)
return; // Already revealed
// Reveal Cell
cell.revealed = true;
var player = state.players[userId];
// Logic
if (cell.type === 'bomb') {
player.hp -= 2;
// Check death...
}
else if (cell.type === 'item') {
// Add item logic...
}
// Next Turn
state.currentTurnIndex = (state.currentTurnIndex + 1) % state.turnOrder.length;
// Broadcast Update
dispatcher.broadcastMessage(types_1.OpCode.UPDATE_STATE, JSON.stringify(state));
}

19
server/build/types.js Normal file
View File

@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CharacterTypes = exports.ItemTypes = exports.OpCode = void 0;
var OpCode;
(function (OpCode) {
OpCode[OpCode["GAME_START"] = 1] = "GAME_START";
OpCode[OpCode["UPDATE_STATE"] = 2] = "UPDATE_STATE";
OpCode[OpCode["MOVE"] = 3] = "MOVE";
OpCode[OpCode["USE_ITEM"] = 4] = "USE_ITEM";
OpCode[OpCode["TURN_CHANGE"] = 5] = "TURN_CHANGE";
OpCode[OpCode["GAME_OVER"] = 6] = "GAME_OVER";
})(OpCode || (exports.OpCode = OpCode = {}));
exports.ItemTypes = [
'medkit', 'bomb_timer', 'poison', 'shield', 'skip',
'magnifier', 'knife', 'revive', 'lightning', 'chest', 'curse'
];
exports.CharacterTypes = [
'elephant', 'cat', 'dog', 'monkey', 'chicken', 'sloth', 'hippo', 'tiger'
];

7
server/go.mod Normal file
View File

@ -0,0 +1,7 @@
module wuziqi-server
go 1.21
require github.com/heroiclabs/nakama-common v1.31.0
require google.golang.org/protobuf v1.31.0 // indirect

10
server/go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/heroiclabs/nakama-common v1.31.0 h1:oaJbwVRUiFXA77gXF3XNrGCmR0CXf7+2vXEvaBLkP6w=
github.com/heroiclabs/nakama-common v1.31.0/go.mod h1:Os8XeXGvHAap/p6M/8fQ3gle4eEXDGRQmoRNcPQTjXs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

1193
server/main.go Normal file

File diff suppressed because it is too large Load Diff

1
server/runtime.go Normal file
View File

@ -0,0 +1 @@
package main

39
游戏逻辑文档.txt Normal file
View File

@ -0,0 +1,39 @@
# 玩家设定
1. 每人血量初始化4点血量
2. 捡到道具自动使用
# 棋盘设定
1. 10*10 100个格子道具数量随机5-10个炸弹数量为30
# 扫雷逻辑
1. 玩家点击某个格子如果点击的是炸弹扣除2点血量如果血量为0玩家淘汰剩下最后一个玩家存活则游戏结束存活玩家为赢家
2. 玩家点击某个格子,如果点击的是道具,则触发对应的道具效果。
3. 玩家获得随机顺序,随机位置
4. 掉线允许重连
5. 允许观察比赛
# 道具逻辑
1. 医疗包 恢复1点血量,可以解除中毒效果
2. 定时炸弹 踩到炸弹后3个回合后被炸扣除2点血量
3. 毒药瓶 随机让一个存活的玩家中毒中毒后每2个回合扣除1点血量除了医疗包不能解除。
4. 护盾 免疫1次伤害
5. 好人卡 跳过一个行动回合,也有护盾的效果,免疫伤害
6. 放大镜 为玩家随机查看一个格子,并把信息告诉该玩家
7. 飞刀 除该玩家外随机一个玩家受到1点伤害
8. 复活甲 免疫一次死亡保留1点血量
9. 闪电 所有玩家扣除1点血量
10. 宝箱 游戏结束玩家获得1个宝箱宝箱可以开出随机的道具卡碎片4个碎片合成一个道具卡
11. 诅咒 下一次受伤的伤害值乘以2
# 角色逻辑
1. 大象 血量初始化为5无法使用医疗包、好人卡、复活甲这三种道具
2. 猫咪 血量初始化为3所有受到的伤害强制为1点包括诅咒加成
3. 汪汪 46人赛中每69回合触发一次放大镜的道具效果获得一个随机格子信息
4. 猴子 每回合有15%概率获得一个香蕉回复1点血量每局最多生效2次
5. 坤坤 每次被伤害有概率获得一个道具道具只能是好人卡、护盾、放大镜概率为8%每局最多生效2次
6. 懒懒 免疫毒药瓶定时炸弹的伤害降低为1
7. 河马 无法拾取道具但有概率免疫死亡每局最多生效1次并且概率为55%
8. 老虎 当老虎在场上时使用飞刀的伤害变为全体伤害并且伤害为2

34
说明文档.md Normal file
View File

@ -0,0 +1,34 @@
# 说明文档.md
## 1. 项目规划
### 1.1 项目简介
本项目为一个基于扫雷机制的多人在线策略游戏暂定名动物扫雷大作战。结合了RPG元素血量、角色技能、道具支持多人对战。
### 1.2 实施方案
- **技术栈**
- **Frontend**: React + Vite + TailwindCSS + @heroiclabs/nakama-js
- **Backend**: Nakama Server + CockroachDB (Docker)
- **Language**: TypeScript (Full Stack)
- **核心模块**
- **游戏核心逻辑**: 迁移至服务端 (Authoritative Server)
- **多人网络同步**: Nakama Match (State Sync)
- **匹配系统**: Nakama Matchmaker (4人自动匹配)
## 2. 进度记录
| 任务ID | 任务名称 | 状态 | 开始时间 | 完成时间 | 备注 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| P0-01 | UI设计方案制定 | 已完成 | 2025-12-17 | 2025-12-17 | 确立“现代极简/轻科幻”风格 |
| P0-02 | 项目初始化 | 已完成 | 2025-12-17 | 2025-12-17 | Vite+React+Tailwind搭建 |
| P1-01 | 服务端环境搭建 | 已完成 | 2025-12-17 | 2025-12-17 | Docker Compose (Nakama+CockroachDB) |
| P1-02 | 前端SDK集成 | 进行中 | 2025-12-17 | - | 安装 nakama-js, 封装 NakamaManager |
## 3. 待办事项
- [x] 确认UI设计方案
- [x] 搭建项目基础结构
- [x] 创建 Docker Compose 环境
- [x] 前端安装 Nakama SDK
- [ ] 启动并验证 Nakama 服务
- [ ] 实现前端自动匹配逻辑 (4人)
- [ ] 开发服务端核心逻辑 (TypeScript)
- [ ] 匹配处理器 (Match Handler)
- [ ] 游戏循环与状态同步