Files
doucan/app/internal/game/game.go
wtz 97e03acbe2 fix: 修复牌型识别、获胜显示、出牌区布局、移动端重连
1. 牌型识别: 新增三带一对、飞机、飞机带单/带对、四带二、四带两对
2. 获胜显示: 先显示最后出牌1秒后再弹出获胜对话框
3. 出牌区布局: 横向排列并换行
4. 移动端重连: 切回前台时自动重连WebSocket
5. 更新文档: README.md和API.md补充新增牌型
2026-02-22 10:00:13 +08:00

897 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package game
import (
"context"
"doudizhu-server/internal/models"
"doudizhu-server/internal/redis"
"encoding/json"
"errors"
"math/rand"
"sort"
"sync"
"time"
)
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")
ErrNotYourTurn = errors.New("not your turn")
ErrPlayerNotFound = errors.New("player not found")
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")
ErrNotEnoughPlayers = errors.New("not enough players")
)
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)
// 统计各种点数的数量
var singles, pairs, triples, quads []int
for v, c := range counts {
switch c {
case 1:
singles = append(singles, v)
case 2:
pairs = append(pairs, v)
case 3:
triples = append(triples, v)
case 4:
quads = append(quads, v)
}
}
// 炸弹(三张或以上相同)
if len(counts) == 1 && n >= 3 {
return models.CardTypeBomb
}
// 四带二4张相同+2单张
if len(quads) == 1 && len(singles) == 2 && len(pairs) == 0 && len(triples) == 0 && n == 6 {
return models.CardTypeFourWithTwo
}
// 四带两对4张相同+2对
if len(quads) == 1 && len(pairs) == 2 && len(singles) == 0 && len(triples) == 0 && n == 8 {
return models.CardTypeFourWithTwoPairs
}
// 飞机(连续的三张)
if len(triples) >= 2 && cl.isConsecutive(triples) {
planeCount := len(triples)
// 纯飞机
if n == planeCount*3 {
return models.CardTypeAirplane
}
// 飞机带单张
if n == planeCount*3+planeCount && len(singles) == planeCount {
return models.CardTypeAirplaneSingle
}
// 飞机带对子
if n == planeCount*3+planeCount*2 && len(pairs) == planeCount {
return models.CardTypeAirplanePair
}
}
// 单张
if n == 1 {
return models.CardTypeSingle
}
// 对子
if n == 2 && len(counts) == 1 {
return models.CardTypePair
}
// 三带一3张相同+1单张
if n == 4 && len(triples) == 1 && len(singles) == 1 {
return models.CardTypeTripleOne
}
// 三带一对3张相同+1对
if n == 5 && len(triples) == 1 && len(pairs) == 1 {
return models.CardTypeTriplePair
}
// 顺子
if cl.isStraight(sorted) {
return models.CardTypeStraight
}
// 连对
if cl.isDoubleStraight(sorted, counts) {
return models.CardTypeDoubleStraight
}
return models.CardTypeInvalid
}
func (cl *CardLogic) isConsecutive(values []int) bool {
if len(values) < 2 {
return true
}
sort.Ints(values)
for i := 1; i < len(values); i++ {
if values[i] != values[i-1]+1 {
return false
}
if values[i] >= 15 { // 2和王不能参与
return false
}
}
return values[0] < 15
}
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
}
if cardType == models.CardTypeAirplane || cardType == models.CardTypeAirplaneSingle || cardType == models.CardTypeAirplanePair {
return cl.compareAirplanes(cards, lastPlay.Cards)
}
return cl.getMainValue(cards) > cl.getMainValue(lastPlay.Cards)
}
func (cl *CardLogic) compareAirplanes(cards, lastCards []models.Card) bool {
myTriples := cl.getTripleValues(cards)
lastTriples := cl.getTripleValues(lastCards)
if len(myTriples) != len(lastTriples) {
return false
}
sort.Ints(myTriples)
sort.Ints(lastTriples)
return myTriples[len(myTriples)-1] > lastTriples[len(lastTriples)-1]
}
func (cl *CardLogic) getTripleValues(cards []models.Card) []int {
counts := cl.getValueCounts(cards)
var triples []int
for v, c := range counts {
if c >= 3 {
triples = append(triples, v)
}
}
return triples
}
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 {
rdb *redis.Client
cl *CardLogic
mu sync.RWMutex
stopCleanup chan struct{}
stopOnce sync.Once
}
func NewGameManager(rdb *redis.Client) *GameManager {
gm := &GameManager{
rdb: rdb,
cl: NewCardLogic(),
stopCleanup: make(chan struct{}),
}
go gm.cleanupLoop()
return gm
}
func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) {
return gm.CreateRoomWithUser("", playerName, maxPlayers)
}
func (gm *GameManager) CreateRoomWithUser(userID, playerName string, maxPlayers int) (*models.Room, *models.Player) {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
roomID := generateID()
playerID := generateID()
player := &models.Player{ID: playerID, UserID: userID, 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.saveRoom(ctx, room)
gm.saveDeck(ctx, roomID, deck)
gm.saveDiscard(ctx, roomID, []models.Card{})
return room, player
}
func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *models.Player, error) {
return gm.JoinRoomWithUser(roomID, "", playerName)
}
func (gm *GameManager) JoinRoomWithUser(roomID, userID, playerName string) (*models.Room, *models.Player, error) {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
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, UserID: userID, Name: playerName, IsOnline: true}
room.Players = append(room.Players, player)
gm.saveRoom(ctx, room)
return room, player, nil
}
func (gm *GameManager) LeaveRoom(roomID, playerID string) error {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound
}
for i, p := range room.Players {
if p.ID == playerID {
room.Players = append(room.Players[:i], room.Players[i+1:]...)
if len(room.Players) == 0 {
gm.deleteRoomData(ctx, roomID)
} else {
gm.saveRoom(ctx, room)
}
return nil
}
}
return ErrPlayerNotFound
}
func (gm *GameManager) LeaveRoomWithTTL(roomID, playerID string) error {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound
}
for _, p := range room.Players {
if p.ID == playerID {
p.IsOnline = false
gm.saveRoom(ctx, room)
gm.rdb.Set(ctx, redis.PlayerTTLKey(playerID), time.Now().Add(redis.PlayerTTL).Unix(), redis.PlayerTTL)
return nil
}
}
return ErrPlayerNotFound
}
func (gm *GameManager) MarkPlayerOnline(roomID, playerID string) {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return
}
for _, p := range room.Players {
if p.ID == playerID {
p.IsOnline = true
gm.saveRoom(ctx, room)
gm.rdb.Delete(ctx, redis.PlayerTTLKey(playerID))
return
}
}
}
func (gm *GameManager) MarkPlayerOffline(roomID, playerID string) {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return
}
for _, p := range room.Players {
if p.ID == playerID {
p.IsOnline = false
gm.saveRoom(ctx, room)
return
}
}
}
func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
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
}
gm.saveRoom(ctx, room)
return nil
}
}
return ErrPlayerNotFound
}
func (gm *GameManager) StartGame(roomID string) error {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound
}
if len(room.Players) < 2 {
return ErrNotEnoughPlayers
}
if room.State == models.RoomStatePlaying {
return ErrGameStarted
}
lastWinner := room.LastWinner
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.saveDeck(ctx, roomID, deck)
gm.saveDiscard(ctx, roomID, []models.Card{})
room.State = models.RoomStatePlaying
room.RoundCount = 1
room.LastPlay = nil
if lastWinner != "" {
for i, p := range room.Players {
if p.ID == lastWinner {
room.CurrentTurn = i
break
}
}
} else {
room.CurrentTurn = 0
}
gm.dealCards(room, 5)
gm.saveRoom(ctx, room)
return nil
}
func (gm *GameManager) dealCards(room *models.Room, count int) {
ctx := context.Background()
deck, _ := gm.loadDeck(ctx, room.ID)
for i := 0; i < count; i++ {
for _, p := range room.Players {
if len(deck) == 0 {
deck, _ = gm.loadDiscard(ctx, room.ID)
gm.cl.Shuffle(deck)
gm.saveDiscard(ctx, room.ID, []models.Card{})
}
if len(deck) > 0 {
p.Cards = append(p.Cards, deck[0])
deck = deck[1:]
}
}
}
gm.saveDeck(ctx, room.ID, 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()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
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,
}
discard, _ := gm.loadDiscard(ctx, roomID)
discard = append(discard, cards...)
gm.saveDiscard(ctx, roomID, discard)
if len(player.Cards) == 0 {
room.LastWinner = playerID
room.State = models.RoomStateFinished
gm.rdb.Set(ctx, redis.RoomTTLKey(roomID), time.Now().Add(redis.RoomTTL).Unix(), redis.RoomTTL)
} else {
room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players)
}
gm.saveRoom(ctx, room)
return nil
}
func (gm *GameManager) Pass(roomID, playerID string) error {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
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
}
room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players)
// 一轮结束:回到最后出牌者,发牌并让他先出
if room.Players[room.CurrentTurn].ID == room.LastPlay.PlayerID {
winnerID := room.LastPlay.PlayerID
room.LastPlay = nil
room.RoundCount++
gm.dealCards(room, 1)
for i, p := range room.Players {
if p.ID == winnerID {
room.CurrentTurn = i
break
}
}
}
gm.saveRoom(ctx, room)
return nil
}
func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) {
gm.mu.RLock()
defer gm.mu.RUnlock()
return gm.loadRoom(context.Background(), roomID)
}
func (gm *GameManager) GetPlayerName(roomID, playerID string) string {
gm.mu.RLock()
defer gm.mu.RUnlock()
room, err := gm.loadRoom(context.Background(), roomID)
if err != nil {
return ""
}
for _, p := range room.Players {
if p.ID == playerID {
return p.Name
}
}
return ""
}
func (gm *GameManager) GetPlayerIDByUserID(roomID, userID string) string {
gm.mu.RLock()
defer gm.mu.RUnlock()
room, err := gm.loadRoom(context.Background(), roomID)
if err != nil {
return ""
}
for _, p := range room.Players {
if p.UserID == userID {
return p.ID
}
}
return ""
}
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
gm.mu.RLock()
defer gm.mu.RUnlock()
room, err := gm.loadRoom(context.Background(), roomID)
if err != nil {
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,
}
}
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) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
gm.cleanupExpired()
case <-gm.stopCleanup:
return
}
}
}
func (gm *GameManager) cleanupExpired() {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
gm.rdb.ScanKeys(ctx, "room:*:ttl", func(key string) error {
var expiryTime int64
if err := gm.rdb.Get(ctx, key, &expiryTime); err != nil {
return nil
}
if time.Now().Unix() > expiryTime {
roomID := key[len("room:") : len(key)-len(":ttl")]
gm.deleteRoomData(ctx, roomID)
}
return nil
})
gm.rdb.ScanKeys(ctx, "player:*:ttl", func(key string) error {
var expiryTime int64
if err := gm.rdb.Get(ctx, key, &expiryTime); err != nil {
return nil
}
if time.Now().Unix() > expiryTime {
playerID := key[len("player:") : len(key)-len(":ttl")]
gm.rdb.Delete(ctx, redis.PlayerKey(playerID), redis.PlayerTTLKey(playerID))
}
return nil
})
}
func (gm *GameManager) Stop() {
gm.stopOnce.Do(func() {
close(gm.stopCleanup)
})
}
func (gm *GameManager) saveRoom(ctx context.Context, room *models.Room) {
gm.rdb.Set(ctx, redis.RoomKey(room.ID), room, redis.RoomTTL)
}
func (gm *GameManager) loadRoom(ctx context.Context, roomID string) (*models.Room, error) {
var room models.Room
if err := gm.rdb.Get(ctx, redis.RoomKey(roomID), &room); err != nil {
return nil, err
}
return &room, nil
}
func (gm *GameManager) saveDeck(ctx context.Context, roomID string, deck []models.Card) {
gm.rdb.Set(ctx, redis.RoomDeckKey(roomID), deck, redis.RoomTTL)
}
func (gm *GameManager) loadDeck(ctx context.Context, roomID string) ([]models.Card, error) {
var deck []models.Card
if err := gm.rdb.Get(ctx, redis.RoomDeckKey(roomID), &deck); err != nil {
return nil, err
}
return deck, nil
}
func (gm *GameManager) saveDiscard(ctx context.Context, roomID string, discard []models.Card) {
gm.rdb.Set(ctx, redis.RoomDiscardKey(roomID), discard, redis.RoomTTL)
}
func (gm *GameManager) loadDiscard(ctx context.Context, roomID string) ([]models.Card, error) {
var discard []models.Card
if err := gm.rdb.Get(ctx, redis.RoomDiscardKey(roomID), &discard); err != nil {
return nil, err
}
return discard, nil
}
func (gm *GameManager) deleteRoomData(ctx context.Context, roomID string) {
room, err := gm.loadRoom(ctx, roomID)
if err == nil {
for _, p := range room.Players {
gm.rdb.Delete(ctx, redis.PlayerKey(p.ID), redis.PlayerTTLKey(p.ID))
}
}
gm.rdb.Delete(ctx,
redis.RoomKey(roomID),
redis.RoomDeckKey(roomID),
redis.RoomDiscardKey(roomID),
redis.RoomTTLKey(roomID),
)
}
func generateID() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
func cardKey(c models.Card) string {
data, _ := json.Marshal(c)
return string(data)
}