初始化斗地主残局版项目
This commit is contained in:
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 二进制文件
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.bin
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Go编译生成的文件
|
||||||
|
/app/cmd/main
|
||||||
|
/app/cmd/*.exe
|
||||||
|
|
||||||
|
# 依赖管理
|
||||||
|
/vendor/
|
||||||
|
/app/vendor/
|
||||||
|
|
||||||
|
# 测试覆盖率文件
|
||||||
|
*.coverprofile
|
||||||
|
coverage.html
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
# IDE和编辑器配置
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
/tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# 环境变量文件
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Docker相关
|
||||||
|
*.pid
|
||||||
150
AGENTS.md
Normal file
150
AGENTS.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 斗地主残局版 - AGENTS.md
|
||||||
|
|
||||||
|
## 1. 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
doudizhu-server/
|
||||||
|
├── app/ # Go后端
|
||||||
|
│ ├── cmd/main.go # 程序入口
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── models/ # 数据模型(Card, Room, Player等)
|
||||||
|
│ │ ├── game/ # 游戏逻辑(牌型判断、对局管理)
|
||||||
|
│ │ ├── handlers/ # HTTP API处理器
|
||||||
|
│ │ └── ws/ # WebSocket处理
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── go.mod
|
||||||
|
├── nginx/nginx.conf # Nginx反向代理配置
|
||||||
|
├── nginx/html/ # 静态文件(HTML/CSS/JS)
|
||||||
|
├── compose.yaml
|
||||||
|
├── README.md
|
||||||
|
└── API.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 构建与运行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地开发
|
||||||
|
cd doudizhu-server/app && go mod tidy && go run ./cmd
|
||||||
|
|
||||||
|
# Docker部署
|
||||||
|
cd doudizhu-server && docker compose up -d --build
|
||||||
|
|
||||||
|
# 代码格式化
|
||||||
|
gofmt -w ./app
|
||||||
|
|
||||||
|
# 静态检查
|
||||||
|
go vet ./app/...
|
||||||
|
|
||||||
|
# 运行所有测试
|
||||||
|
go test ./app/...
|
||||||
|
|
||||||
|
# 运行单个包测试
|
||||||
|
go test ./app/internal/game/...
|
||||||
|
|
||||||
|
# 运行单个测试
|
||||||
|
go test ./app/internal/game/... -run TestCardType
|
||||||
|
|
||||||
|
# 带覆盖率
|
||||||
|
go test ./app/... -cover
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Go代码风格规范
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
|
||||||
|
- **导出**:大驼峰命名,如 `GameManager`、`NewCardLogic`
|
||||||
|
- **模块内**:小驼峰命名,如 `userId`、`cardKey`
|
||||||
|
- **模块名**:全小写,如 `game`、`handlers`、`ws`
|
||||||
|
- **常量**:全大写下划线命名
|
||||||
|
|
||||||
|
### Import规范
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"encoding/json" // 标准库
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket" // 第三方库
|
||||||
|
|
||||||
|
"doudizhu-server/internal/game" // 本地包
|
||||||
|
"doudizhu-server/internal/models"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
使用哨兵错误模式:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 定义 (internal/game/errors.go)
|
||||||
|
var (
|
||||||
|
ErrRoomNotFound = errors.New("room not found")
|
||||||
|
ErrNotYourTurn = errors.New("not your turn")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 使用:早返回
|
||||||
|
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 并发安全
|
||||||
|
|
||||||
|
```go
|
||||||
|
type GameManager struct {
|
||||||
|
rooms map[string]*models.Room
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读操作用RLock
|
||||||
|
func (gm *GameManager) GetRoom(id string) (*models.Room, error) {
|
||||||
|
gm.mu.RLock()
|
||||||
|
defer gm.mu.RUnlock()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写操作用Lock
|
||||||
|
func (gm *GameManager) CreateRoom(...) {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. HTTP API响应格式
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ApiResponse struct {
|
||||||
|
Status int `json:"status"` // HTTP状态码
|
||||||
|
Code int `json:"code"` // 0=成功, 1=失败
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 游戏规则要点
|
||||||
|
|
||||||
|
- **超人强**:第55张牌,最大单牌,可管一切
|
||||||
|
- **简化牌型**:三张可成顺子/炸弹,两对可成连对
|
||||||
|
- **发牌**:每人初始5张,一轮后摸1张
|
||||||
|
- **无地主**:上一轮获胜者先出
|
||||||
|
|
||||||
|
## 6. 开发注意事项
|
||||||
|
|
||||||
|
1. **代码变动后同步更新文档**:README.md 和 API.md
|
||||||
|
2. **后端API路由必须在 `/api` 下**
|
||||||
|
3. **WebSocket路径**:`/api/ws?roomId=xxx&playerId=xxx`
|
||||||
|
4. **前端不使用npm/react**,采用HTML+CSS+JS
|
||||||
|
5. **JSON字段用下划线命名**,如 `room_id`,但尽量用单词
|
||||||
|
|
||||||
|
## 7. 调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
# 前端不生效?强制刷新 (Ctrl+F5)
|
||||||
|
```
|
||||||
277
API.md
Normal file
277
API.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# 斗地主残局版 API 文档
|
||||||
|
|
||||||
|
## 基础信息
|
||||||
|
|
||||||
|
- 基础URL: `http://<host>/api`
|
||||||
|
- 响应格式: JSON
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
所有API响应遵循统一格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| status | int | HTTP状态码 |
|
||||||
|
| code | int | 业务状态码 (0=成功, 1=失败) |
|
||||||
|
| message | string | 提示信息 |
|
||||||
|
| data | object | 实际业务数据 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
### 创建房间
|
||||||
|
|
||||||
|
**POST** `/api/rooms`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"playerName": "玩家昵称",
|
||||||
|
"maxPlayers": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| playerName | string | 是 | 玩家昵称 |
|
||||||
|
| maxPlayers | int | 否 | 最大玩家数,默认4,范围2-10 |
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"roomId": "a1b2c3d4e5f6g7h8",
|
||||||
|
"playerId": "i9j0k1l2m3n4o5p6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 加入房间
|
||||||
|
|
||||||
|
**POST** `/api/rooms/{roomId}/join`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"playerName": "玩家昵称"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"roomId": "a1b2c3d4e5f6g7h8",
|
||||||
|
"playerId": "q7r8s9t0u1v2w3x4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 400,
|
||||||
|
"code": 1,
|
||||||
|
"message": "room not found",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket API
|
||||||
|
|
||||||
|
**连接地址**: `ws://<host>/api/ws?roomId={roomId}&playerId={playerId}`
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
|
||||||
|
所有消息为JSON格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "消息类型",
|
||||||
|
"playerId": "玩家ID",
|
||||||
|
"roomId": "房间ID",
|
||||||
|
"data": { ... },
|
||||||
|
"timestamp": 1708123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息类型
|
||||||
|
|
||||||
|
| 类型 | 方向 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| ready | 客户端→服务器 | 准备/取消准备 |
|
||||||
|
| play | 客户端→服务器 | 出牌 |
|
||||||
|
| pass | 客户端→服务器 | 不出 |
|
||||||
|
| chat | 双向 | 聊天消息 |
|
||||||
|
| state | 服务器→客户端 | 游戏状态更新 |
|
||||||
|
| gameOver | 服务器→客户端 | 游戏结束 |
|
||||||
|
| error | 服务器→客户端 | 错误消息 |
|
||||||
|
|
||||||
|
### 准备
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ready",
|
||||||
|
"playerId": "xxx",
|
||||||
|
"roomId": "xxx",
|
||||||
|
"data": { "ready": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当所有玩家都准备后,游戏自动开始。
|
||||||
|
|
||||||
|
### 出牌
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "play",
|
||||||
|
"playerId": "xxx",
|
||||||
|
"roomId": "xxx",
|
||||||
|
"data": {
|
||||||
|
"cards": [
|
||||||
|
{ "suit": 0, "value": 14 },
|
||||||
|
{ "suit": 1, "value": 14 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不出
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pass",
|
||||||
|
"playerId": "xxx",
|
||||||
|
"roomId": "xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意: 只能在上家出牌后才能"不出",自己最后出牌时不能"不出"。
|
||||||
|
|
||||||
|
### 聊天
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "chat",
|
||||||
|
"playerId": "xxx",
|
||||||
|
"roomId": "xxx",
|
||||||
|
"data": "消息内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 游戏状态 (服务器推送)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "state",
|
||||||
|
"data": {
|
||||||
|
"id": "a1b2c3d4e5f6g7h8",
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"id": "xxx",
|
||||||
|
"name": "玩家1",
|
||||||
|
"cards": [...],
|
||||||
|
"cardCount": 5,
|
||||||
|
"isReady": true,
|
||||||
|
"isOnline": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentTurn": 0,
|
||||||
|
"state": 1,
|
||||||
|
"lastPlay": {
|
||||||
|
"playerId": "xxx",
|
||||||
|
"cards": [...],
|
||||||
|
"cardType": 2
|
||||||
|
},
|
||||||
|
"roundCount": 1,
|
||||||
|
"maxPlayers": 4
|
||||||
|
},
|
||||||
|
"timestamp": 1708123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意: 只有当前玩家能看到自己的手牌(cards),其他玩家只能看到牌数(cardCount)。
|
||||||
|
|
||||||
|
### 游戏结束 (服务器推送)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "gameOver",
|
||||||
|
"data": {
|
||||||
|
"winnerId": "xxx"
|
||||||
|
},
|
||||||
|
"timestamp": 1708123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据类型
|
||||||
|
|
||||||
|
### Card (牌)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| suit | int | 花色 |
|
||||||
|
| value | int | 点数 |
|
||||||
|
|
||||||
|
花色值:
|
||||||
|
- 0: 红桃 ♥
|
||||||
|
- 1: 方块 ♦
|
||||||
|
- 2: 梅花 ♣
|
||||||
|
- 3: 黑桃 ♠
|
||||||
|
- 4: 王 (Joker)
|
||||||
|
- 5: 超人强 (Super)
|
||||||
|
|
||||||
|
点数值:
|
||||||
|
- 3-10: 对应点数
|
||||||
|
- 11: J
|
||||||
|
- 12: Q
|
||||||
|
- 13: K
|
||||||
|
- 14: A
|
||||||
|
- 15: 2
|
||||||
|
- 16: 小王
|
||||||
|
- 17: 大王
|
||||||
|
- 18: 超人强
|
||||||
|
|
||||||
|
### CardType (牌型)
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 0 | 无效 |
|
||||||
|
| 1 | 单牌 |
|
||||||
|
| 2 | 对子 |
|
||||||
|
| 3 | 三张 (未使用) |
|
||||||
|
| 4 | 三带一 |
|
||||||
|
| 5 | 三带对 (未使用) |
|
||||||
|
| 6 | 顺子 |
|
||||||
|
| 7 | 连对 |
|
||||||
|
| 8 | 炸弹 |
|
||||||
|
| 9 | 火箭 |
|
||||||
|
|
||||||
|
### RoomState (房间状态)
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 0 | 等待中 |
|
||||||
|
| 1 | 游戏中 |
|
||||||
|
| 2 | 已结束 |
|
||||||
101
README.md
Normal file
101
README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 斗地主残局版 - 在线对战平台
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
斗地主残局版(斗残)是创造性转化自原版斗地主的在线对战游戏。
|
||||||
|
|
||||||
|
### 特殊规则
|
||||||
|
|
||||||
|
1. **新增牌 - 超人强**: 作为第55张牌,是最大单牌,可管一切单牌,不可组合出
|
||||||
|
2. **人数灵活**: 最少2人,最多10人
|
||||||
|
3. **发牌规则**: 每人初始5张牌,一轮结束后每人摸一张牌
|
||||||
|
4. **简化牌型**: 三张可成顺子,三张可成炸弹,两对可成连对
|
||||||
|
5. **无地主**: 不设置地主,上一轮获胜者先出牌
|
||||||
|
|
||||||
|
### 牌型说明
|
||||||
|
|
||||||
|
| 牌型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 单牌 | 任意一张牌 |
|
||||||
|
| 对子 | 两张相同点数的牌 |
|
||||||
|
| 顺子 | 三张或以上连续点数的牌(不含2和王) |
|
||||||
|
| 连对 | 两对或以上连续点数的对子(不含2和王) |
|
||||||
|
| 三带一 | 三张相同点数+任意一张 |
|
||||||
|
| 炸弹 | 三张或以上相同点数的牌 |
|
||||||
|
| 火箭 | 小王+大王 |
|
||||||
|
|
||||||
|
### 牌型大小
|
||||||
|
|
||||||
|
超人强 > 大王 > 小王 > 2 > A > K > Q > J > 10 > 9 > 8 > 7 > 6 > 5 > 4 > 3
|
||||||
|
|
||||||
|
炸弹可管任何非炸弹牌型,火箭最大。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 后端: Go 1.21
|
||||||
|
- 前端: HTML + CSS + JavaScript
|
||||||
|
- 通信: WebSocket
|
||||||
|
- 代理: Nginx
|
||||||
|
- 部署: Docker Compose
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
doudizhu-server/
|
||||||
|
├── app/ # Go后端
|
||||||
|
│ ├── cmd/main.go # 程序入口
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ ├── game/ # 游戏逻辑
|
||||||
|
│ │ ├── handlers/ # HTTP处理器
|
||||||
|
│ │ └── ws/ # WebSocket处理
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── go.mod
|
||||||
|
├── nginx/
|
||||||
|
│ ├── nginx.conf # Nginx配置
|
||||||
|
│ └── html/ # 静态文件
|
||||||
|
├── compose.yaml
|
||||||
|
├── README.md
|
||||||
|
└── API.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doudizhu-server
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问
|
||||||
|
|
||||||
|
- 本机: http://localhost
|
||||||
|
- 局域网: http://<服务器IP>
|
||||||
|
|
||||||
|
### 游戏流程
|
||||||
|
|
||||||
|
1. 输入昵称,创建房间或输入房间号加入
|
||||||
|
2. 所有玩家点击"准备"
|
||||||
|
3. 游戏自动开始,每人发5张牌
|
||||||
|
4. 轮流出牌或选择"不出"
|
||||||
|
5. 一轮结束后每人摸一张牌
|
||||||
|
6. 先出完所有手牌者获胜
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doudizhu-server/app
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd
|
||||||
|
# 另开终端启动nginx或直接访问http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doudizhu-server
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
15
app/Dockerfile
Normal file
15
app/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
WORKDIR /app/cmd
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /server .
|
||||||
|
|
||||||
|
FROM alpine:3.18
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /server .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./server"]
|
||||||
39
app/cmd/main.go
Normal file
39
app/cmd/main.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"doudizhu-server/internal/game"
|
||||||
|
"doudizhu-server/internal/handlers"
|
||||||
|
"doudizhu-server/internal/ws"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
gameMgr := game.NewGameManager()
|
||||||
|
hub := ws.NewHub(gameMgr)
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
h := handlers.NewHandler(gameMgr, hub)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/rooms", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" {
|
||||||
|
h.CreateRoom(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/rooms/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" {
|
||||||
|
h.JoinRoom(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/ws", h.WebSocket)
|
||||||
|
|
||||||
|
log.Println("App server starting on :8080")
|
||||||
|
if err := http.ListenAndServe(":8080", mux); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/go.mod
Normal file
7
app/go.mod
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module doudizhu-server
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.1
|
||||||
|
|
||||||
|
require golang.org/x/net v0.17.0 // indirect
|
||||||
4
app/go.sum
Normal file
4
app/go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
|
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
34
app/internal/game/errors.go
Normal file
34
app/internal/game/errors.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"doudizhu-server/internal/models"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRoomNotFound = errors.New("room not found")
|
||||||
|
ErrRoomFull = errors.New("room is full")
|
||||||
|
ErrGameStarted = errors.New("game already started")
|
||||||
|
ErrGameNotStarted = errors.New("game not started")
|
||||||
|
ErrPlayerNotFound = errors.New("player not found")
|
||||||
|
ErrNotEnoughPlayers = errors.New("not enough players")
|
||||||
|
ErrPlayerNotReady = errors.New("player not ready")
|
||||||
|
ErrNotYourTurn = errors.New("not your turn")
|
||||||
|
ErrCardsNotInHand = errors.New("cards not in hand")
|
||||||
|
ErrInvalidCardType = errors.New("invalid card type")
|
||||||
|
ErrCannotBeat = errors.New("cannot beat last play")
|
||||||
|
ErrCannotPass = errors.New("cannot pass")
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cardKey(c models.Card) string {
|
||||||
|
return fmt.Sprintf("%d_%d", c.Suit, c.Value)
|
||||||
|
}
|
||||||
563
app/internal/game/game.go
Normal file
563
app/internal/game/game.go
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"doudizhu-server/internal/models"
|
||||||
|
"math/rand"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CardLogic struct{}
|
||||||
|
|
||||||
|
func NewCardLogic() *CardLogic {
|
||||||
|
return &CardLogic{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) CreateDeck() []models.Card {
|
||||||
|
deck := make([]models.Card, 0, 55)
|
||||||
|
suits := []models.Suit{models.SuitHeart, models.SuitDiamond, models.SuitClub, models.SuitSpade}
|
||||||
|
for _, suit := range suits {
|
||||||
|
for value := 3; value <= 15; value++ {
|
||||||
|
deck = append(deck, models.Card{Suit: suit, Value: value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deck = append(deck, models.Card{Suit: models.SuitJoker, Value: 16})
|
||||||
|
deck = append(deck, models.Card{Suit: models.SuitJoker, Value: 17})
|
||||||
|
deck = append(deck, models.Card{Suit: models.SuitSuper, Value: 18})
|
||||||
|
return deck
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) Shuffle(deck []models.Card) {
|
||||||
|
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
r.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) SortCards(cards []models.Card) {
|
||||||
|
sort.Slice(cards, func(i, j int) bool {
|
||||||
|
if cards[i].Value != cards[j].Value {
|
||||||
|
return cards[i].Value > cards[j].Value
|
||||||
|
}
|
||||||
|
return cards[i].Suit < cards[j].Suit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) GetCardType(cards []models.Card) models.CardType {
|
||||||
|
n := len(cards)
|
||||||
|
if n == 0 {
|
||||||
|
return models.CardTypeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if cl.isRocket(cards) {
|
||||||
|
return models.CardTypeRocket
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := make([]models.Card, n)
|
||||||
|
copy(sorted, cards)
|
||||||
|
cl.SortCards(sorted)
|
||||||
|
counts := cl.getValueCounts(sorted)
|
||||||
|
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
return models.CardTypeSingle
|
||||||
|
case 2:
|
||||||
|
if len(counts) == 1 {
|
||||||
|
return models.CardTypePair
|
||||||
|
}
|
||||||
|
return models.CardTypeInvalid
|
||||||
|
case 3:
|
||||||
|
if len(counts) == 1 {
|
||||||
|
return models.CardTypeBomb
|
||||||
|
}
|
||||||
|
if cl.isStraight(sorted) {
|
||||||
|
return models.CardTypeStraight
|
||||||
|
}
|
||||||
|
return models.CardTypeInvalid
|
||||||
|
case 4:
|
||||||
|
if len(counts) == 1 {
|
||||||
|
return models.CardTypeBomb
|
||||||
|
}
|
||||||
|
if len(counts) == 2 {
|
||||||
|
for _, c := range counts {
|
||||||
|
if c == 3 {
|
||||||
|
return models.CardTypeTripleOne
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cl.isStraight(sorted) {
|
||||||
|
return models.CardTypeStraight
|
||||||
|
}
|
||||||
|
if cl.isDoubleStraight(sorted, counts) {
|
||||||
|
return models.CardTypeDoubleStraight
|
||||||
|
}
|
||||||
|
return models.CardTypeInvalid
|
||||||
|
default:
|
||||||
|
if cl.isStraight(sorted) {
|
||||||
|
return models.CardTypeStraight
|
||||||
|
}
|
||||||
|
if cl.isDoubleStraight(sorted, counts) {
|
||||||
|
return models.CardTypeDoubleStraight
|
||||||
|
}
|
||||||
|
hasTriple := false
|
||||||
|
for _, c := range counts {
|
||||||
|
if c >= 3 {
|
||||||
|
hasTriple = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasTriple && (n == 4 || n == 5) {
|
||||||
|
return models.CardTypeTripleOne
|
||||||
|
}
|
||||||
|
return models.CardTypeInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) isRocket(cards []models.Card) bool {
|
||||||
|
if len(cards) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasSmall, hasBig := false, false
|
||||||
|
for _, c := range cards {
|
||||||
|
if c.Suit == models.SuitJoker {
|
||||||
|
if c.Value == 16 {
|
||||||
|
hasSmall = true
|
||||||
|
} else if c.Value == 17 {
|
||||||
|
hasBig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasSmall && hasBig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) isStraight(cards []models.Card) bool {
|
||||||
|
if len(cards) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range cards {
|
||||||
|
if c.Value >= 15 || c.Suit == models.SuitJoker || c.Suit == models.SuitSuper {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := make([]int, len(cards))
|
||||||
|
for i, c := range cards {
|
||||||
|
values[i] = c.Value
|
||||||
|
}
|
||||||
|
sort.Ints(values)
|
||||||
|
for i := 1; i < len(values); i++ {
|
||||||
|
if values[i] != values[i-1]+1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) isDoubleStraight(cards []models.Card, counts map[int]int) bool {
|
||||||
|
if len(cards) < 4 || len(cards)%2 != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pairs := make([]int, 0)
|
||||||
|
for v, c := range counts {
|
||||||
|
if c != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v >= 15 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pairs = append(pairs, v)
|
||||||
|
}
|
||||||
|
sort.Ints(pairs)
|
||||||
|
for i := 1; i < len(pairs); i++ {
|
||||||
|
if pairs[i] != pairs[i-1]+1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) getValueCounts(cards []models.Card) map[int]int {
|
||||||
|
counts := make(map[int]int)
|
||||||
|
for _, c := range cards {
|
||||||
|
counts[c.Value]++
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) CanPlay(cards []models.Card, lastPlay *models.PlayRecord) bool {
|
||||||
|
cardType := cl.GetCardType(cards)
|
||||||
|
if cardType == models.CardTypeInvalid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lastPlay == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cardType == models.CardTypeRocket {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cardType == models.CardTypeBomb {
|
||||||
|
if lastPlay.CardType == models.CardTypeBomb {
|
||||||
|
return cl.getMainValue(cards) > cl.getMainValue(lastPlay.Cards)
|
||||||
|
}
|
||||||
|
if lastPlay.CardType == models.CardTypeRocket {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastPlay.CardType == models.CardTypeBomb || lastPlay.CardType == models.CardTypeRocket {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cardType != lastPlay.CardType || len(cards) != len(lastPlay.Cards) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cl.getMainValue(cards) > cl.getMainValue(lastPlay.Cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CardLogic) getMainValue(cards []models.Card) int {
|
||||||
|
counts := cl.getValueCounts(cards)
|
||||||
|
maxValue, maxCount := 0, 0
|
||||||
|
for v, c := range counts {
|
||||||
|
if c > maxCount || (c == maxCount && v > maxValue) {
|
||||||
|
maxCount, maxValue = c, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameManager struct {
|
||||||
|
rooms map[string]*models.Room
|
||||||
|
players map[string]*models.Player
|
||||||
|
deck map[string][]models.Card
|
||||||
|
discard map[string][]models.Card
|
||||||
|
mu sync.RWMutex
|
||||||
|
cl *CardLogic
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGameManager() *GameManager {
|
||||||
|
return &GameManager{
|
||||||
|
rooms: make(map[string]*models.Room),
|
||||||
|
players: make(map[string]*models.Player),
|
||||||
|
deck: make(map[string][]models.Card),
|
||||||
|
discard: make(map[string][]models.Card),
|
||||||
|
cl: NewCardLogic(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
roomID := generateID()
|
||||||
|
playerID := generateID()
|
||||||
|
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
|
||||||
|
room := &models.Room{
|
||||||
|
ID: roomID,
|
||||||
|
Players: []*models.Player{player},
|
||||||
|
MaxPlayers: maxPlayers,
|
||||||
|
State: models.RoomStateWaiting,
|
||||||
|
}
|
||||||
|
|
||||||
|
deck := gm.cl.CreateDeck()
|
||||||
|
gm.cl.Shuffle(deck)
|
||||||
|
|
||||||
|
gm.rooms[roomID] = room
|
||||||
|
gm.players[playerID] = player
|
||||||
|
gm.deck[roomID] = deck
|
||||||
|
gm.discard[roomID] = make([]models.Card, 0)
|
||||||
|
|
||||||
|
return room, player
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *models.Player, error) {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, ErrRoomNotFound
|
||||||
|
}
|
||||||
|
if len(room.Players) >= room.MaxPlayers {
|
||||||
|
return nil, nil, ErrRoomFull
|
||||||
|
}
|
||||||
|
if room.State == models.RoomStatePlaying {
|
||||||
|
return nil, nil, ErrGameStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := generateID()
|
||||||
|
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
|
||||||
|
room.Players = append(room.Players, player)
|
||||||
|
gm.players[playerID] = player
|
||||||
|
|
||||||
|
return room, player, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) LeaveRoom(roomID, playerID string) error {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return ErrRoomNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range room.Players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
room.Players = append(room.Players[:i], room.Players[i+1:]...)
|
||||||
|
delete(gm.players, playerID)
|
||||||
|
if len(room.Players) == 0 {
|
||||||
|
delete(gm.rooms, roomID)
|
||||||
|
delete(gm.deck, roomID)
|
||||||
|
delete(gm.discard, roomID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrPlayerNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return ErrRoomNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if room.State == models.RoomStatePlaying {
|
||||||
|
return ErrGameStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
p.IsReady = ready
|
||||||
|
if room.State == models.RoomStateFinished {
|
||||||
|
room.State = models.RoomStateWaiting
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrPlayerNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) StartGame(roomID string) error {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return ErrRoomNotFound
|
||||||
|
}
|
||||||
|
if len(room.Players) < 2 {
|
||||||
|
return ErrNotEnoughPlayers
|
||||||
|
}
|
||||||
|
if room.State == models.RoomStatePlaying {
|
||||||
|
return ErrGameStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range room.Players {
|
||||||
|
p.Cards = make([]models.Card, 0)
|
||||||
|
p.CardCount = 0
|
||||||
|
p.IsReady = false
|
||||||
|
}
|
||||||
|
|
||||||
|
deck := gm.cl.CreateDeck()
|
||||||
|
gm.cl.Shuffle(deck)
|
||||||
|
gm.deck[roomID] = deck
|
||||||
|
gm.discard[roomID] = make([]models.Card, 0)
|
||||||
|
|
||||||
|
room.State = models.RoomStatePlaying
|
||||||
|
room.RoundCount = 1
|
||||||
|
room.CurrentTurn = 0
|
||||||
|
room.LastPlay = nil
|
||||||
|
room.LastWinner = ""
|
||||||
|
|
||||||
|
gm.dealCards(roomID, 5)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) dealCards(roomID string, count int) {
|
||||||
|
room := gm.rooms[roomID]
|
||||||
|
deck := gm.deck[roomID]
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if len(deck) == 0 {
|
||||||
|
deck = gm.discard[roomID]
|
||||||
|
gm.cl.Shuffle(deck)
|
||||||
|
gm.discard[roomID] = make([]models.Card, 0)
|
||||||
|
}
|
||||||
|
if len(deck) > 0 {
|
||||||
|
p.Cards = append(p.Cards, deck[0])
|
||||||
|
deck = deck[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gm.deck[roomID] = deck
|
||||||
|
|
||||||
|
for _, p := range room.Players {
|
||||||
|
gm.cl.SortCards(p.Cards)
|
||||||
|
p.CardCount = len(p.Cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) PlayCards(roomID, playerID string, cards []models.Card) error {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return ErrRoomNotFound
|
||||||
|
}
|
||||||
|
if room.State != models.RoomStatePlaying {
|
||||||
|
return ErrGameNotStarted
|
||||||
|
}
|
||||||
|
if room.Players[room.CurrentTurn].ID != playerID {
|
||||||
|
return ErrNotYourTurn
|
||||||
|
}
|
||||||
|
|
||||||
|
player := room.Players[room.CurrentTurn]
|
||||||
|
if !gm.hasCards(player, cards) {
|
||||||
|
return ErrCardsNotInHand
|
||||||
|
}
|
||||||
|
|
||||||
|
cardType := gm.cl.GetCardType(cards)
|
||||||
|
if cardType == models.CardTypeInvalid {
|
||||||
|
return ErrInvalidCardType
|
||||||
|
}
|
||||||
|
if !gm.cl.CanPlay(cards, room.LastPlay) {
|
||||||
|
return ErrCannotBeat
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.removeCards(player, cards)
|
||||||
|
player.CardCount = len(player.Cards)
|
||||||
|
|
||||||
|
room.LastPlay = &models.PlayRecord{
|
||||||
|
PlayerID: playerID,
|
||||||
|
Cards: cards,
|
||||||
|
CardType: cardType,
|
||||||
|
}
|
||||||
|
gm.discard[roomID] = append(gm.discard[roomID], cards...)
|
||||||
|
|
||||||
|
if len(player.Cards) == 0 {
|
||||||
|
room.LastWinner = playerID
|
||||||
|
room.State = models.RoomStateFinished
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.nextTurn(room)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) Pass(roomID, playerID string) error {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return ErrRoomNotFound
|
||||||
|
}
|
||||||
|
if room.State != models.RoomStatePlaying {
|
||||||
|
return ErrGameNotStarted
|
||||||
|
}
|
||||||
|
if room.Players[room.CurrentTurn].ID != playerID {
|
||||||
|
return ErrNotYourTurn
|
||||||
|
}
|
||||||
|
if room.LastPlay == nil || room.LastPlay.PlayerID == playerID {
|
||||||
|
return ErrCannotPass
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.nextTurn(room)
|
||||||
|
|
||||||
|
if room.Players[room.CurrentTurn].ID == room.LastPlay.PlayerID {
|
||||||
|
room.LastPlay = nil
|
||||||
|
gm.dealCards(roomID, 1)
|
||||||
|
room.RoundCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) nextTurn(room *models.Room) {
|
||||||
|
room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) hasCards(player *models.Player, cards []models.Card) bool {
|
||||||
|
hand := make(map[string]int)
|
||||||
|
for _, c := range player.Cards {
|
||||||
|
hand[cardKey(c)]++
|
||||||
|
}
|
||||||
|
for _, c := range cards {
|
||||||
|
k := cardKey(c)
|
||||||
|
if hand[k] == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hand[k]--
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) removeCards(player *models.Player, cards []models.Card) {
|
||||||
|
toRemove := make(map[string]int)
|
||||||
|
for _, c := range cards {
|
||||||
|
toRemove[cardKey(c)]++
|
||||||
|
}
|
||||||
|
newCards := make([]models.Card, 0)
|
||||||
|
removed := make(map[string]int)
|
||||||
|
for _, c := range player.Cards {
|
||||||
|
k := cardKey(c)
|
||||||
|
if removed[k] < toRemove[k] {
|
||||||
|
removed[k]++
|
||||||
|
} else {
|
||||||
|
newCards = append(newCards, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.Cards = newCards
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) {
|
||||||
|
gm.mu.RLock()
|
||||||
|
defer gm.mu.RUnlock()
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrRoomNotFound
|
||||||
|
}
|
||||||
|
return room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
|
||||||
|
gm.mu.RLock()
|
||||||
|
defer gm.mu.RUnlock()
|
||||||
|
|
||||||
|
room, ok := gm.rooms[roomID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
players := make([]*models.Player, len(room.Players))
|
||||||
|
for i, p := range room.Players {
|
||||||
|
pp := &models.Player{
|
||||||
|
ID: p.ID,
|
||||||
|
Name: p.Name,
|
||||||
|
IsReady: p.IsReady,
|
||||||
|
IsOnline: p.IsOnline,
|
||||||
|
CardCount: p.CardCount,
|
||||||
|
}
|
||||||
|
if p.ID == playerID {
|
||||||
|
pp.Cards = p.Cards
|
||||||
|
}
|
||||||
|
players[i] = pp
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.Room{
|
||||||
|
ID: room.ID,
|
||||||
|
Players: players,
|
||||||
|
CurrentTurn: room.CurrentTurn,
|
||||||
|
State: room.State,
|
||||||
|
LastPlay: room.LastPlay,
|
||||||
|
LastWinner: room.LastWinner,
|
||||||
|
RoundCount: room.RoundCount,
|
||||||
|
MaxPlayers: room.MaxPlayers,
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/internal/handlers/handlers.go
Normal file
96
app/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"doudizhu-server/internal/game"
|
||||||
|
"doudizhu-server/internal/models"
|
||||||
|
"doudizhu-server/internal/ws"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
GameMgr *game.GameManager
|
||||||
|
Hub *ws.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(gameMgr *game.GameManager, hub *ws.Hub) *Handler {
|
||||||
|
return &Handler{GameMgr: gameMgr, Hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.CreateRoomRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PlayerName == "" {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "player name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MaxPlayers < 2 || req.MaxPlayers > 10 {
|
||||||
|
req.MaxPlayers = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
room, player := h.GameMgr.CreateRoom(req.PlayerName, req.MaxPlayers)
|
||||||
|
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
roomID := strings.ToLower(extractRoomID(r.URL.Path))
|
||||||
|
if roomID == "" {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "room id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.JoinRoomRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PlayerName == "" {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "player name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room, player, err := h.GameMgr.JoinRoom(roomID, req.PlayerName)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws.ServeWs(h.Hub, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) jsonSuccess(w http.ResponseWriter, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.ApiResponse{
|
||||||
|
Status: 200,
|
||||||
|
Code: 0,
|
||||||
|
Message: "success",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) jsonError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(models.ApiResponse{
|
||||||
|
Status: status,
|
||||||
|
Code: 1,
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRoomID(path string) string {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
103
app/internal/models/models.go
Normal file
103
app/internal/models/models.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type Suit int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SuitHeart Suit = iota
|
||||||
|
SuitDiamond
|
||||||
|
SuitClub
|
||||||
|
SuitSpade
|
||||||
|
SuitJoker
|
||||||
|
SuitSuper
|
||||||
|
)
|
||||||
|
|
||||||
|
type Card struct {
|
||||||
|
Suit Suit `json:"suit"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CardTypeInvalid CardType = iota
|
||||||
|
CardTypeSingle
|
||||||
|
CardTypePair
|
||||||
|
CardTypeTriple
|
||||||
|
CardTypeTripleOne
|
||||||
|
CardTypeTriplePair
|
||||||
|
CardTypeStraight
|
||||||
|
CardTypeDoubleStraight
|
||||||
|
CardTypeBomb
|
||||||
|
CardTypeRocket
|
||||||
|
)
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cards []Card `json:"cards,omitempty"`
|
||||||
|
CardCount int `json:"cardCount"`
|
||||||
|
IsReady bool `json:"isReady"`
|
||||||
|
IsOnline bool `json:"isOnline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoomStateWaiting RoomState = iota
|
||||||
|
RoomStatePlaying
|
||||||
|
RoomStateFinished
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlayRecord struct {
|
||||||
|
PlayerID string `json:"playerId"`
|
||||||
|
Cards []Card `json:"cards"`
|
||||||
|
CardType CardType `json:"cardType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Players []*Player `json:"players"`
|
||||||
|
CurrentTurn int `json:"currentTurn"`
|
||||||
|
State RoomState `json:"state"`
|
||||||
|
LastPlay *PlayRecord `json:"lastPlay,omitempty"`
|
||||||
|
LastWinner string `json:"lastWinner,omitempty"`
|
||||||
|
RoundCount int `json:"roundCount"`
|
||||||
|
MaxPlayers int `json:"maxPlayers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MsgTypeJoin MessageType = "join"
|
||||||
|
MsgTypeLeave MessageType = "leave"
|
||||||
|
MsgTypeReady MessageType = "ready"
|
||||||
|
MsgTypePlay MessageType = "play"
|
||||||
|
MsgTypePass MessageType = "pass"
|
||||||
|
MsgTypeState MessageType = "state"
|
||||||
|
MsgTypeError MessageType = "error"
|
||||||
|
MsgTypeChat MessageType = "chat"
|
||||||
|
MsgTypeGameOver MessageType = "gameOver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Type MessageType `json:"type"`
|
||||||
|
PlayerID string `json:"playerId,omitempty"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiResponse struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRoomRequest struct {
|
||||||
|
PlayerName string `json:"playerName"`
|
||||||
|
MaxPlayers int `json:"maxPlayers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JoinRoomRequest struct {
|
||||||
|
PlayerName string `json:"playerName"`
|
||||||
|
}
|
||||||
288
app/internal/ws/hub.go
Normal file
288
app/internal/ws/hub.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"doudizhu-server/internal/game"
|
||||||
|
"doudizhu-server/internal/models"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
ID string
|
||||||
|
RoomID string
|
||||||
|
Conn *websocket.Conn
|
||||||
|
Send chan []byte
|
||||||
|
Hub *Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
Clients map[string]*Client
|
||||||
|
Register chan *Client
|
||||||
|
Unregister chan *Client
|
||||||
|
GameMgr *game.GameManager
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub(gameMgr *game.GameManager) *Hub {
|
||||||
|
return &Hub{
|
||||||
|
Clients: make(map[string]*Client),
|
||||||
|
Register: make(chan *Client, 64),
|
||||||
|
Unregister: make(chan *Client, 64),
|
||||||
|
GameMgr: gameMgr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-h.Register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.Clients[c.ID] = c
|
||||||
|
h.mu.Unlock()
|
||||||
|
case c := <-h.Unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.Clients[c.ID]; ok {
|
||||||
|
delete(h.Clients, c.ID)
|
||||||
|
close(c.Send)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if c.RoomID != "" {
|
||||||
|
h.GameMgr.LeaveRoom(c.RoomID, c.ID)
|
||||||
|
h.broadcastRoomState(c.RoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReadPump() {
|
||||||
|
defer func() {
|
||||||
|
c.Hub.Unregister <- c
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
c.Conn.SetPongHandler(func(string) error {
|
||||||
|
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for {
|
||||||
|
_, data, err := c.Conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.handleMessage(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) WritePump() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-c.Send:
|
||||||
|
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if !ok {
|
||||||
|
c.Conn.WriteMessage(websocket.CloseMessage, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.Conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleMessage(data []byte) {
|
||||||
|
var msg models.Message
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.Timestamp = time.Now().Unix()
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case models.MsgTypeReady:
|
||||||
|
ready := true
|
||||||
|
if m, ok := msg.Data.(map[string]interface{}); ok {
|
||||||
|
if r, ok := m["ready"].(bool); ok {
|
||||||
|
ready = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Hub.GameMgr.SetReady(c.RoomID, c.ID, ready)
|
||||||
|
c.Hub.broadcastRoomState(c.RoomID)
|
||||||
|
c.tryStartGame()
|
||||||
|
|
||||||
|
case models.MsgTypePlay:
|
||||||
|
cards := c.parseCards(msg.Data)
|
||||||
|
if len(cards) == 0 {
|
||||||
|
c.sendError("invalid cards")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.Hub.GameMgr.PlayCards(c.RoomID, c.ID, cards); err != nil {
|
||||||
|
c.sendError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room, _ := c.Hub.GameMgr.GetRoom(c.RoomID)
|
||||||
|
if room != nil && room.State == models.RoomStateFinished {
|
||||||
|
c.Hub.broadcastToRoom(c.RoomID, models.Message{
|
||||||
|
Type: models.MsgTypeGameOver,
|
||||||
|
Data: map[string]string{"winnerId": room.LastWinner},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
c.Hub.broadcastRoomState(c.RoomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
case models.MsgTypePass:
|
||||||
|
if err := c.Hub.GameMgr.Pass(c.RoomID, c.ID); err != nil {
|
||||||
|
c.sendError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Hub.broadcastRoomState(c.RoomID)
|
||||||
|
|
||||||
|
case models.MsgTypeChat:
|
||||||
|
c.Hub.broadcastToRoom(c.RoomID, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) tryStartGame() {
|
||||||
|
room, err := c.Hub.GameMgr.GetRoom(c.RoomID)
|
||||||
|
if err != nil || len(room.Players) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if !p.IsReady {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.Hub.GameMgr.StartGame(c.RoomID); err == nil {
|
||||||
|
c.Hub.broadcastRoomState(c.RoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseCards(data interface{}) []models.Card {
|
||||||
|
cards := make([]models.Card, 0)
|
||||||
|
m, ok := data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
arr, ok := m["cards"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
for _, item := range arr {
|
||||||
|
if cm, ok := item.(map[string]interface{}); ok {
|
||||||
|
card := models.Card{}
|
||||||
|
if suit, ok := cm["suit"].(float64); ok {
|
||||||
|
card.Suit = models.Suit(int(suit))
|
||||||
|
}
|
||||||
|
if value, ok := cm["value"].(float64); ok {
|
||||||
|
card.Value = int(value)
|
||||||
|
}
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendError(msg string) {
|
||||||
|
data, _ := json.Marshal(models.Message{
|
||||||
|
Type: models.MsgTypeError,
|
||||||
|
Data: msg,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case c.Send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) broadcastToRoom(roomID string, msg models.Message) {
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, c := range h.Clients {
|
||||||
|
if c.RoomID == roomID {
|
||||||
|
select {
|
||||||
|
case c.Send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) broadcastRoomState(roomID string) {
|
||||||
|
h.mu.RLock()
|
||||||
|
clients := make([]*Client, 0)
|
||||||
|
for _, c := range h.Clients {
|
||||||
|
if c.RoomID == roomID {
|
||||||
|
clients = append(clients, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, c := range clients {
|
||||||
|
state := h.GameMgr.GetRoomState(roomID, c.ID)
|
||||||
|
if state != nil {
|
||||||
|
data, _ := json.Marshal(models.Message{
|
||||||
|
Type: models.MsgTypeState,
|
||||||
|
Data: state,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case c.Send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||||
|
roomID := strings.ToLower(r.URL.Query().Get("roomId"))
|
||||||
|
playerID := r.URL.Query().Get("playerId")
|
||||||
|
if roomID == "" || playerID == "" {
|
||||||
|
http.Error(w, "missing params", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("ws upgrade error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
ID: playerID,
|
||||||
|
RoomID: roomID,
|
||||||
|
Conn: conn,
|
||||||
|
Send: make(chan []byte, 256),
|
||||||
|
Hub: hub,
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.Clients[client.ID] = client
|
||||||
|
hub.mu.Unlock()
|
||||||
|
|
||||||
|
go client.WritePump()
|
||||||
|
go client.ReadPump()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
hub.broadcastRoomState(roomID)
|
||||||
|
}
|
||||||
19
compose.yaml
Normal file
19
compose.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: ./app
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./nginx/html:/usr/share/nginx/html:ro
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
57
nginx/html/css/style.css
Normal file
57
nginx/html/css/style.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#1a1a2e,#16213e);min-height:100vh;color:#fff}
|
||||||
|
#app{max-width:1000px;margin:0 auto;padding:20px}
|
||||||
|
.screen{animation:fadeIn .2s}
|
||||||
|
.hidden{display:none!important}
|
||||||
|
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||||
|
h1{text-align:center;font-size:2rem;color:#ffd700;margin-bottom:20px}
|
||||||
|
h2{text-align:center;color:#ffd700;margin-bottom:15px}
|
||||||
|
h3{color:#ffd700;margin-bottom:10px}
|
||||||
|
.form{background:rgba(255,255,255,.1);padding:25px;border-radius:12px;max-width:350px;margin:0 auto 20px}
|
||||||
|
.form input,.form select{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px}
|
||||||
|
.form input::placeholder{color:#aaa}
|
||||||
|
.btn{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-size:15px;font-weight:600;transition:.2s}
|
||||||
|
.btn:hover{transform:translateY(-1px)}
|
||||||
|
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
||||||
|
.btn.primary{background:linear-gradient(135deg,#ffd700,#ff8c00);color:#1a1a2e}
|
||||||
|
.btn.danger{background:linear-gradient(135deg,#ff4757,#c0392b)}
|
||||||
|
.btn.sm{padding:6px 12px;font-size:13px}
|
||||||
|
.join-row{display:flex;gap:8px;margin-top:10px}
|
||||||
|
.join-row input{flex:1}
|
||||||
|
.rules{background:rgba(255,255,255,.05);padding:15px;border-radius:8px;max-width:350px;margin:0 auto}
|
||||||
|
.rules ul{list-style:none}
|
||||||
|
.rules li{padding:5px 0;color:#ccc;font-size:14px}
|
||||||
|
.room-id{font-size:1.3rem;color:#ffd700;letter-spacing:1px}
|
||||||
|
#playerList{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:15px 0}
|
||||||
|
.player-card{background:rgba(255,255,255,.1);padding:12px 20px;border-radius:8px;text-align:center;min-width:100px}
|
||||||
|
.player-card.ready{background:rgba(0,255,0,.2);border:2px solid #0f0}
|
||||||
|
.player-card.current{background:rgba(255,215,0,.3);border:2px solid #ffd700}
|
||||||
|
.player-card .name{font-weight:600}
|
||||||
|
.player-card .status{font-size:12px;color:#aaa;margin-top:4px}
|
||||||
|
.actions{display:flex;justify-content:center;gap:10px;margin-top:15px}
|
||||||
|
.header{display:flex;justify-content:space-between;padding:8px 15px;background:rgba(0,0,0,.3);border-radius:8px;margin-bottom:15px;font-size:14px}
|
||||||
|
.others{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-bottom:15px;min-height:50px}
|
||||||
|
.play-area{min-height:100px;background:rgba(0,0,0,.2);border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:15px}
|
||||||
|
.last-play{text-align:center}
|
||||||
|
.last-play span{color:#ffd700;font-size:14px}
|
||||||
|
.my-area{background:rgba(0,0,0,.3);border-radius:10px;padding:15px}
|
||||||
|
.my-cards{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-bottom:12px;min-height:80px}
|
||||||
|
.card{width:50px;height:70px;background:linear-gradient(135deg,#fff,#f0f0f0);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;cursor:pointer;transition:.15s;user-select:none}
|
||||||
|
.card:hover{transform:translateY(-3px)}
|
||||||
|
.card.selected{transform:translateY(-12px);box-shadow:0 4px 12px rgba(0,0,0,.4);border:2px solid #ffd700}
|
||||||
|
.card.red{color:#e74c3c}
|
||||||
|
.card.black{color:#2d3436}
|
||||||
|
.card.super{background:linear-gradient(135deg,#ffd700,#ff8c00);color:#fff;font-size:12px}
|
||||||
|
.card.joker{background:linear-gradient(135deg,#9b59b6,#8e44ad);color:#fff;font-size:11px}
|
||||||
|
.card.sm{width:35px;height:50px;font-size:11px}
|
||||||
|
.chat{position:fixed;bottom:15px;right:15px;width:250px;background:rgba(0,0,0,.85);border-radius:8px;overflow:hidden}
|
||||||
|
#chatMsgs{height:150px;overflow-y:auto;padding:8px;font-size:13px}
|
||||||
|
#chatMsgs p{margin-bottom:5px}
|
||||||
|
#chatMsgs .sys{color:#888;font-style:italic}
|
||||||
|
#chatMsgs .pn{color:#ffd700}
|
||||||
|
.chat-input{display:flex;padding:8px;gap:6px;background:rgba(0,0,0,.3)}
|
||||||
|
.chat-input input{flex:1;padding:6px;border:none;border-radius:4px;background:rgba(255,255,255,.1);color:#fff;font-size:13px}
|
||||||
|
.modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center}
|
||||||
|
.modal-box{background:#16213e;padding:30px;border-radius:12px;text-align:center;border:2px solid #ffd700}
|
||||||
|
.modal-box h2{margin-bottom:20px}
|
||||||
|
@media(max-width:600px){#app{padding:10px}.card{width:42px;height:60px;font-size:12px}.chat{width:100%;bottom:0;right:0;border-radius:8px 8px 0 0}}
|
||||||
86
nginx/html/index.html
Normal file
86
nginx/html/index.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>斗地主残局版</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="lobby" class="screen">
|
||||||
|
<h1>斗地主残局版</h1>
|
||||||
|
<div class="form">
|
||||||
|
<input type="text" id="playerName" placeholder="昵称" maxlength="10">
|
||||||
|
<select id="maxPlayers">
|
||||||
|
<option value="2">2人</option>
|
||||||
|
<option value="3">3人</option>
|
||||||
|
<option value="4" selected>4人</option>
|
||||||
|
<option value="5">5人</option>
|
||||||
|
<option value="6">6人</option>
|
||||||
|
</select>
|
||||||
|
<button id="createBtn" class="btn primary">创建房间</button>
|
||||||
|
<div class="join-row">
|
||||||
|
<input type="text" id="roomIdInput" placeholder="房间号">
|
||||||
|
<button id="joinBtn" class="btn">加入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rules">
|
||||||
|
<h3>规则</h3>
|
||||||
|
<ul>
|
||||||
|
<li>新增"超人强"为最大单牌</li>
|
||||||
|
<li>每人初始5张牌</li>
|
||||||
|
<li>三张可成顺子/炸弹,两对可成连对</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="waiting" class="screen hidden">
|
||||||
|
<h2>等待中</h2>
|
||||||
|
<p>房间号: <span id="displayRoomId" class="room-id"></span></p>
|
||||||
|
<div id="playerList"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="readyBtn" class="btn primary">准备</button>
|
||||||
|
<button id="leaveBtn" class="btn danger">离开</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game" class="screen hidden">
|
||||||
|
<div class="header">
|
||||||
|
<span>第<span id="roundNum">1</span>轮</span>
|
||||||
|
<span id="turnInfo"></span>
|
||||||
|
</div>
|
||||||
|
<div id="others" class="others"></div>
|
||||||
|
<div id="playArea" class="play-area">
|
||||||
|
<div id="lastPlay" class="last-play hidden">
|
||||||
|
<span id="lastPlayer"></span>
|
||||||
|
<div id="lastCards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-area">
|
||||||
|
<div id="myCards" class="my-cards"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="playBtn" class="btn primary" disabled>出牌</button>
|
||||||
|
<button id="passBtn" class="btn" disabled>不出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gameOver" class="modal hidden">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2 id="winnerText"></h2>
|
||||||
|
<button id="againBtn" class="btn primary">再来一局</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat" class="chat">
|
||||||
|
<div id="chatMsgs"></div>
|
||||||
|
<div class="chat-input">
|
||||||
|
<input type="text" id="chatInput" placeholder="消息">
|
||||||
|
<button id="chatBtn" class="btn sm">发</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/js/game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
346
nginx/html/js/game.js
Normal file
346
nginx/html/js/game.js
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
let ws = null, playerId = '', roomId = '', state = null, selected = [];
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
function show(id) {
|
||||||
|
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
|
||||||
|
$(id).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chat(name, msg, sys) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.innerHTML = sys ? '<span class="sys">' + msg + '</span>' : '<span class="pn">' + name + ':</span> ' + msg;
|
||||||
|
$('chatMsgs').appendChild(p);
|
||||||
|
$('chatMsgs').scrollTop = 1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const name = $('playerName').value.trim();
|
||||||
|
if (!name) { alert('请输入昵称'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rooms', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value})
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0) {
|
||||||
|
roomId = d.data.roomId;
|
||||||
|
playerId = d.data.playerId;
|
||||||
|
connect();
|
||||||
|
} else {
|
||||||
|
alert(d.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('创建失败: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function join() {
|
||||||
|
const name = $('playerName').value.trim();
|
||||||
|
const rid = $('roomIdInput').value.trim().toLowerCase();
|
||||||
|
if (!name) { alert('请输入昵称'); return; }
|
||||||
|
if (!rid) { alert('请输入房间号'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rooms/' + rid + '/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({playerName: name})
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0) {
|
||||||
|
roomId = d.data.roomId;
|
||||||
|
playerId = d.data.playerId;
|
||||||
|
connect();
|
||||||
|
} else {
|
||||||
|
alert(d.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('加入失败: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(proto + '//' + location.host + '/api/ws?roomId=' + roomId + '&playerId=' + playerId);
|
||||||
|
ws.onopen = function() { chat('', '已连接', true); };
|
||||||
|
ws.onmessage = function(e) {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'state') render(msg.data);
|
||||||
|
else if (msg.type === 'gameOver') showGameOver(msg.data);
|
||||||
|
else if (msg.type === 'chat') chat(msg.data.playerName, msg.data.message);
|
||||||
|
else if (msg.type === 'error') chat('', msg.data, true);
|
||||||
|
};
|
||||||
|
ws.onerror = function() { chat('', '连接错误', true); };
|
||||||
|
ws.onclose = function() { chat('', '断开连接', true); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(type, data) {
|
||||||
|
if (ws && ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify({type: type, playerId: playerId, roomId: roomId, data: data}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(s) {
|
||||||
|
state = s;
|
||||||
|
$('displayRoomId').textContent = s.id;
|
||||||
|
$('roundNum').textContent = s.roundCount;
|
||||||
|
var me = null;
|
||||||
|
for (var i = 0; i < s.players.length; i++) {
|
||||||
|
if (s.players[i].id === playerId) { me = s.players[i]; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.state === 0) {
|
||||||
|
show('waiting');
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < s.players.length; i++) {
|
||||||
|
var p = s.players[i];
|
||||||
|
html += '<div class="player-card' + (p.isReady ? ' ready' : '') + '">';
|
||||||
|
html += '<div class="name">' + p.name + (p.id === playerId ? ' (你)' : '') + '</div>';
|
||||||
|
html += '<div class="status">' + (p.isReady ? '已准备' : '等待中') + '</div></div>';
|
||||||
|
}
|
||||||
|
$('playerList').innerHTML = html;
|
||||||
|
$('readyBtn').textContent = (me && me.isReady) ? '取消准备' : '准备';
|
||||||
|
} else if (s.state === 1) {
|
||||||
|
show('game');
|
||||||
|
var cur = s.players[s.currentTurn];
|
||||||
|
$('turnInfo').textContent = cur.id === playerId ? '轮到你出牌' : '等待 ' + cur.name + ' 出牌';
|
||||||
|
|
||||||
|
var othersHtml = '';
|
||||||
|
for (var i = 0; i < s.players.length; i++) {
|
||||||
|
var p = s.players[i];
|
||||||
|
if (p.id === playerId) continue;
|
||||||
|
othersHtml += '<div class="player-card' + (s.currentTurn === i ? ' current' : '') + '">';
|
||||||
|
othersHtml += '<div class="name">' + p.name + '</div>';
|
||||||
|
othersHtml += '<div class="status">' + p.cardCount + '张</div></div>';
|
||||||
|
}
|
||||||
|
$('others').innerHTML = othersHtml;
|
||||||
|
|
||||||
|
if (s.lastPlay) {
|
||||||
|
$('lastPlay').classList.remove('hidden');
|
||||||
|
var lpName = '';
|
||||||
|
for (var i = 0; i < s.players.length; i++) {
|
||||||
|
if (s.players[i].id === s.lastPlay.playerId) { lpName = s.players[i].name; break; }
|
||||||
|
}
|
||||||
|
$('lastPlayer').textContent = lpName;
|
||||||
|
$('lastCards').innerHTML = s.lastPlay.cards.map(function(c) { return makeCardHtml(c, true); }).join('');
|
||||||
|
} else {
|
||||||
|
$('lastPlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
renderMyCards(me);
|
||||||
|
updateBtns();
|
||||||
|
} else if (s.state === 2) {
|
||||||
|
show('waiting');
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < s.players.length; i++) {
|
||||||
|
var p = s.players[i];
|
||||||
|
html += '<div class="player-card">';
|
||||||
|
html += '<div class="name">' + p.name + '</div>';
|
||||||
|
html += '<div class="status">' + (p.isReady ? '已准备' : '等待中') + '</div></div>';
|
||||||
|
}
|
||||||
|
$('playerList').innerHTML = html;
|
||||||
|
$('readyBtn').textContent = (me && me.isReady) ? '取消准备' : '准备';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardClass(c) {
|
||||||
|
if (c.suit === 5) return 'super';
|
||||||
|
if (c.suit === 4) return 'joker';
|
||||||
|
return c.suit < 2 ? 'red' : 'black';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardText(c) {
|
||||||
|
if (c.suit === 5) return '超人强';
|
||||||
|
if (c.suit === 4) return c.value === 16 ? '小王' : '大王';
|
||||||
|
var suits = ['♥', '♦', '♣', '♠'];
|
||||||
|
var vals = ['', '', '', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '2'];
|
||||||
|
return suits[c.suit] + vals[c.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCardHtml(c, small) {
|
||||||
|
return '<div class="card ' + getCardClass(c) + (small ? ' sm' : '') + '">' + getCardText(c) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMyCards(me) {
|
||||||
|
var el = $('myCards');
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!me || !me.cards) return;
|
||||||
|
for (var i = 0; i < me.cards.length; i++) {
|
||||||
|
(function(c) {
|
||||||
|
var key = c.suit + '_' + c.value;
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'card ' + getCardClass(c) + (selected.indexOf(key) >= 0 ? ' selected' : '');
|
||||||
|
div.textContent = getCardText(c);
|
||||||
|
div.onclick = function() {
|
||||||
|
var idx = selected.indexOf(key);
|
||||||
|
if (idx === -1) { selected.push(key); div.classList.add('selected'); }
|
||||||
|
else { selected.splice(idx, 1); div.classList.remove('selected'); }
|
||||||
|
updateBtns();
|
||||||
|
};
|
||||||
|
el.appendChild(div);
|
||||||
|
})(me.cards[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueCounts(cards) {
|
||||||
|
var counts = {};
|
||||||
|
for (var i = 0; i < cards.length; i++) {
|
||||||
|
var v = cards[i].value;
|
||||||
|
counts[v] = (counts[v] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardType(cards) {
|
||||||
|
var n = cards.length;
|
||||||
|
if (n === 0) return 0;
|
||||||
|
|
||||||
|
var isRocket = n === 2 && cards.some(function(c) { return c.suit === 4 && c.value === 16; })
|
||||||
|
&& cards.some(function(c) { return c.suit === 4 && c.value === 17; });
|
||||||
|
if (isRocket) return 9;
|
||||||
|
|
||||||
|
var counts = getValueCounts(cards);
|
||||||
|
var keys = Object.keys(counts);
|
||||||
|
|
||||||
|
if (n === 1) return 1;
|
||||||
|
if (n === 2 && keys.length === 1) return 2;
|
||||||
|
if (n === 3 && keys.length === 1) return 8;
|
||||||
|
if (n === 4 && keys.length === 1) return 8;
|
||||||
|
if (n === 4 && keys.length === 2) {
|
||||||
|
for (var k in counts) { if (counts[k] === 3) return 4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = cards.slice().sort(function(a, b) { return a.value - b.value; });
|
||||||
|
var isSeq = true;
|
||||||
|
for (var i = 1; i < sorted.length; i++) {
|
||||||
|
if (sorted[i].value !== sorted[i-1].value + 1 || sorted[i].value >= 15) { isSeq = false; break; }
|
||||||
|
}
|
||||||
|
if (n >= 3 && keys.length === n && isSeq) return 6;
|
||||||
|
|
||||||
|
var allPairs = true;
|
||||||
|
for (var k in counts) { if (counts[k] !== 2 || parseInt(k) >= 15) allPairs = false; }
|
||||||
|
var pairVals = keys.map(function(k) { return parseInt(k); }).sort(function(a,b){return a-b;});
|
||||||
|
var seqPairs = true;
|
||||||
|
for (var i = 1; i < pairVals.length; i++) {
|
||||||
|
if (pairVals[i] !== pairVals[i-1] + 1) seqPairs = false;
|
||||||
|
}
|
||||||
|
if (n >= 4 && n % 2 === 0 && allPairs && seqPairs) return 7;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPlay(cards, lastPlay) {
|
||||||
|
var cardType = getCardType(cards);
|
||||||
|
if (cardType === 0) return false;
|
||||||
|
if (!lastPlay) return true;
|
||||||
|
|
||||||
|
if (cardType === 9) return true;
|
||||||
|
if (cardType === 8) {
|
||||||
|
if (lastPlay.cardType === 8) {
|
||||||
|
var myMain = 0, lastMain = 0;
|
||||||
|
var myCounts = getValueCounts(cards);
|
||||||
|
var lastCounts = getValueCounts(lastPlay.cards);
|
||||||
|
for (var k in myCounts) { if (myCounts[k] >= 3 && parseInt(k) > myMain) myMain = parseInt(k); }
|
||||||
|
for (var k in lastCounts) { if (lastCounts[k] >= 3 && parseInt(k) > lastMain) lastMain = parseInt(k); }
|
||||||
|
return myMain > lastMain;
|
||||||
|
}
|
||||||
|
return lastPlay.cardType !== 9;
|
||||||
|
}
|
||||||
|
if (lastPlay.cardType === 8 || lastPlay.cardType === 9) return false;
|
||||||
|
if (cardType !== lastPlay.cardType || cards.length !== lastPlay.cards.length) return false;
|
||||||
|
|
||||||
|
var myMain = 0, lastMain = 0;
|
||||||
|
var myCounts = getValueCounts(cards);
|
||||||
|
var lastCounts = getValueCounts(lastPlay.cards);
|
||||||
|
var myMaxC = 0, lastMaxC = 0;
|
||||||
|
for (var k in myCounts) {
|
||||||
|
if (myCounts[k] > myMaxC || (myCounts[k] === myMaxC && parseInt(k) > myMain)) {
|
||||||
|
myMaxC = myCounts[k]; myMain = parseInt(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var k in lastCounts) {
|
||||||
|
if (lastCounts[k] > lastMaxC || (lastCounts[k] === lastMaxC && parseInt(k) > lastMain)) {
|
||||||
|
lastMaxC = lastCounts[k]; lastMain = parseInt(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return myMain > lastMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBtns() {
|
||||||
|
var myTurn = state && state.players[state.currentTurn].id === playerId;
|
||||||
|
var cards = getSelectedCards();
|
||||||
|
var validPlay = selected.length > 0 && canPlay(cards, state ? state.lastPlay : null);
|
||||||
|
$('playBtn').disabled = !myTurn || !validPlay;
|
||||||
|
var canPass = state && state.lastPlay && state.lastPlay.playerId !== playerId;
|
||||||
|
$('passBtn').disabled = !myTurn || !canPass;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedCards() {
|
||||||
|
if (!state) return [];
|
||||||
|
var me = null;
|
||||||
|
for (var i = 0; i < state.players.length; i++) {
|
||||||
|
if (state.players[i].id === playerId) { me = state.players[i]; break; }
|
||||||
|
}
|
||||||
|
if (!me || !me.cards) return [];
|
||||||
|
return me.cards.filter(function(c) { return selected.indexOf(c.suit + '_' + c.value) >= 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
var cards = getSelectedCards();
|
||||||
|
if (!canPlay(cards, state ? state.lastPlay : null)) {
|
||||||
|
chat('', '牌型无效或管不上', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send('play', {cards: cards});
|
||||||
|
selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pass() { send('pass', {}); }
|
||||||
|
|
||||||
|
function toggleReady() {
|
||||||
|
var me = null;
|
||||||
|
if (state) {
|
||||||
|
for (var i = 0; i < state.players.length; i++) {
|
||||||
|
if (state.players[i].id === playerId) { me = state.players[i]; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send('ready', {ready: me ? !me.isReady : true});
|
||||||
|
}
|
||||||
|
|
||||||
|
function leave() {
|
||||||
|
if (ws) ws.close();
|
||||||
|
show('lobby');
|
||||||
|
roomId = ''; playerId = ''; state = null; selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver(d) {
|
||||||
|
var w = null;
|
||||||
|
if (state) {
|
||||||
|
for (var i = 0; i < state.players.length; i++) {
|
||||||
|
if (state.players[i].id === d.winnerId) { w = state.players[i]; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('winnerText').textContent = w ? (w.id === playerId ? '你赢了!' : w.name + ' 获胜') : '游戏结束';
|
||||||
|
$('gameOver').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function again() {
|
||||||
|
$('gameOver').classList.add('hidden');
|
||||||
|
send('ready', {ready: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
$('createBtn').onclick = create;
|
||||||
|
$('joinBtn').onclick = join;
|
||||||
|
$('readyBtn').onclick = toggleReady;
|
||||||
|
$('leaveBtn').onclick = leave;
|
||||||
|
$('playBtn').onclick = play;
|
||||||
|
$('passBtn').onclick = pass;
|
||||||
|
$('againBtn').onclick = again;
|
||||||
|
$('chatBtn').onclick = function() {
|
||||||
|
var v = $('chatInput').value.trim();
|
||||||
|
if (v) { send('chat', v); $('chatInput').value = ''; }
|
||||||
|
};
|
||||||
|
$('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); };
|
||||||
|
$('roomIdInput').onkeypress = function(e) { if (e.key === 'Enter') join(); };
|
||||||
|
});
|
||||||
28
nginx/nginx.conf
Normal file
28
nginx/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://app:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ws {
|
||||||
|
proxy_pass http://app:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user