360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
// 遊戲引擎核心 (Liar's Bar)
|
|
// 規則與 Python 版 (game.py) 對齊:
|
|
// - 每手每人 5 張,牌組依玩家人數等比擴充(Q:K:A 等量 + 約 10% Joker),支援 2-8 人
|
|
// - 每次可出 1-3 張,宣稱皆為目標牌;Joker 為萬能牌
|
|
// - 下一位玩家可選擇出牌或質疑「上一組」出的牌
|
|
// - 質疑:整組牌全為目標牌/Joker 則質疑失敗(質疑者開槍),否則質疑成功(出牌者開槍)
|
|
// - 質疑後本手結束,存活玩家重新發牌並選新目標牌
|
|
// - 特殊:輪到某玩家時若其他存活玩家已無牌,該玩家人餘牌視為自動打出並受系統質疑
|
|
// - 僅存一位存活者時結束
|
|
|
|
const HAND_SIZE = 5;
|
|
const REVOLVER_CHAMBERS = 6;
|
|
const TARGETS = ['Q', 'K', 'A'];
|
|
const MAX_PLAY_PER_TURN = 3;
|
|
|
|
class GameEngine {
|
|
constructor() {
|
|
this.players = new Map(); // id -> { id, hand, revolver, alive }
|
|
this.currentPlayerId = null;
|
|
this.targetCard = null;
|
|
this.playGroups = []; // 本手連續出牌組 [{ playerId, cards }]
|
|
this.roundNumber = 0;
|
|
this.isGameOver = false;
|
|
this.winnerId = null;
|
|
this.lastShooterId = null; // 上一手開槍者(存活時作為下一手起始)
|
|
}
|
|
|
|
// ---------- 初始化 ----------
|
|
|
|
init(playerIds) {
|
|
this.players.clear();
|
|
this.currentPlayerId = null;
|
|
this.targetCard = null;
|
|
this.playGroups = [];
|
|
this.roundNumber = 0;
|
|
this.isGameOver = false;
|
|
this.winnerId = null;
|
|
this.lastShooterId = null;
|
|
|
|
for (const id of playerIds) {
|
|
this.players.set(id, {
|
|
id,
|
|
hand: [],
|
|
revolver: this.createRevolver(),
|
|
alive: true
|
|
});
|
|
}
|
|
|
|
this.roundNumber = 1;
|
|
this.dealCards(this.getAlivePlayers().length);
|
|
this.setTargetCard();
|
|
this.currentPlayerId = this.pickRandomAlivePlayer();
|
|
return this.getGameState();
|
|
}
|
|
|
|
// 依存活人數建立等比牌組(每人 5 張)
|
|
createDeck(playerCount) {
|
|
const total = playerCount * HAND_SIZE;
|
|
const jokerCount = Math.max(1, Math.round(total * 0.1));
|
|
const normalTotal = total - jokerCount;
|
|
const base = Math.floor(normalTotal / 3);
|
|
let remainder = normalTotal % 3;
|
|
const counts = { Q: base, K: base, A: base };
|
|
for (const key of TARGETS) {
|
|
if (remainder > 0) { counts[key] += 1; remainder -= 1; }
|
|
}
|
|
const deck = [];
|
|
for (const key of Object.keys(counts)) {
|
|
for (let i = 0; i < counts[key]; i++) deck.push(key);
|
|
}
|
|
for (let i = 0; i < jokerCount; i++) deck.push('JOKER');
|
|
this.shuffle(deck);
|
|
return deck;
|
|
}
|
|
|
|
// ---------- 基本工具 ----------
|
|
|
|
shuffle(deck) {
|
|
for (let i = deck.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[deck[i], deck[j]] = [deck[j], deck[i]];
|
|
}
|
|
return deck;
|
|
}
|
|
|
|
createRevolver() {
|
|
const chambers = Array(REVOLVER_CHAMBERS).fill(false);
|
|
chambers[Math.floor(Math.random() * REVOLVER_CHAMBERS)] = true;
|
|
return { chambers, currentChamber: 0 };
|
|
}
|
|
|
|
setTargetCard() {
|
|
this.targetCard = TARGETS[Math.floor(Math.random() * TARGETS.length)];
|
|
}
|
|
|
|
getAlivePlayers() {
|
|
return Array.from(this.players.values()).filter((p) => p.alive);
|
|
}
|
|
|
|
getPlayer(playerId) {
|
|
return this.players.get(playerId);
|
|
}
|
|
|
|
pickRandomAlivePlayer() {
|
|
const alive = this.getAlivePlayers();
|
|
return alive.length ? alive[Math.floor(Math.random() * alive.length)].id : null;
|
|
}
|
|
|
|
// 目前玩家的下一位「有牌且存活」的玩家
|
|
getNextAlivePlayerWithCards() {
|
|
const ids = Array.from(this.players.keys());
|
|
if (ids.length === 0) return null;
|
|
const curIdx = ids.indexOf(this.currentPlayerId);
|
|
for (let i = 1; i <= ids.length; i++) {
|
|
const player = this.players.get(ids[(curIdx + i) % ids.length]);
|
|
if (player && player.alive && player.hand.length > 0) return player.id;
|
|
}
|
|
// 所有人手上都無牌:回傳目前玩家(由呼叫端處理)
|
|
return this.currentPlayerId;
|
|
}
|
|
|
|
isAllOthersEmpty(playerId) {
|
|
return this.getAlivePlayers()
|
|
.filter((p) => p.id !== playerId)
|
|
.every((p) => p.hand.length === 0);
|
|
}
|
|
|
|
// ---------- 回合流程 ----------
|
|
|
|
// 出牌:cardIndices 為 1-3 個手牌索引(此輪宣稱皆為目標牌)
|
|
playCard(playerId, cardIndices) {
|
|
if (this.isGameOver) return { error: '遊戲已結束' };
|
|
const player = this.getPlayer(playerId);
|
|
if (!player || !player.alive) return { error: '玩家已死亡' };
|
|
if (playerId !== this.currentPlayerId) return { error: '還沒輪到你出牌' };
|
|
|
|
if (!Array.isArray(cardIndices) || cardIndices.length < 1 || cardIndices.length > MAX_PLAY_PER_TURN) {
|
|
return { error: `每次只能出 1-${MAX_PLAY_PER_TURN} 張` };
|
|
}
|
|
const unique = new Set(cardIndices);
|
|
if (unique.size !== cardIndices.length) {
|
|
return { error: '不能重複選擇同一張牌' };
|
|
}
|
|
for (const idx of cardIndices) {
|
|
if (!Number.isInteger(idx) || idx < 0 || idx >= player.hand.length) {
|
|
return { error: '無效的選牌索引' };
|
|
}
|
|
}
|
|
|
|
const played = cardIndices.map((idx) => player.hand[idx]);
|
|
// 由大到小移除,避免索引位移
|
|
[...cardIndices].sort((a, b) => b - a).forEach((idx) => player.hand.splice(idx, 1));
|
|
this.playGroups.push({ playerId: player.id, cards: played });
|
|
|
|
this.currentPlayerId = this.getNextAlivePlayerWithCards();
|
|
|
|
const baseResult = {
|
|
success: true,
|
|
playedGroup: { playerId: player.id, cards: played },
|
|
nextPlayerId: this.currentPlayerId
|
|
};
|
|
|
|
// 特殊規則:輪到 nextPlayer 時其他存活玩家已無牌 → 系統質疑(自動打出剩餘手牌)
|
|
const allAliveEmpty = this.getAlivePlayers().every((p) => p.hand.length === 0);
|
|
const othersEmpty = !allAliveEmpty
|
|
&& this.currentPlayerId !== null
|
|
&& this.getPlayer(this.currentPlayerId).hand.length > 0
|
|
&& this.isAllOthersEmpty(this.currentPlayerId);
|
|
|
|
if (othersEmpty) {
|
|
const sys = this.systemChallenge(this.currentPlayerId);
|
|
baseResult.systemChallenge = sys;
|
|
} else if (allAliveEmpty) {
|
|
// 所有人同時無牌(理論上不會發生)→ 視為無效質疑,重開一手
|
|
baseResult.autoRoundReset = true;
|
|
this.startNewRound();
|
|
}
|
|
|
|
return baseResult;
|
|
}
|
|
|
|
// 系統質疑:自動打出某玩家剩餘手牌並質疑
|
|
systemChallenge(playerId) {
|
|
const player = this.getPlayer(playerId);
|
|
const cards = [...player.hand];
|
|
player.hand = [];
|
|
const valid = cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER');
|
|
const isBluff = !valid;
|
|
|
|
let shooter = null;
|
|
let shot = null;
|
|
if (isBluff) {
|
|
// 剩餘手牌有假牌 → 出牌者開槍
|
|
shooter = playerId;
|
|
shot = this.fireRevolver(playerId);
|
|
} else {
|
|
// 全是真牌 → 系統質疑失敗(與 Python 一致:無人開槍)
|
|
shot = { playerId, hasBullet: false, alive: player.alive };
|
|
}
|
|
|
|
this.clearTable();
|
|
this.startNewRound();
|
|
|
|
return {
|
|
systemChallenge: true,
|
|
playerId,
|
|
autoPlayedCards: cards,
|
|
isBluff,
|
|
shooter,
|
|
shot
|
|
};
|
|
}
|
|
|
|
// 質疑上一組出的牌(由下一位玩家執行)
|
|
challenge(challengerId) {
|
|
if (this.isGameOver) return { error: '遊戲已結束' };
|
|
const challenger = this.getPlayer(challengerId);
|
|
if (!challenger || !challenger.alive) return { error: '挑戰者已死亡' };
|
|
if (challengerId !== this.currentPlayerId) return { error: '還沒輪到你質疑' };
|
|
|
|
const last = this.playGroups[this.playGroups.length - 1];
|
|
if (!last) return { error: '還沒有出牌可以質疑' };
|
|
|
|
const valid = last.cards.length > 0 && last.cards.every((c) => c === this.targetCard || c === 'JOKER');
|
|
const isBluff = !valid;
|
|
|
|
const result = {
|
|
challengerId,
|
|
playerId: last.playerId,
|
|
isBluff,
|
|
challengedGroup: last.cards
|
|
};
|
|
|
|
if (isBluff) {
|
|
// 質疑成功 → 出牌者開槍
|
|
result.shooter = last.playerId;
|
|
result.shot = this.fireRevolver(last.playerId);
|
|
} else {
|
|
// 質疑失敗 → 質疑者開槍
|
|
result.shooter = challengerId;
|
|
result.shot = this.fireRevolver(challengerId);
|
|
}
|
|
|
|
this.clearTable();
|
|
// 質疑後本手結束,重開一手(若遊戲還未結束)
|
|
this.startNewRound();
|
|
return result;
|
|
}
|
|
|
|
// 重開一手:重新發牌、選目標牌、決定起始玩家
|
|
startNewRound() {
|
|
const alive = this.getAlivePlayers();
|
|
if (alive.length <= 1) {
|
|
this.isGameOver = true;
|
|
this.winnerId = alive.length === 1 ? alive[0].id : null;
|
|
return;
|
|
}
|
|
this.roundNumber += 1;
|
|
this.clearTable();
|
|
this.setTargetCard();
|
|
this.dealCards(alive.length);
|
|
|
|
// 起始玩家:上一手開槍者(若存活),否則隨機
|
|
if (this.lastShooterId && this.getPlayer(this.lastShooterId) && this.getPlayer(this.lastShooterId).alive) {
|
|
this.currentPlayerId = this.lastShooterId;
|
|
} else {
|
|
this.currentPlayerId = this.pickRandomAlivePlayer();
|
|
}
|
|
}
|
|
|
|
clearTable() {
|
|
this.playGroups = [];
|
|
}
|
|
|
|
dealCards(aliveCount) {
|
|
for (const p of this.players.values()) p.hand = [];
|
|
const deck = this.createDeck(aliveCount);
|
|
for (let round = 0; round < HAND_SIZE; round++) {
|
|
for (const id of this.players.keys()) {
|
|
const p = this.players.get(id);
|
|
if (p.alive && deck.length > 0) p.hand.push(deck.pop());
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- 射擊 / 結束 ----------
|
|
|
|
fireRevolver(playerId) {
|
|
const player = this.getPlayer(playerId);
|
|
if (!player) return null;
|
|
const revolver = player.revolver;
|
|
const hasBullet = revolver.chambers[revolver.currentChamber];
|
|
revolver.currentChamber = (revolver.currentChamber + 1) % REVOLVER_CHAMBERS;
|
|
|
|
if (hasBullet) {
|
|
player.alive = false;
|
|
player.revolver = this.createRevolver(); // 死亡後重置左輪手槍
|
|
}
|
|
|
|
this.lastShooterId = playerId;
|
|
this.checkGameOver();
|
|
|
|
return { playerId, hasBullet, alive: player.alive, isGameOver: this.isGameOver };
|
|
}
|
|
|
|
checkGameOver() {
|
|
if (this.getAlivePlayers().length <= 1) {
|
|
this.isGameOver = true;
|
|
const alive = this.getAlivePlayers();
|
|
this.winnerId = alive.length === 1 ? alive[0].id : null;
|
|
}
|
|
}
|
|
|
|
removePlayer(playerId) {
|
|
const player = this.getPlayer(playerId);
|
|
if (player) {
|
|
player.alive = false;
|
|
player.hand = [];
|
|
this.checkGameOver();
|
|
}
|
|
}
|
|
|
|
// ---------- 對外狀態 ----------
|
|
|
|
getGameState() {
|
|
const state = {
|
|
players: [],
|
|
currentPlayerId: this.currentPlayerId,
|
|
targetCard: this.targetCard,
|
|
playedCards: [],
|
|
playedGroups: this.playGroups.map((g) => ({ playerId: g.playerId, cardCount: g.cards.length })),
|
|
roundNumber: this.roundNumber,
|
|
isGameOver: this.isGameOver,
|
|
winnerId: this.winnerId
|
|
};
|
|
for (const [id, player] of this.players) {
|
|
state.players.push({
|
|
id,
|
|
handSize: player.hand.length,
|
|
alive: player.alive,
|
|
revolverChambers: player.revolver.currentChamber
|
|
});
|
|
}
|
|
// 資訊邊界:公開狀態只顯示「誰出了幾張」,不洩漏真實牌值;
|
|
// 真實牌值只在質疑(challenge / systemChallenge)時透過 challenged 事件揭露。
|
|
return state;
|
|
}
|
|
|
|
getPlayerHand(playerId) {
|
|
const player = this.getPlayer(playerId);
|
|
if (!player) return [];
|
|
return [...player.hand];
|
|
}
|
|
|
|
isBluff(cards) {
|
|
return !(cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER'));
|
|
}
|
|
}
|
|
|
|
module.exports = GameEngine; |