From 13d2b0e1dccbd6c5458f6668c1f129c4f5928f36 Mon Sep 17 00:00:00 2001 From: wtz Date: Thu, 19 Feb 2026 21:18:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E6=96=97=E5=9C=B0?= =?UTF-8?q?=E4=B8=BB=E6=AE=8B=E5=B1=80=E7=89=88=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 46 +++ AGENTS.md | 150 ++++++++ API.md | 277 +++++++++++++++ README.md | 101 ++++++ app/Dockerfile | 15 + app/cmd/main.go | 39 +++ app/go.mod | 7 + app/go.sum | 4 + app/internal/game/errors.go | 34 ++ app/internal/game/game.go | 563 ++++++++++++++++++++++++++++++ app/internal/handlers/handlers.go | 96 +++++ app/internal/models/models.go | 103 ++++++ app/internal/ws/hub.go | 288 +++++++++++++++ compose.yaml | 19 + nginx/html/css/style.css | 57 +++ nginx/html/index.html | 86 +++++ nginx/html/js/game.js | 346 ++++++++++++++++++ nginx/nginx.conf | 28 ++ 18 files changed, 2259 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 API.md create mode 100644 README.md create mode 100644 app/Dockerfile create mode 100644 app/cmd/main.go create mode 100644 app/go.mod create mode 100644 app/go.sum create mode 100644 app/internal/game/errors.go create mode 100644 app/internal/game/game.go create mode 100644 app/internal/handlers/handlers.go create mode 100644 app/internal/models/models.go create mode 100644 app/internal/ws/hub.go create mode 100644 compose.yaml create mode 100644 nginx/html/css/style.css create mode 100644 nginx/html/index.html create mode 100644 nginx/html/js/game.js create mode 100644 nginx/nginx.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e32b54 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..64e74a7 --- /dev/null +++ b/AGENTS.md @@ -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) +``` diff --git a/API.md b/API.md new file mode 100644 index 0000000..dd486d8 --- /dev/null +++ b/API.md @@ -0,0 +1,277 @@ +# 斗地主残局版 API 文档 + +## 基础信息 + +- 基础URL: `http:///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:///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 | 已结束 | diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e6998d --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..982387a --- /dev/null +++ b/app/Dockerfile @@ -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"] diff --git a/app/cmd/main.go b/app/cmd/main.go new file mode 100644 index 0000000..eec44c0 --- /dev/null +++ b/app/cmd/main.go @@ -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) + } +} diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..13ae5be --- /dev/null +++ b/app/go.mod @@ -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 diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..272772f --- /dev/null +++ b/app/go.sum @@ -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= diff --git a/app/internal/game/errors.go b/app/internal/game/errors.go new file mode 100644 index 0000000..0151f04 --- /dev/null +++ b/app/internal/game/errors.go @@ -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) +} diff --git a/app/internal/game/game.go b/app/internal/game/game.go new file mode 100644 index 0000000..70d253f --- /dev/null +++ b/app/internal/game/game.go @@ -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, + } +} diff --git a/app/internal/handlers/handlers.go b/app/internal/handlers/handlers.go new file mode 100644 index 0000000..051f336 --- /dev/null +++ b/app/internal/handlers/handlers.go @@ -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 "" +} diff --git a/app/internal/models/models.go b/app/internal/models/models.go new file mode 100644 index 0000000..c298164 --- /dev/null +++ b/app/internal/models/models.go @@ -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"` +} diff --git a/app/internal/ws/hub.go b/app/internal/ws/hub.go new file mode 100644 index 0000000..7cb6298 --- /dev/null +++ b/app/internal/ws/hub.go @@ -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) +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9e193c2 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css new file mode 100644 index 0000000..1857bab --- /dev/null +++ b/nginx/html/css/style.css @@ -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}} diff --git a/nginx/html/index.html b/nginx/html/index.html new file mode 100644 index 0000000..40b483a --- /dev/null +++ b/nginx/html/index.html @@ -0,0 +1,86 @@ + + + + + + 斗地主残局版 + + + +
+
+

斗地主残局版

+
+ + + +
+ + +
+
+
+

规则

+
    +
  • 新增"超人强"为最大单牌
  • +
  • 每人初始5张牌
  • +
  • 三张可成顺子/炸弹,两对可成连对
  • +
+
+
+ + + + + + + +
+
+
+ + +
+
+
+ + + diff --git a/nginx/html/js/game.js b/nginx/html/js/game.js new file mode 100644 index 0000000..5120517 --- /dev/null +++ b/nginx/html/js/game.js @@ -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 ? '' + msg + '' : '' + name + ': ' + 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 += '
'; + html += '
' + p.name + (p.id === playerId ? ' (你)' : '') + '
'; + html += '
' + (p.isReady ? '已准备' : '等待中') + '
'; + } + $('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 += '
'; + othersHtml += '
' + p.name + '
'; + othersHtml += '
' + p.cardCount + '张
'; + } + $('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 += '
'; + html += '
' + p.name + '
'; + html += '
' + (p.isReady ? '已准备' : '等待中') + '
'; + } + $('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 '
' + getCardText(c) + '
'; +} + +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(); }; +}); diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..f4119de --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } +}