From 97e03acbe2276236ac99af873ee728eca63a69c0 Mon Sep 17 00:00:00 2001 From: wtz Date: Sun, 22 Feb 2026 10:00:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=89=8C=E5=9E=8B?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E3=80=81=E8=8E=B7=E8=83=9C=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E5=87=BA=E7=89=8C=E5=8C=BA=E5=B8=83=E5=B1=80=E3=80=81?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=87=8D=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 牌型识别: 新增三带一对、飞机、飞机带单/带对、四带二、四带两对 2. 获胜显示: 先显示最后出牌1秒后再弹出获胜对话框 3. 出牌区布局: 横向排列并换行 4. 移动端重连: 切回前台时自动重连WebSocket 5. 更新文档: README.md和API.md补充新增牌型 --- API.md | 7 +- README.md | 6 ++ app/internal/game/game.go | 170 ++++++++++++++++++++++++---------- app/internal/models/models.go | 5 + app/internal/ws/hub.go | 29 ++++++ nginx/html/css/style.css | 3 +- nginx/html/js/game.js | 133 +++++++++++++++++++++----- 7 files changed, 279 insertions(+), 74 deletions(-) diff --git a/API.md b/API.md index f7b8637..24db033 100644 --- a/API.md +++ b/API.md @@ -427,11 +427,16 @@ | 2 | 对子 | | 3 | 三张 (未使用) | | 4 | 三带一 | -| 5 | 三带对 (未使用) | +| 5 | 三带一对 | | 6 | 顺子 | | 7 | 连对 | | 8 | 炸弹 | | 9 | 火箭 | +| 10 | 飞机 | +| 11 | 飞机带单 | +| 12 | 飞机带对 | +| 13 | 四带二 | +| 14 | 四带两对 | ### RoomState (房间状态) diff --git a/README.md b/README.md index 4616b3c..a382bed 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ | 顺子 | 三张或以上连续点数的牌(不含2和王) | | 连对 | 两对或以上连续点数的对子(不含2和王) | | 三带一 | 三张相同点数+任意一张 | +| 三带一对 | 三张相同点数+一对 | +| 飞机 | 两个或以上连续的三张(不含2和王) | +| 飞机带单 | 飞机+同数量单张 | +| 飞机带对 | 飞机+同数量对子 | +| 四带二 | 四张相同点数+两张单牌 | +| 四带两对 | 四张相同点数+两对 | | 炸弹 | 三张或以上相同点数的牌 | | 火箭 | 小王+大王 | diff --git a/app/internal/game/game.go b/app/internal/game/game.go index b2c4ac4..f598dc9 100644 --- a/app/internal/game/game.go +++ b/app/internal/game/game.go @@ -75,59 +75,100 @@ func (cl *CardLogic) GetCardType(cards []models.Card) models.CardType { cl.SortCards(sorted) counts := cl.getValueCounts(sorted) - switch n { - case 1: - return models.CardTypeSingle - case 2: - if len(counts) == 1 { - return models.CardTypePair + // 统计各种点数的数量 + 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) } - 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 } + + // 炸弹(三张或以上相同) + 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 { @@ -230,9 +271,36 @@ func (cl *CardLogic) CanPlay(cards []models.Card, lastPlay *models.PlayRecord) b 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 diff --git a/app/internal/models/models.go b/app/internal/models/models.go index 0198396..6eaf634 100644 --- a/app/internal/models/models.go +++ b/app/internal/models/models.go @@ -29,6 +29,11 @@ const ( CardTypeDoubleStraight CardTypeBomb CardTypeRocket + CardTypeAirplane + CardTypeAirplaneSingle + CardTypeAirplanePair + CardTypeFourWithTwo + CardTypeFourWithTwoPairs ) type Player struct { diff --git a/app/internal/ws/hub.go b/app/internal/ws/hub.go index 563bab6..76e3c87 100644 --- a/app/internal/ws/hub.go +++ b/app/internal/ws/hub.go @@ -185,6 +185,7 @@ func (c *Client) handleMessage(data []byte) { } room, _ := c.Hub.GameMgr.GetRoom(c.RoomID) if room != nil && room.State == models.RoomStateFinished { + c.Hub.broadcastFinalState(c.RoomID, room) c.Hub.broadcastToRoom(c.RoomID, models.Message{ Type: models.MsgTypeGameOver, Data: map[string]string{"winnerId": room.LastWinner}, @@ -313,6 +314,34 @@ func (h *Hub) broadcastRoomState(roomID string) { } } +func (h *Hub) broadcastFinalState(roomID string, room *models.Room) { + 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 { + state.State = models.RoomStatePlaying + data, _ := json.Marshal(models.Message{ + Type: models.MsgTypeState, + Data: state, + Timestamp: time.Now().Unix(), + }) + select { + case c.Send <- data: + case <-c.done: + default: + } + } + } +} + func (h *Hub) BroadcastRoomLeft(roomID, playerID string) { h.mu.RLock() clients := make([]*Client, 0) diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css index b1c714d..72e43ba 100644 --- a/nginx/html/css/style.css +++ b/nginx/html/css/style.css @@ -45,6 +45,7 @@ h3{color:#ffd700;margin-bottom:10px} .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} +#lastCards{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-top:8px} .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} @@ -63,6 +64,6 @@ h3{color:#ffd700;margin-bottom:10px} .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{background:#16213e;padding:30px;border-radius:12px;text-align:center;border:2px solid #ffd700;max-width:400px} .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/js/game.js b/nginx/html/js/game.js index 2a34089..f56aec7 100644 --- a/nginx/html/js/game.js +++ b/nginx/html/js/game.js @@ -1,6 +1,8 @@ let ws = null, playerId = '', roomId = '', state = null, selected = []; let token = '', userId = '', username = '', nickname = ''; let loginCaptchaId = '', regCaptchaId = ''; +let reconnectTimer = null; +let reconnectDelay = 1000; const $ = id => document.getElementById(id); function show(id) { @@ -246,6 +248,10 @@ async function leaveRoom() { headers: authHeaders() }); } catch (e) {} + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } if (ws) ws.close(); show('lobby'); roomId = ''; @@ -257,7 +263,14 @@ async function leaveRoom() { 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.onopen = function() { + chat('', '已连接', true); + reconnectDelay = 1000; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; ws.onmessage = function(e) { const msg = JSON.parse(e.data); if (msg.type === 'state') render(msg.data); @@ -267,9 +280,41 @@ function connect() { else if (msg.type === 'error') chat('', msg.data, true); }; ws.onerror = function() { chat('', '连接错误', true); }; - ws.onclose = function() { chat('', '断开连接', true); }; + ws.onclose = function() { + chat('', '断开连接', true); + scheduleReconnect(); + }; } +function scheduleReconnect() { + if (reconnectTimer) return; + if (!roomId || !playerId) return; + reconnectDelay = Math.min(reconnectDelay * 2, 30000); + reconnectTimer = setTimeout(function() { + reconnectTimer = null; + if (roomId && playerId) { + chat('', '正在重连...', true); + connect(); + } + }, reconnectDelay); +} + +document.addEventListener('visibilitychange', function() { + if (document.hidden) { + if (ws) { + try { ws.close(); } catch (e) {} + ws = null; + } + } else { + if (!ws || ws.readyState !== 1) { + if (roomId && playerId) { + reconnectDelay = 500; + scheduleReconnect(); + } + } + } +}); + function send(type, data) { if (ws && ws.readyState === 1) { ws.send(JSON.stringify({type: type, playerId: playerId, roomId: roomId, data: data})); @@ -386,49 +431,86 @@ function getValueCounts(cards) { return counts; } +function isConsecutive(values) { + if (values.length < 2) return true; + values = values.slice().sort(function(a, b) { return a - b; }); + for (var i = 1; i < values.length; i++) { + if (values[i] !== values[i - 1] + 1 || values[i] >= 15) return false; + } + return values[0] < 15; +} + 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; }); + + 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); - + + var singles = [], pairs = [], triples = [], quads = []; + for (var v in counts) { + var c = counts[v]; + var val = parseInt(v); + if (c === 1) singles.push(val); + else if (c === 2) pairs.push(val); + else if (c === 3) triples.push(val); + else if (c === 4) quads.push(val); + } + + if (keys.length === 1 && n >= 3) return 8; + + if (quads.length === 1 && singles.length === 2 && pairs.length === 0 && triples.length === 0 && n === 6) return 13; + if (quads.length === 1 && pairs.length === 2 && singles.length === 0 && triples.length === 0 && n === 8) return 14; + + if (triples.length >= 2 && isConsecutive(triples)) { + var planeCount = triples.length; + if (n === planeCount * 3) return 10; + if (n === planeCount * 3 + planeCount && singles.length === planeCount) return 11; + if (n === planeCount * 3 + planeCount * 2 && pairs.length === planeCount) return 12; + } + 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; } - } - + if (n === 4 && triples.length === 1 && singles.length === 1) return 4; + if (n === 5 && triples.length === 1 && pairs.length === 1) return 5; + 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 (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 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 (pairVals[i] !== pairVals[i - 1] + 1) seqPairs = false; } if (n >= 4 && n % 2 === 0 && allPairs && seqPairs) return 7; - + return 0; } +function getTripleValues(cards) { + var counts = getValueCounts(cards); + var triples = []; + for (var v in counts) { + if (counts[v] >= 3) triples.push(parseInt(v)); + } + return triples; +} + 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) { @@ -443,7 +525,14 @@ function canPlay(cards, lastPlay) { } if (lastPlay.cardType === 8 || lastPlay.cardType === 9) return false; if (cardType !== lastPlay.cardType || cards.length !== lastPlay.cards.length) return false; - + + if (cardType === 10 || cardType === 11 || cardType === 12) { + var myTriples = getTripleValues(cards).sort(function(a, b) { return a - b; }); + var lastTriples = getTripleValues(lastPlay.cards).sort(function(a, b) { return a - b; }); + if (myTriples.length !== lastTriples.length) return false; + return myTriples[myTriples.length - 1] > lastTriples[lastTriples.length - 1]; + } + var myMain = 0, lastMain = 0; var myCounts = getValueCounts(cards); var lastCounts = getValueCounts(lastPlay.cards); @@ -514,8 +603,10 @@ function showGameOver(d) { if (state.players[i].id === d.winnerId) { w = state.players[i]; break; } } } - $('winnerText').textContent = w ? (w.id === playerId ? '你赢了!' : w.name + ' 获胜') : '游戏结束'; - $('gameOver').classList.remove('hidden'); + setTimeout(function() { + $('winnerText').textContent = w ? (w.id === playerId ? '你赢了!' : w.name + ' 获胜') : '游戏结束'; + $('gameOver').classList.remove('hidden'); + }, 1000); } function again() {