448 lines
14 KiB
JavaScript
448 lines
14 KiB
JavaScript
/**
|
|
* 斗地主游戏核心逻辑
|
|
* 本地实现,完整游戏流程
|
|
*/
|
|
|
|
// ========== 牌组定义 ==========
|
|
const SUITS = ['♠', '♥', '♣', '♦'];
|
|
const RANKS = ['3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '2'];
|
|
|
|
// 生成完整牌组
|
|
function generateDeck() {
|
|
const deck = [];
|
|
let id = 0;
|
|
|
|
for (const rank of RANKS) {
|
|
for (const suit of SUITS) {
|
|
deck.push({
|
|
id: id++,
|
|
rank: rank,
|
|
suit: suit,
|
|
value: RANKS.indexOf(rank) + 3,
|
|
isJoker: false
|
|
});
|
|
}
|
|
}
|
|
|
|
deck.push({ id: id++, rank: '小王', suit: '🃏', value: 16, isJoker: true });
|
|
deck.push({ id: id++, rank: '大王', suit: '🃏', value: 17, isJoker: true });
|
|
|
|
return deck;
|
|
}
|
|
|
|
// 洗牌
|
|
function shuffleDeck(deck) {
|
|
const shuffled = [...deck];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
// ========== 牌型识别 ==========
|
|
const CARD_TYPES = {
|
|
SINGLE: 'single', // 单张
|
|
PAIR: 'pair', // 对子
|
|
TRIPLE1: 'triple1', // 三带一
|
|
STRAIGHT: 'straight', // 顺子
|
|
BOMB: 'bomb', // 炸弹
|
|
JOKER: 'joker' // 王炸
|
|
};
|
|
|
|
// 识别牌型
|
|
function identifyCardType(cards) {
|
|
if (!cards || cards.length === 0) return null;
|
|
|
|
const sorted = [...cards].sort((a, b) => a.value - b.value);
|
|
const n = cards.length;
|
|
|
|
// 王炸
|
|
if (n === 2 && cards.every(c => c.isJoker)) {
|
|
return { type: CARD_TYPES.JOKER, value: 100, cards: cards, name: '王炸' };
|
|
}
|
|
|
|
// 单张
|
|
if (n === 1) {
|
|
return { type: CARD_TYPES.SINGLE, value: cards[0].value, cards: cards, name: '单张' };
|
|
}
|
|
|
|
// 对子
|
|
if (n === 2 && sorted[0].value === sorted[1].value) {
|
|
return { type: CARD_TYPES.PAIR, value: sorted[0].value, cards: cards, name: '对子' };
|
|
}
|
|
|
|
// 炸弹
|
|
if (n === 4 && sorted.every(c => c.value === sorted[0].value)) {
|
|
return { type: CARD_TYPES.BOMB, value: sorted[0].value, cards: cards, name: '炸弹' };
|
|
}
|
|
|
|
// 三带一
|
|
if (n === 4) {
|
|
const valueCount = {};
|
|
cards.forEach(c => valueCount[c.value] = (valueCount[c.value] || 0) + 1);
|
|
const values = Object.values(valueCount);
|
|
if (values.includes(3) && values.includes(1)) {
|
|
const tripleValue = parseInt(Object.keys(valueCount).find(k => valueCount[k] === 3));
|
|
return { type: CARD_TYPES.TRIPLE1, value: tripleValue, cards: cards, name: '三带一' };
|
|
}
|
|
}
|
|
|
|
// 顺子 (>=5张连续,不能含2和王)
|
|
if (n >= 5) {
|
|
const hasInvalid = cards.some(c => c.value >= 15); // 2=15, 王=16,17
|
|
if (!hasInvalid) {
|
|
const isStraight = sorted.every((c, i) => {
|
|
if (i === 0) return true;
|
|
return c.value === sorted[i-1].value + 1;
|
|
});
|
|
if (isStraight) {
|
|
return {
|
|
type: CARD_TYPES.STRAIGHT,
|
|
value: sorted[0].value,
|
|
length: n,
|
|
cards: cards,
|
|
name: '顺子'
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 比较牌型大小
|
|
function canBeat(desktop, newType) {
|
|
if (!desktop) return true;
|
|
|
|
// 王炸最大
|
|
if (newType.type === CARD_TYPES.JOKER) return true;
|
|
if (desktop.type === CARD_TYPES.JOKER) return false;
|
|
|
|
// 炸弹 > 非炸弹
|
|
if (newType.type === CARD_TYPES.BOMB && desktop.type !== CARD_TYPES.BOMB) return true;
|
|
if (desktop.type === CARD_TYPES.BOMB && newType.type !== CARD_TYPES.BOMB) return false;
|
|
|
|
// 同类型比较
|
|
if (newType.type !== desktop.type) return false;
|
|
if (newType.length && desktop.length && newType.length !== desktop.length) return false;
|
|
|
|
return newType.value > desktop.value;
|
|
}
|
|
|
|
// ========== AI逻辑 ==========
|
|
class DoudizhuAI {
|
|
constructor(playerId) {
|
|
this.id = playerId;
|
|
}
|
|
|
|
// 评估手牌强度(用于叫地主)
|
|
evaluateHand(cards) {
|
|
let score = 0;
|
|
|
|
// 大牌加分
|
|
cards.forEach(c => {
|
|
if (c.value >= 15) score += 3; // 2, 王
|
|
else if (c.value >= 14) score += 2; // A
|
|
else if (c.value >= 13) score += 1; // K
|
|
});
|
|
|
|
// 炸弹加分
|
|
const valueCount = {};
|
|
cards.forEach(c => valueCount[c.value] = (valueCount[c.value] || 0) + 1);
|
|
Object.values(valueCount).forEach(count => {
|
|
if (count === 4) score += 5;
|
|
});
|
|
|
|
// 王炸
|
|
if (cards.filter(c => c.isJoker).length === 2) score += 8;
|
|
|
|
return score;
|
|
}
|
|
|
|
// 决定是否叫地主
|
|
wantToBeLord(cards) {
|
|
const score = this.evaluateHand(cards);
|
|
return score >= 8; // 分数>=8才当地主
|
|
}
|
|
|
|
// 找出所有可出的牌型
|
|
findAllValidPlays(cards, desktop) {
|
|
const validPlays = [];
|
|
const n = cards.length;
|
|
|
|
// 辅助函数:生成所有组合
|
|
const getCombinations = (arr, k) => {
|
|
if (k === 0) return [[]];
|
|
if (arr.length < k) return [];
|
|
const result = [];
|
|
for (let i = 0; i <= arr.length - k; i++) {
|
|
const subCombos = getCombinations(arr.slice(i + 1), k - 1);
|
|
subCombos.forEach(sub => result.push([arr[i], ...sub]));
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// 尝试不同数量的牌
|
|
for (let k = 1; k <= Math.min(5, n); k++) {
|
|
const combos = getCombinations(cards, k);
|
|
combos.forEach(combo => {
|
|
const cardType = identifyCardType(combo);
|
|
if (cardType && canBeat(desktop, cardType)) {
|
|
validPlays.push({ cards: combo, type: cardType });
|
|
}
|
|
});
|
|
}
|
|
|
|
// 炸弹单独处理(可以管任何非炸弹)
|
|
const valueCount = {};
|
|
cards.forEach(c => valueCount[c.value] = (valueCount[c.value] || 0) + 1);
|
|
Object.entries(valueCount).forEach(([value, count]) => {
|
|
if (count === 4) {
|
|
const bombCards = cards.filter(c => c.value == value);
|
|
const bombType = identifyCardType(bombCards);
|
|
if (bombType && canBeat(desktop, bombType)) {
|
|
validPlays.push({ cards: bombCards, type: bombType });
|
|
}
|
|
}
|
|
});
|
|
|
|
// 王炸
|
|
const jokers = cards.filter(c => c.isJoker);
|
|
if (jokers.length === 2) {
|
|
const jokerType = identifyCardType(jokers);
|
|
if (jokerType && canBeat(desktop, jokerType)) {
|
|
validPlays.push({ cards: jokers, type: jokerType });
|
|
}
|
|
}
|
|
|
|
return validPlays;
|
|
}
|
|
|
|
// 选择最佳出牌
|
|
chooseBestPlay(validPlays, cards, isLord, teammateCards) {
|
|
if (validPlays.length === 0) return null;
|
|
|
|
// 排序策略
|
|
validPlays.sort((a, b) => {
|
|
// 优先出能走完的
|
|
const aRemaining = cards.length - a.cards.length;
|
|
const bRemaining = cards.length - b.cards.length;
|
|
if (aRemaining === 0) return -1;
|
|
if (bRemaining === 0) return 1;
|
|
|
|
// 优先出小牌
|
|
return a.type.value - b.type.value;
|
|
});
|
|
|
|
// 选择第一个(最小且合法的)
|
|
return validPlays[0];
|
|
}
|
|
|
|
// AI决策
|
|
makeDecision(cards, desktop, isLord, teammateCards) {
|
|
const validPlays = this.findAllValidPlays(cards, desktop);
|
|
|
|
if (validPlays.length === 0) {
|
|
return { action: 'pass' };
|
|
}
|
|
|
|
const bestPlay = this.chooseBestPlay(validPlays, cards, isLord, teammateCards);
|
|
|
|
if (bestPlay) {
|
|
return { action: 'play', cards: bestPlay.cards, type: bestPlay.type };
|
|
}
|
|
|
|
return { action: 'pass' };
|
|
}
|
|
}
|
|
|
|
// ========== 游戏主类 ==========
|
|
class DoudizhuGame {
|
|
constructor() {
|
|
this.players = [
|
|
{ id: 0, cards: [], isLord: false, name: '玩家', ai: null },
|
|
{ id: 1, cards: [], isLord: false, name: '农民甲', ai: new DoudizhuAI(1) },
|
|
{ id: 2, cards: [], isLord: false, name: '农民乙', ai: new DoudizhuAI(2) }
|
|
];
|
|
this.deck = [];
|
|
this.bottomCards = [];
|
|
this.currentPlayer = 0;
|
|
this.gamePhase = 'init';
|
|
this.desktopCards = null;
|
|
this.lastAction = null;
|
|
this.passCount = 0;
|
|
}
|
|
|
|
init() {
|
|
this.deck = shuffleDeck(generateDeck());
|
|
this.players.forEach(p => {
|
|
p.cards = [];
|
|
p.isLord = false;
|
|
});
|
|
this.bottomCards = [];
|
|
this.desktopCards = null;
|
|
this.passCount = 0;
|
|
this.gamePhase = 'dealing';
|
|
}
|
|
|
|
deal() {
|
|
// 发51张,每人17张
|
|
for (let i = 0; i < 51; i++) {
|
|
this.players[i % 3].cards.push(this.deck[i]);
|
|
}
|
|
// 剩余3张底牌
|
|
this.bottomCards = this.deck.slice(51, 54);
|
|
|
|
// 排序
|
|
this.players.forEach(p => {
|
|
p.cards.sort((a, b) => a.value - b.value);
|
|
});
|
|
|
|
this.gamePhase = 'calling';
|
|
return {
|
|
players: this.players.map(p => ({
|
|
id: p.id,
|
|
cardCount: p.cards.length,
|
|
cards: p.id === 0 ? p.cards : null // 只有玩家能看到自己的牌
|
|
})),
|
|
bottomCards: this.bottomCards
|
|
};
|
|
}
|
|
|
|
// 叫地主
|
|
callLord(playerId, want) {
|
|
if (want) {
|
|
this.players[playerId].isLord = true;
|
|
this.players[playerId].cards.push(...this.bottomCards);
|
|
this.players[playerId].cards.sort((a, b) => a.value - b.value);
|
|
this.currentPlayer = playerId;
|
|
this.gamePhase = 'playing';
|
|
return { success: true, lordId: playerId };
|
|
}
|
|
return { success: false };
|
|
}
|
|
|
|
// AI叫地主
|
|
aiCallLord() {
|
|
for (let i = 1; i <= 2; i++) {
|
|
const ai = this.players[i].ai;
|
|
if (ai.wantToBeLord(this.players[i].cards)) {
|
|
return this.callLord(i, true);
|
|
}
|
|
}
|
|
// 都没叫,随机选一个
|
|
const randomLord = Math.floor(Math.random() * 3);
|
|
return this.callLord(randomLord, true);
|
|
}
|
|
|
|
// 出牌
|
|
playCards(playerId, cards) {
|
|
const player = this.players[playerId];
|
|
|
|
// 验证牌是否在手中
|
|
const cardIds = cards.map(c => c.id);
|
|
const hasAllCards = cardIds.every(id =>
|
|
player.cards.some(c => c.id === id)
|
|
);
|
|
if (!hasAllCards) return { success: false, error: '牌不在手中' };
|
|
|
|
// 识别牌型
|
|
const cardType = identifyCardType(cards);
|
|
if (!cardType) return { success: false, error: '非法牌型' };
|
|
|
|
// 验证能否管上
|
|
if (this.desktopCards && !canBeat(this.desktopCards, cardType)) {
|
|
return { success: false, error: '管不上' };
|
|
}
|
|
|
|
// 执行出牌
|
|
player.cards = player.cards.filter(c => !cardIds.includes(c.id));
|
|
this.desktopCards = cardType;
|
|
this.lastAction = 'play';
|
|
this.passCount = 0;
|
|
|
|
// 检查胜利
|
|
if (player.cards.length === 0) {
|
|
return {
|
|
success: true,
|
|
cardType: cardType,
|
|
remainingCards: player.cards.length,
|
|
gameOver: true,
|
|
winner: playerId
|
|
};
|
|
}
|
|
|
|
// 下一个玩家
|
|
this.currentPlayer = (playerId + 1) % 3;
|
|
|
|
return {
|
|
success: true,
|
|
cardType: cardType,
|
|
remainingCards: player.cards.length,
|
|
nextPlayer: this.currentPlayer
|
|
};
|
|
}
|
|
|
|
// 不出
|
|
pass(playerId) {
|
|
this.passCount++;
|
|
this.lastAction = 'pass';
|
|
|
|
// 如果连续两人不出,清空桌面
|
|
if (this.passCount >= 2) {
|
|
this.desktopCards = null;
|
|
this.passCount = 0;
|
|
this.currentPlayer = (playerId + 1) % 3;
|
|
return { success: true, clearDesktop: true, nextPlayer: this.currentPlayer };
|
|
}
|
|
|
|
this.currentPlayer = (playerId + 1) % 3;
|
|
return { success: true, nextPlayer: this.currentPlayer };
|
|
}
|
|
|
|
// AI决策
|
|
aiPlay() {
|
|
const aiPlayer = this.players[this.currentPlayer];
|
|
const ai = aiPlayer.ai;
|
|
|
|
const teammateCards = this.currentPlayer === 1
|
|
? this.players[2].cards.length
|
|
: this.players[1].cards.length;
|
|
|
|
const decision = ai.makeDecision(
|
|
aiPlayer.cards,
|
|
this.desktopCards,
|
|
aiPlayer.isLord,
|
|
teammateCards
|
|
);
|
|
|
|
if (decision.action === 'play') {
|
|
return this.playCards(this.currentPlayer, decision.cards);
|
|
} else {
|
|
return this.pass(this.currentPlayer);
|
|
}
|
|
}
|
|
|
|
// 获取游戏状态
|
|
getState() {
|
|
return {
|
|
phase: this.gamePhase,
|
|
currentPlayer: this.currentPlayer,
|
|
desktopCards: this.desktopCards,
|
|
players: this.players.map(p => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
isLord: p.isLord,
|
|
cardCount: p.cards.length,
|
|
cards: p.id === 0 ? p.cards : null
|
|
}))
|
|
};
|
|
}
|
|
}
|
|
|
|
// 导出
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { DoudizhuGame, DoudizhuAI, identifyCardType, canBeat, CARD_TYPES };
|
|
}
|