first commit
This commit is contained in:
commit
9bb5e9da9c
40
.trae/documents/Nakama 服务端集成规划.md
Normal file
40
.trae/documents/Nakama 服务端集成规划.md
Normal 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
140
DEPLOYMENT.md
Normal 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
5
app/.env.production
Normal 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
24
app/.gitignore
vendored
Normal 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
73
app/README.md
Normal 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
BIN
app/dist.zip
Normal file
Binary file not shown.
23
app/eslint.config.js
Normal file
23
app/eslint.config.js
Normal 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
13
app/index.html
Normal 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
4109
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
app/package.json
Normal file
38
app/package.json
Normal 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
6
app/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
app/public/vite.svg
Normal file
1
app/public/vite.svg
Normal 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 |
53
app/scripts/simulate_bots.ts
Normal file
53
app/scripts/simulate_bots.ts
Normal 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);
|
||||
52
app/scripts/simulate_bots_fixed.ts
Normal file
52
app/scripts/simulate_bots_fixed.ts
Normal 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);
|
||||
56
app/scripts/simulate_bots_matchmaker.ts
Normal file
56
app/scripts/simulate_bots_matchmaker.ts
Normal 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);
|
||||
56
app/scripts/test_matchmaker.cjs
Normal file
56
app/scripts/test_matchmaker.cjs
Normal 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();
|
||||
56
app/scripts/test_matchmaker.mjs
Normal file
56
app/scripts/test_matchmaker.mjs
Normal 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
42
app/src/App.css
Normal 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
704
app/src/App.tsx
Normal 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
343
app/src/Explosion.css
Normal 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
1
app/src/assets/react.svg
Normal 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
9
app/src/index.css
Normal 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
134
app/src/lib/nakama.ts
Normal 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
10
app/src/main.tsx
Normal 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
19
app/tailwind.config.js
Normal 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
33
app/tsconfig.app.json
Normal 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
7
app/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
29
app/tsconfig.node.json
Normal file
29
app/tsconfig.node.json
Normal 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
13
app/vite.config.ts
Normal 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
97
docker-compose.all.yml
Normal 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
55
docker-compose.cloud.yml
Normal 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
52
docker-compose.yml
Normal 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
27
nginx-cors-config.conf
Normal 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
16
server/Dockerfile
Normal 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
BIN
server/backend.so
Normal file
Binary file not shown.
17
server/build/main.js
Normal file
17
server/build/main.js
Normal 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');
|
||||
};
|
||||
170
server/build/match_handler.js
Normal file
170
server/build/match_handler.js
Normal 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
19
server/build/types.js
Normal 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
7
server/go.mod
Normal 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
10
server/go.sum
Normal 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
1193
server/main.go
Normal file
File diff suppressed because it is too large
Load Diff
1
server/runtime.go
Normal file
1
server/runtime.go
Normal file
@ -0,0 +1 @@
|
||||
package main
|
||||
39
游戏逻辑文档.txt
Normal file
39
游戏逻辑文档.txt
Normal 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. 汪汪 4(6)人赛中,每6(9)回合触发一次放大镜的道具效果,获得一个随机格子信息
|
||||
4. 猴子 每回合有15%概率获得一个香蕉(回复1点血量),每局最多生效2次
|
||||
5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡、护盾、放大镜,概率为8%,每局最多生效2次
|
||||
6. 懒懒 免疫毒药瓶,定时炸弹的伤害降低为1
|
||||
7. 河马 无法拾取道具,但有概率免疫死亡,每局最多生效1次,并且概率为55%
|
||||
8. 老虎 当老虎在场上时,使用飞刀的伤害变为全体伤害,并且伤害为2
|
||||
|
||||
|
||||
34
说明文档.md
Normal file
34
说明文档.md
Normal 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)
|
||||
- [ ] 游戏循环与状态同步
|
||||
Loading…
x
Reference in New Issue
Block a user