斗地主Fight the Landlord
by SwiftGalaxy491416 lines43.8 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>斗地主Fight the Landlord</title>
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0; padding: 0; height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: radial-gradient(ellipse at 50% 40%, #1b6b3a 0%, #0d3d21 60%, #062512 100%);
color: #fff; overflow: hidden; user-select: none;
}
#app { position: relative; display: flex; flex-direction: column; height: 100vh; width: 100vw; }
.top-row {
flex: 0 0 auto;
display: flex; justify-content: space-between; align-items: flex-start;
padding: 8px 10px 0;
gap: 6px;
}
.panel { width: 31%; max-width: 280px; min-width: 110px; }
.panel.right { text-align: right; }
.player-tag {
display: inline-flex; align-items: center; gap: 6px;
background: rgba(0,0,0,.45);
border: 1px solid rgba(255,255,255,.16);
border-radius: 20px; padding: 5px 12px;
font-size: 13px; white-space: nowrap;
backdrop-filter: blur(4px);
}
.player-tag.landlord { border-color: #ffcc33; box-shadow: 0 0 10px rgba(255,204,51,.5); }
.player-tag .badge {
background: linear-gradient(180deg,#ffd75e,#e8a400); color:#4a2c00;
font-size: 11px; font-weight: bold; padding: 1px 6px; border-radius: 8px;
}
.player-tag .badge.blue {
background: linear-gradient(180deg,#9fd8ff,#4a9fe8); color:#00243d;
}
.player-tag .badge.gray {
background: linear-gradient(180deg,#cfcfcf,#8d8d8d); color:#222;
}
.player-tag .cnt { color: #9fe0b6; font-size: 12px; }
.player-tag.thinking { animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
.played {
display: flex; flex-wrap: wrap; gap: 2px;
margin-top: 6px; min-height: 52px;
align-items: center;
}
.panel.right .played { justify-content: flex-end; }
.mid {
flex: 1 1 auto; position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; min-height: 0;
}
/* ============ 底牌区域(可收起 / 展开) ============ */
.bottom-cards-wrap {
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.bottom-cards { display: flex; gap: 4px; }
.bottom-toggle {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(0,0,0,.38);
border: 1px solid rgba(255,255,255,.14);
border-radius: 13px;
padding: 3px 11px;
font-size: 12px; color: #8ecfa8; letter-spacing: 2px;
line-height: 1.4;
transition: background .15s, border-color .15s, transform .15s, color .15s;
}
.bottom-toggle .arrow {
display: inline-block;
font-size: 11px; letter-spacing: 0;
transition: transform .22s ease;
}
.bottom-toggle.clickable {
cursor: pointer;
border-color: rgba(255,210,77,.5);
color: #ffe9a8;
box-shadow: 0 0 8px rgba(255,210,77,.18);
}
.bottom-toggle.clickable:hover { background: rgba(0,0,0,.62); }
.bottom-toggle.clickable:active { transform: scale(.94); }
.bottom-cards-wrap.collapsed .bottom-cards { display: none; }
.bottom-cards-wrap.collapsed .arrow { transform: rotate(-90deg); }
.center-play {
display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
gap: 2px; min-height: 60px; max-width: 92vw;
}
.card {
position: relative;
flex: 0 0 auto;
width: 62px; height: 88px;
background: linear-gradient(180deg, #ffffff 0%, #f3f3f3 100%);
border-radius: 7px;
border: 1px solid #adadad;
box-shadow: 0 1px 4px rgba(0,0,0,.42), inset 0 0 0 1px rgba(255,255,255,.7);
overflow: hidden;
color: #1a1a1a;
transition: transform .12s ease, box-shadow .12s ease;
}
.card.red { color: #d21f26; }
.card.black { color: #1a1a1a; }
.card .corner {
position: absolute;
display: flex; flex-direction: column; align-items: center;
line-height: 1;
font-weight: 800;
font-family: "Arial Black", Arial, "Helvetica Neue", sans-serif;
letter-spacing: -0.5px;
}
.card .corner .r { font-size: 16px; }
.card .corner .s { font-size: 13px; margin-top: 1px; }
.card .corner.tl { top: 3px; left: 4px; }
.card .corner.br { bottom: 3px; right: 4px; transform: rotate(180deg); }
.card .pip {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 30px; opacity: .88;
}
.card .pip.joker-pip { font-size: 26px; font-weight: 800; }
.card.back {
background: repeating-linear-gradient(45deg, #2b4d94 0 6px, #24407c 6px 12px);
border: 1px solid #16295a;
box-shadow: 0 1px 4px rgba(0,0,0,.5);
}
.card.back::after {
content: ""; position: absolute; inset: 4px;
border: 1px solid rgba(255,255,255,.22); border-radius: 4px;
}
.card.small { width: 46px; height: 66px; border-radius: 5px; }
.card.small .corner.tl { top: 2px; left: 3px; }
.card.small .corner.br { bottom: 2px; right: 3px; }
.card.small .corner .r { font-size: 12px; }
.card.small .corner .s { font-size: 10px; margin-top: 0; }
.card.small .pip { font-size: 20px; }
.card.small .pip.joker-pip { font-size: 17px; }
.bottom-area { flex: 0 0 auto; padding-bottom: 4px; }
.hand {
display: flex; justify-content: center; align-items: flex-end;
height: 118px; padding: 26px 6px 0;
position: relative; overflow: visible;
}
.hand .card { cursor: pointer; }
.hand .card:hover { transform: translateY(-6px); }
.hand .card.sel {
transform: translateY(-22px);
box-shadow: 0 8px 16px rgba(255, 215, 0, .9), 0 0 0 2px #ffd24d;
}
.hand .card.sel:hover { transform: translateY(-24px); }
.controls {
display: flex; justify-content: center; align-items: center; gap: 12px;
padding: 4px 10px 12px;
flex-wrap: wrap;
}
.btn {
padding: 9px 22px; border-radius: 22px; border: 1px solid rgba(255,255,255,.3);
background: rgba(255,255,255,.12); color: #fff;
font-size: 15px; font-weight: 600; cursor: pointer;
transition: all .15s; backdrop-filter: blur(4px);
font-family: inherit;
}
.btn:hover:not(:disabled) { background: rgba(255,255,255,.24); transform: translateY(-1px); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .32; cursor: not-allowed; }
.btn.primary {
background: linear-gradient(180deg, #ffd75e, #e8a400);
color: #4a2c00; border-color: #ffdf7e;
box-shadow: 0 3px 10px rgba(232,164,0,.4);
}
.btn.primary:hover:not(:disabled) { background: linear-gradient(180deg, #ffe58c, #f5b41a); }
.btn.sort { background: rgba(80,180,255,.2); border-color: rgba(120,200,255,.5); }
#toast {
position: fixed; left: 50%; bottom: 42%; transform: translateX(-50%);
background: rgba(0,0,0,.84); color: #ffe9a8;
padding: 10px 24px; border-radius: 20px; font-size: 15px;
opacity: 0; pointer-events: none; transition: opacity .25s;
z-index: 500; white-space: nowrap; border: 1px solid rgba(255,210,77,.4);
}
#toast.show { opacity: 1; }
#overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.72);
display: none; align-items: center; justify-content: center;
z-index: 900; backdrop-filter: blur(3px);
padding: 12px;
}
#overlay.show { display: flex; }
.result-box {
background: linear-gradient(180deg, #1e5c38, #0e3620);
border: 2px solid #ffd24d; border-radius: 18px;
padding: 26px 30px;
text-align: center;
box-shadow: 0 12px 40px rgba(0,0,0,.6);
animation: pop .3s ease;
max-width: min(94vw, 560px);
max-height: 90vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@keyframes pop { from { transform: scale(.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.result-box h1 { margin: 0 0 8px; font-size: 30px; color: #ffd24d; letter-spacing: 4px; }
.result-box p { margin: 0 0 16px; font-size: 14px; color: #a9e3c0; line-height: 1.6; }
/* ============ ★ 结算时的“剩余手牌”展示区 ============ */
.reveal-section {
display: flex; flex-direction: column; gap: 12px;
margin: 0 0 20px;
padding: 12px 12px 14px;
background: rgba(0,0,0,.24);
border: 1px solid rgba(255,255,255,.09);
border-radius: 12px;
text-align: left;
}
.reveal-title {
font-size: 11px; color: #8ecfa8; letter-spacing: 3px;
text-align: center;
padding-bottom: 2px;
border-bottom: 1px dashed rgba(255,255,255,.12);
}
.reveal-row { display: flex; flex-direction: column; gap: 5px; }
.reveal-name {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: #a9e3c0;
}
.reveal-name .who { font-weight: 700; }
.reveal-name.landlord .who { color: #ffd24d; }
.reveal-name.winner .who { color: #7dffb0; }
.reveal-name .n {
margin-left: auto;
font-size: 11px; color: #7fb894;
background: rgba(255,255,255,.07);
padding: 1px 7px; border-radius: 8px;
}
.reveal-cards {
display: flex; flex-wrap: wrap; gap: 4px 3px;
min-height: 22px;
}
.reveal-empty { font-size: 12px; color: #6fae86; padding: 4px 2px; font-style: italic; }
.tag-you { color: #ffd24d; }
@media (max-width: 560px) {
.card { width: 52px; height: 74px; border-radius: 6px; }
.card .corner .r { font-size: 13px; }
.card .corner .s { font-size: 11px; }
.card .corner.tl { top: 2px; left: 3px; }
.card .corner.br { bottom: 2px; right: 3px; }
.card .pip { font-size: 24px; }
.card .pip.joker-pip { font-size: 21px; }
.card.small { width: 40px; height: 58px; border-radius: 5px; }
.card.small .corner .r { font-size: 11px; }
.card.small .corner .s { font-size: 9px; }
.card.small .pip { font-size: 17px; }
.card.small .pip.joker-pip { font-size: 14px; }
.hand { height: 104px; padding: 24px 4px 0; }
.hand .card.sel { transform: translateY(-18px); }
.hand .card.sel:hover { transform: translateY(-18px); }
.btn { padding: 8px 16px; font-size: 13px; }
.player-tag { font-size: 11px; padding: 4px 9px; }
.played { min-height: 46px; }
.bottom-toggle { font-size: 11px; padding: 2px 9px; gap: 4px; }
.bottom-toggle .arrow { font-size: 10px; }
.result-box { padding: 20px 16px; }
.result-box h1 { font-size: 24px; letter-spacing: 2px; }
.result-box p { font-size: 13px; margin-bottom: 12px; }
.reveal-section { padding: 10px; gap: 10px; }
}
</style>
</head>
<body>
<div id="app">
<div class="top-row">
<div class="panel left">
<div class="player-tag" id="tag-2"><span class="name">左家</span></div>
<div class="played" id="played-2"></div>
</div>
<div class="bottom-cards-wrap" id="bottomWrap">
<div class="bottom-toggle" id="bottomToggle">
<span class="txt">底 牌</span><span class="arrow">▾</span>
</div>
<div class="bottom-cards" id="bottomCards"></div>
</div>
<div class="panel right">
<div class="player-tag" id="tag-1"><span class="name">右家</span></div>
<div class="played" id="played-1"></div>
</div>
</div>
<div class="mid">
<div class="center-play" id="played-0"></div>
</div>
<div class="bottom-area">
<div class="hand" id="myHand"></div>
<div class="controls" id="controls"></div>
</div>
</div>
<div id="toast"></div>
<div id="overlay"><div class="result-box" id="resultBox"></div></div>
<script>
/* =========================================================
基础数据
========================================================= */
const SUITS = ['♠', '♥', '♣', '♦'];
const SUIT_ORDER = { '♠': 0, '♥': 1, '♣': 2, '♦': 3, 'joker': 4 };
const RANK_NAME = { 3:'3',4:'4',5:'5',6:'6',7:'7',8:'8',9:'9',10:'10',11:'J',12:'Q',13:'K',14:'A',15:'2' };
const NAMES = ['你', '右家', '左家'];
/* 叫地主 AI 门槛 */
const BID_THRESHOLD = { call: 21, rob: 28 };
function createDeck() {
const deck = [];
let id = 0;
for (let v = 3; v <= 15; v++) {
for (const s of SUITS) deck.push({ id: id++, value: v, rank: RANK_NAME[v], suit: s });
}
deck.push({ id: id++, value: 16, rank: '小王', suit: 'joker' });
deck.push({ id: id++, value: 17, rank: '大王', suit: 'joker' });
return deck;
}
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/* =========================================================
牌型判定
========================================================= */
function isConsec(vals) {
for (let i = 1; i < vals.length; i++) if (vals[i] !== vals[i - 1] + 1) return false;
return true;
}
function findSeq(arr, m) {
for (let i = 0; i + m <= arr.length; i++) {
let ok = true;
for (let j = 1; j < m; j++) {
if (arr[i + j] !== arr[i + j - 1] + 1) { ok = false; break; }
}
if (ok) return arr.slice(i, i + m);
}
return null;
}
function getType(cards) {
if (!cards || cards.length === 0) return null;
const n = cards.length;
const cnt = new Map();
for (const c of cards) cnt.set(c.value, (cnt.get(c.value) || 0) + 1);
const vals = [...cnt.keys()].sort((a, b) => a - b);
const C = v => cnt.get(v) || 0;
if (n === 2 && C(16) === 1 && C(17) === 1) return { type: 'rocket', value: 100, size: 2 };
if (n === 4 && vals.length === 1) return { type: 'bomb', value: vals[0], size: 4 };
if (n === 1) return { type: 'single', value: vals[0], size: 1 };
if (n === 2 && vals.length === 1) return { type: 'pair', value: vals[0], size: 2 };
if (n === 3 && vals.length === 1) return { type: 'trio', value: vals[0], size: 3 };
if (n === 4 && vals.length === 2) {
const t = vals.find(v => C(v) === 3);
if (t !== undefined) return { type: 'trio1', value: t, size: 4 };
}
if (n === 5 && vals.length === 2) {
const t = vals.find(v => C(v) === 3);
if (t !== undefined && vals.some(v => C(v) === 2)) return { type: 'trio2', value: t, size: 5 };
}
if (n >= 5 && vals.length === n && vals[n - 1] <= 14 && isConsec(vals)) {
return { type: 'straight', value: vals[n - 1], size: n };
}
if (n >= 6 && n % 2 === 0 && vals.length === n / 2 &&
vals.every(v => C(v) === 2) && vals[vals.length - 1] <= 14 && isConsec(vals)) {
return { type: 'pairseq', value: vals[vals.length - 1], size: n };
}
const triVals = vals.filter(v => C(v) >= 3 && v <= 14);
for (let m = Math.floor(n / 3); m >= 2; m--) {
if (n !== 3 * m) continue;
const s = findSeq(triVals, m);
if (s) return { type: 'plane', value: s[s.length - 1], size: n, m };
}
for (let m = Math.floor(n / 4); m >= 2; m--) {
if (n !== 4 * m) continue;
const s = findSeq(triVals, m);
if (!s) continue;
const rem = new Map(cnt);
s.forEach(v => rem.set(v, rem.get(v) - 3));
let ok = true, total = 0;
rem.forEach((c) => {
if (c <= 0) return;
total += c;
if (c >= 3) ok = false;
});
const rocketInWing = (rem.get(16) || 0) > 0 && (rem.get(17) || 0) > 0;
if (ok && total === m && !rocketInWing) {
return { type: 'plane1', value: s[s.length - 1], size: n, m };
}
}
for (let m = Math.floor(n / 5); m >= 2; m--) {
if (n !== 5 * m) continue;
const s = findSeq(triVals, m);
if (!s) continue;
const rem = new Map(cnt);
s.forEach(v => rem.set(v, rem.get(v) - 3));
let ok = true, pairs = 0;
rem.forEach(c => { if (c > 0) { if (c !== 2) ok = false; else pairs++; } });
if (ok && pairs === m) return { type: 'plane2', value: s[s.length - 1], size: n, m };
}
if (n === 6) {
const q = vals.find(v => C(v) === 4);
if (q !== undefined) {
const rem = new Map(cnt); rem.set(q, 0);
let total = 0; rem.forEach(c => total += c);
const rocket = (rem.get(16) || 0) > 0 && (rem.get(17) || 0) > 0;
if (total === 2 && !rocket) return { type: 'four2', value: q, size: 6 };
}
}
if (n === 8) {
const q = vals.find(v => C(v) === 4);
if (q !== undefined) {
const rem = new Map(cnt); rem.set(q, 0);
let ok = true, pairs = 0;
rem.forEach(c => { if (c > 0) { if (c !== 2) ok = false; else pairs++; } });
if (ok && pairs === 2) return { type: 'four2p', value: q, size: 8 };
}
}
return null;
}
function canBeat(a, b) {
if (!a) return false;
if (!b) return true;
if (a.type === 'rocket') return true;
if (b.type === 'rocket') return false;
if (a.type === 'bomb' && b.type === 'bomb') return a.value > b.value;
if (a.type === 'bomb') return true;
if (b.type === 'bomb') return false;
if (a.type !== b.type) return false;
if (a.size !== b.size) return false;
return a.value > b.value;
}
/* =========================================================
游戏状态
========================================================= */
const G = {
hands: [[], [], []],
bottom: [],
landlord: -1,
current: 0,
lastPlay: null,
tableCards: [[], [], []],
passShown: [false, false, false],
phase: 'idle',
selected: new Set(),
sortMode: 'value',
bid: null,
bidOptions: null,
bidMark: ['', '', ''],
playCount: [0, 0, 0],
aiTilt: [0, 0, 0],
bottomOpen: true,
};
/* =========================================================
DOM
========================================================= */
const $ = id => document.getElementById(id);
const elHand = $('myHand');
const elControls = $('controls');
const elPlayed = [ $('played-0'), $('played-1'), $('played-2') ];
const elTag = [ null, $('tag-1'), $('tag-2') ];
const elBottom = $('bottomCards');
const elBottomWrap = $('bottomWrap');
const elBottomToggle = $('bottomToggle');
const elToast = $('toast');
const elOverlay = $('overlay');
const elResult = $('resultBox');
let toastTimer = null;
function toast(msg) {
elToast.textContent = msg;
elToast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => elToast.classList.remove('show'), 1600);
}
/* 点击箭头:展开 / 收起底牌面板 */
elBottomToggle.addEventListener('click', () => {
if (G.landlord === -1) return;
G.bottomOpen = !G.bottomOpen;
renderBottom();
});
/* =========================================================
牌面渲染
========================================================= */
function cardEl(card, faceUp, small) {
const d = document.createElement('div');
d.className = 'card' + (small ? ' small' : '');
if (!faceUp) { d.classList.add('back'); return d; }
const isJoker = card.value >= 16;
const red = isJoker ? (card.value === 17) : (card.suit === '♥' || card.suit === '♦');
d.classList.add(red ? 'red' : 'black');
if (isJoker) {
const t = card.value === 17 ? '大' : '小';
d.innerHTML =
`<div class="corner tl"><span class="r">${t}</span><span class="s">王</span></div>` +
`<div class="pip joker-pip">${t}王</div>` +
`<div class="corner br"><span class="r">${t}</span><span class="s">王</span></div>`;
} else {
d.innerHTML =
`<div class="corner tl"><span class="r">${card.rank}</span><span class="s">${card.suit}</span></div>` +
`<div class="pip">${card.suit}</div>` +
`<div class="corner br"><span class="r">${card.rank}</span><span class="s">${card.suit}</span></div>`;
}
return d;
}
/* =========================================================
排序
========================================================= */
function sortHand(hand) {
if (G.sortMode === 'value') {
hand.sort((a, b) => b.value - a.value || SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit]);
} else {
const cnt = {};
hand.forEach(c => cnt[c.value] = (cnt[c.value] || 0) + 1);
const cat = c => {
const n = cnt[c.value];
if (n >= 4) return 0;
if (n === 3) return 1;
if (n === 2) return 2;
return 3;
};
hand.sort((a, b) =>
cat(a) - cat(b) || b.value - a.value || SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit]);
}
}
/* 统一的大→小排序,供结算展示用 */
function sortDesc(hand) {
return hand.slice().sort(
(a, b) => b.value - a.value || SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit]
);
}
/* =========================================================
手牌布局
========================================================= */
function layoutMyHand() {
const cards = [...elHand.children];
const n = cards.length;
if (!n) return;
const cw = cards[0].offsetWidth || 62;
const avail = elHand.clientWidth - 10;
const isPhone = window.innerWidth <= 560;
const minStep = isPhone ? 15 : 21;
let step = (n === 1) ? cw : (avail - cw) / (n - 1);
step = Math.min(step, cw - 2);
step = Math.max(step, minStep);
cards.forEach((c, i) => {
c.style.marginLeft = (i === 0) ? '0px' : (step - cw) + 'px';
});
}
window.addEventListener('resize', () => { layoutMyHand(); });
/* =========================================================
渲染
========================================================= */
function renderMyHand() {
const hand = G.hands[0];
sortHand(hand);
elHand.innerHTML = '';
hand.forEach((card, i) => {
const ce = cardEl(card, true, false);
const sel = G.selected.has(card.id);
ce.style.zIndex = sel ? (200 + i) : i;
if (sel) ce.classList.add('sel');
ce.addEventListener('click', () => {
if (G.phase !== 'play' || G.current !== 0) return;
if (G.selected.has(card.id)) {
G.selected.delete(card.id);
ce.classList.remove('sel');
ce.style.zIndex = i;
} else {
G.selected.add(card.id);
ce.classList.add('sel');
ce.style.zIndex = 200 + i;
}
renderControls();
});
elHand.appendChild(ce);
});
requestAnimationFrame(layoutMyHand);
}
function renderPlayed(p) {
const el = elPlayed[p];
el.innerHTML = '';
const cards = G.tableCards[p];
if (cards && cards.length) {
cards.forEach(c => el.appendChild(cardEl(c, true, true)));
} else if (G.passShown[p]) {
const s = document.createElement('div');
s.style.cssText = 'font-size:14px;color:#ffb0b0;font-weight:bold;letter-spacing:2px;padding:4px 10px;';
s.textContent = '不出';
el.appendChild(s);
}
}
/* 底牌渲染:确定地主后默认收起,只留可点击的箭头标签 */
function renderBottom() {
const revealed = G.landlord !== -1;
elBottom.innerHTML = '';
G.bottom.forEach(c => elBottom.appendChild(cardEl(c, revealed, true)));
elBottomToggle.classList.toggle('clickable', revealed);
elBottomWrap.classList.toggle('collapsed', revealed && !G.bottomOpen);
}
function renderTags() {
for (let p = 1; p <= 2; p++) {
const el = elTag[p];
const isL = p === G.landlord;
el.classList.toggle('landlord', isL);
el.classList.toggle('thinking', G.phase === 'play' && G.current === p);
let extra = '';
if (G.phase === 'bid' && G.bidMark[p]) {
const cls = (G.bidMark[p] === '不叫' || G.bidMark[p] === '不抢') ? 'gray' : 'blue';
extra = `<span class="badge ${cls}">${G.bidMark[p]}</span>`;
}
el.innerHTML =
`<span class="name">${NAMES[p]}</span>` +
`<span class="cnt">${G.hands[p].length}张</span>` +
(isL ? '<span class="badge">地主</span>' : '') +
extra;
}
}
function renderControls() {
elControls.innerHTML = '';
if (G.phase === 'over') {
const b = document.createElement('button');
b.className = 'btn primary';
b.textContent = '再来一局';
b.onclick = () => { elOverlay.classList.remove('show'); newGame(); };
elControls.appendChild(b);
return;
}
if (G.bidOptions && G.bidOptions.length) {
G.bidOptions.forEach(opt => {
const b = document.createElement('button');
b.className = 'btn' + (opt.indexOf('不') === -1 ? ' primary' : '');
b.textContent = opt;
b.onclick = () => {
const action =
opt === '叫地主' ? 'call' :
opt === '抢地主' ? 'rob' :
opt === '不叫' ? 'pass' : 'norob';
G.bidOptions = null;
renderControls();
submitBid(action);
};
elControls.appendChild(b);
});
return;
}
if (G.phase === 'play') {
const sb = document.createElement('button');
sb.className = 'btn sort';
sb.textContent = '理牌:' + (G.sortMode === 'value' ? '按点数' : '按牌型');
sb.onclick = () => {
G.sortMode = G.sortMode === 'value' ? 'type' : 'value';
renderMyHand();
renderControls();
};
elControls.appendChild(sb);
const myTurn = G.current === 0;
const canPass = myTurn && G.lastPlay && G.lastPlay.player !== 0;
const pb = document.createElement('button');
pb.className = 'btn';
pb.textContent = '不出';
pb.disabled = !canPass;
pb.onclick = humanPass;
elControls.appendChild(pb);
const ob = document.createElement('button');
ob.className = 'btn primary';
ob.textContent = '出牌';
ob.disabled = !myTurn;
ob.onclick = humanPlay;
elControls.appendChild(ob);
return;
}
if (G.phase === 'bid') {
const tip = document.createElement('div');
tip.style.cssText = 'font-size:14px;color:#a9e3c0;';
tip.textContent = '等待其他玩家叫地主…';
elControls.appendChild(tip);
return;
}
if (G.phase === 'idle') {
const b = document.createElement('button');
b.className = 'btn primary';
b.textContent = '开始游戏';
b.onclick = () => newGame();
elControls.appendChild(b);
}
}
function render() {
renderMyHand();
renderPlayed(0); renderPlayed(1); renderPlayed(2);
renderBottom();
renderTags();
renderControls();
}
/* =========================================================
发牌
========================================================= */
function newGame() {
G.selected.clear();
G.landlord = -1;
G.lastPlay = null;
G.tableCards = [[], [], []];
G.passShown = [false, false, false];
G.playCount = [0, 0, 0];
G.bidOptions = null;
G.bidMark = ['', '', ''];
G.phase = 'bid';
G.bottomOpen = true;
G.aiTilt = [0, (Math.random() - 0.5) * 6, (Math.random() - 0.5) * 6];
const deck = shuffle(createDeck());
G.hands = [[], [], []];
for (let i = 0; i < 51; i++) G.hands[i % 3].push(deck[i]);
G.bottom = deck.slice(51, 54);
G.hands.forEach(h => sortHand(h));
render();
const start = Math.floor(Math.random() * 3);
startBid(start);
}
/* =========================================================
叫地主 / 抢地主 状态机
========================================================= */
function startBid(start) {
G.bid = {
order: [start, (start + 1) % 3, (start + 2) % 3],
idx: 0,
stage: 'call',
firstCaller: -1,
lastRobber: -1,
robQueue: [],
robIdx: 0,
robRound: 0,
robRoundRobbed: false,
};
G.bidMark = ['', '', ''];
stepBid();
}
function stepBid() {
const B = G.bid;
if (!B || G.phase !== 'bid') return;
if (B.stage === 'call') {
if (B.idx >= B.order.length) {
if (B.firstCaller === -1) {
toast('三家都不叫,重新发牌');
setTimeout(() => { if (G.phase === 'bid') newGame(); }, 1500);
return;
}
B.stage = 'rob';
B.robRound = 1;
B.robQueue = [ (B.firstCaller + 1) % 3, (B.firstCaller + 2) % 3 ];
B.robIdx = 0;
B.robRoundRobbed = false;
stepBid();
return;
}
askBid(B.order[B.idx], 'call');
return;
}
if (B.robIdx >= B.robQueue.length) {
if (B.robRoundRobbed) {
if (B.robRound >= 3) {
finishBid(B.lastRobber);
return;
}
B.robRound++;
B.robQueue = [ (B.lastRobber + 1) % 3, (B.lastRobber + 2) % 3 ];
B.robIdx = 0;
B.robRoundRobbed = false;
stepBid();
return;
}
finishBid(B.lastRobber !== -1 ? B.lastRobber : B.firstCaller);
return;
}
askBid(B.robQueue[B.robIdx], 'rob');
}
function askBid(p, stage) {
if (p === 0) {
G.bidOptions = stage === 'call' ? ['叫地主', '不叫'] : ['抢地主', '不抢'];
renderControls();
} else {
renderControls();
const delay = 550 + Math.random() * 600;
setTimeout(() => {
if (G.phase !== 'bid' || !G.bid) return;
aiBidDecision(p, stage);
}, delay);
}
}
function submitBid(action) {
const B = G.bid;
if (!B || G.phase !== 'bid') return;
let p;
if (B.stage === 'call') p = B.order[B.idx];
else p = B.robQueue[B.robIdx];
if (p === undefined) return;
if (B.stage === 'call') {
if (action === 'call') { B.firstCaller = p; G.bidMark[p] = '叫地主'; }
else { G.bidMark[p] = '不叫'; }
B.idx++;
} else {
if (action === 'rob') {
B.lastRobber = p;
B.robRoundRobbed = true;
G.bidMark[p] = '抢地主';
} else {
G.bidMark[p] = '不抢';
}
B.robIdx++;
}
G.bidOptions = null;
render();
setTimeout(stepBid, 320);
}
function finishBid(landlord) {
G.bid = null;
G.bidOptions = null;
if (landlord === -1) {
toast('无人叫地主,重新发牌');
setTimeout(() => { if (G.phase === 'bid') newGame(); }, 1500);
return;
}
setLandlord(landlord);
}
/* 底牌一定塞进地主手里,然后强制重渲染 */
function setLandlord(p) {
G.landlord = p;
G.bottomOpen = false;
for (const c of G.bottom) G.hands[p].push(c);
sortHand(G.hands[p]);
G.phase = 'play';
G.current = p;
G.lastPlay = null;
G.tableCards = [[], [], []];
G.passShown = [false, false, false];
G.bidOptions = null;
G.bidMark = ['', '', ''];
G.selected.clear();
render();
const n = G.hands[p].length;
toast(`${NAMES[p]} 成为地主 · 手牌 ${n} 张`);
if (p !== 0) setTimeout(aiTurn, 1000);
}
/* =========================================================
AI 叫地主评估函数
========================================================= */
function evaluateHand(hand) {
const cnt = new Map();
for (const c of hand) cnt.set(c.value, (cnt.get(c.value) || 0) + 1);
const C = v => cnt.get(v) || 0;
const vals = [...cnt.keys()].sort((a, b) => a - b);
let score = 0;
score += C(17) * 9;
score += C(16) * 7;
score += C(15) * 4;
score += C(14) * 2.2;
score += C(13) * 1.2;
for (const v of vals) if (C(v) === 4) score += 11;
if (C(16) && C(17)) score += 7;
for (const v of vals) if (v <= 14 && C(v) === 3) score += 2.5;
let run = 0, bestRun = 0;
for (let v = 3; v <= 14; v++) {
if (C(v) >= 1) { run++; if (run > bestRun) bestRun = run; }
else run = 0;
}
if (bestRun >= 5) score += (bestRun - 4) * 1.8;
let prun = 0, bestPrun = 0;
for (let v = 3; v <= 14; v++) {
if (C(v) >= 2) { prun++; if (prun > bestPrun) bestPrun = prun; }
else prun = 0;
}
if (bestPrun >= 3) score += (bestPrun - 2) * 1.5;
let junk = 0;
for (const v of vals) if (C(v) === 1 && v <= 11) junk++;
score -= junk * 1.2;
let smallPairs = 0;
for (const v of vals) if (C(v) === 2 && v <= 10) smallPairs++;
score -= smallPairs * 0.6;
return score;
}
function aiBidDecision(p, stage) {
const base = evaluateHand(G.hands[p]);
const s = base + (G.aiTilt[p] || 0);
let action;
if (stage === 'call') {
action = s >= BID_THRESHOLD.call ? 'call' : 'pass';
} else {
action = s >= BID_THRESHOLD.rob ? 'rob' : 'norob';
}
submitBid(action);
}
/* =========================================================
出牌流程
========================================================= */
function playCards(p, cards) {
if (!cards || cards.length === 0) return;
const type = getType(cards);
if (!type) return;
const ids = new Set(cards.map(c => c.id));
G.hands[p] = G.hands[p].filter(c => !ids.has(c.id));
G.playCount[p]++;
G.lastPlay = { player: p, cards: cards.slice(), type };
G.tableCards = [[], [], []];
G.tableCards[p] = cards.slice();
G.passShown = [false, false, false];
if (p === 0) G.selected.clear();
render();
if (G.hands[p].length === 0) { endGame(p); return; }
G.current = (p + 1) % 3;
render();
if (G.current !== 0) setTimeout(aiTurn, 850);
}
function passTurn(p) {
G.passShown[p] = true;
const next = (p + 1) % 3;
if (G.lastPlay && G.lastPlay.player === next) {
G.tableCards = [[], [], []];
G.passShown = [false, false, false];
} else {
G.tableCards[p] = [];
}
G.current = next;
render();
if (G.phase !== 'play') return;
if (next !== 0) setTimeout(aiTurn, 850);
}
/* =========================================================
玩家操作
========================================================= */
function humanPlay() {
if (G.phase !== 'play' || G.current !== 0) return;
const cards = G.hands[0].filter(c => G.selected.has(c.id));
if (cards.length === 0) { toast('请先选择要出的牌'); return; }
const t = getType(cards);
if (!t) { toast('牌型不合法'); return; }
if (G.lastPlay && G.lastPlay.player !== 0) {
if (!canBeat(t, G.lastPlay.type)) { toast('管不上上家的牌'); return; }
}
playCards(0, cards);
}
function humanPass() {
if (G.phase !== 'play' || G.current !== 0) return;
if (!G.lastPlay || G.lastPlay.player === 0) return;
G.selected.clear();
renderMyHand();
passTurn(0);
}
/* =========================================================
搜索可压制牌型
========================================================= */
function findBeats(hand, lt) {
const res = [];
if (!lt || lt.type === 'rocket') return res;
const byVal = new Map();
hand.forEach(c => {
if (!byVal.has(c.value)) byVal.set(c.value, []);
byVal.get(c.value).push(c);
});
const vals = [...byVal.keys()].sort((a, b) => a - b);
const cnt = v => (byVal.get(v) || []).length;
const take = (v, k) => byVal.get(v).slice(0, k);
switch (lt.type) {
case 'single':
for (const v of vals) if (v > lt.value) res.push(take(v, 1));
break;
case 'pair':
for (const v of vals) if (v > lt.value && cnt(v) >= 2) res.push(take(v, 2));
break;
case 'trio':
for (const v of vals) if (v > lt.value && cnt(v) >= 3) res.push(take(v, 3));
break;
case 'trio1':
for (const v of vals) {
if (v <= lt.value || cnt(v) < 3) continue;
const k = vals.find(x => x !== v && cnt(x) >= 1);
if (k !== undefined) res.push([...take(v, 3), take(k, 1)[0]]);
}
break;
case 'trio2':
for (const v of vals) {
if (v <= lt.value || cnt(v) < 3) continue;
const k = vals.find(x => x !== v && cnt(x) >= 2);
if (k !== undefined) res.push([...take(v, 3), ...take(k, 2)]);
}
break;
case 'straight': {
const len = lt.size;
for (let st = 3; st + len - 1 <= 14; st++) {
const end = st + len - 1;
if (end <= lt.value) continue;
let ok = true;
for (let v = st; v <= end; v++) if (cnt(v) < 1) { ok = false; break; }
if (!ok) continue;
const cards = [];
for (let v = st; v <= end; v++) cards.push(byVal.get(v)[0]);
res.push(cards);
}
break;
}
case 'pairseq': {
const len = lt.size / 2;
for (let st = 3; st + len - 1 <= 14; st++) {
const end = st + len - 1;
if (end <= lt.value) continue;
let ok = true;
for (let v = st; v <= end; v++) if (cnt(v) < 2) { ok = false; break; }
if (!ok) continue;
const cards = [];
for (let v = st; v <= end; v++) cards.push(...take(v, 2));
res.push(cards);
}
break;
}
case 'plane': {
const m = lt.m;
for (let st = 3; st + m - 1 <= 14; st++) {
const end = st + m - 1;
if (end <= lt.value) continue;
let ok = true;
for (let v = st; v <= end; v++) if (cnt(v) < 3) { ok = false; break; }
if (!ok) continue;
const cards = [];
for (let v = st; v <= end; v++) cards.push(...take(v, 3));
res.push(cards);
}
break;
}
case 'plane1': {
const m = lt.m;
for (let st = 3; st + m - 1 <= 14; st++) {
const end = st + m - 1;
if (end <= lt.value) continue;
let ok = true;
for (let v = st; v <= end; v++) if (cnt(v) < 3) { ok = false; break; }
if (!ok) continue;
const used = new Set();
for (let v = st; v <= end; v++) used.add(v);
const wings = vals.filter(v => !used.has(v))
.sort((a, b) => cnt(a) - cnt(b) || a - b);
if (wings.length < m) continue;
const cards = [];
for (let v = st; v <= end; v++) cards.push(...take(v, 3));
for (let i = 0; i < m; i++) cards.push(byVal.get(wings[i])[0]);
res.push(cards);
}
break;
}
case 'plane2': {
const m = lt.m;
for (let st = 3; st + m - 1 <= 14; st++) {
const end = st + m - 1;
if (end <= lt.value) continue;
let ok = true;
for (let v = st; v <= end; v++) if (cnt(v) < 3) { ok = false; break; }
if (!ok) continue;
const used = new Set();
for (let v = st; v <= end; v++) used.add(v);
const wings = vals.filter(v => !used.has(v) && cnt(v) >= 2);
if (wings.length < m) continue;
const cards = [];
for (let v = st; v <= end; v++) cards.push(...take(v, 3));
for (let i = 0; i < m; i++) cards.push(...take(wings[i], 2));
res.push(cards);
}
break;
}
default:
break;
}
for (const v of vals) {
if (cnt(v) === 4) {
if (lt.type !== 'bomb' || v > lt.value) res.push(take(v, 4));
}
}
if (cnt(16) >= 1 && cnt(17) >= 1) res.push([byVal.get(16)[0], byVal.get(17)[0]]);
return res;
}
/* =========================================================
AI 出牌
========================================================= */
function aiChooseLead(p, hand) {
if (hand.length === 0) return [];
const whole = getType(hand);
if (whole) return hand.slice();
const byVal = new Map();
hand.forEach(c => {
if (!byVal.has(c.value)) byVal.set(c.value, []);
byVal.get(c.value).push(c);
});
const vals = [...byVal.keys()].sort((a, b) => a - b);
const cnt = v => (byVal.get(v) || []).length;
for (let len = 8; len >= 5; len--) {
for (let st = 3; st + len - 1 <= 14; st++) {
let ok = true;
for (let v = st; v < st + len; v++) if (cnt(v) < 1) { ok = false; break; }
if (!ok) continue;
let safe = true;
for (let v = st; v < st + len; v++) if (cnt(v) >= 3) { safe = false; break; }
if (!safe) continue;
const cards = [];
for (let v = st; v < st + len; v++) cards.push(byVal.get(v)[0]);
return cards;
}
}
for (let len = 4; len >= 3; len--) {
for (let st = 3; st + len - 1 <= 14; st++) {
let ok = true;
for (let v = st; v < st + len; v++) if (cnt(v) < 2) { ok = false; break; }
if (!ok) continue;
let safe = true;
for (let v = st; v < st + len; v++) if (cnt(v) >= 4) { safe = false; break; }
if (!safe) continue;
const cards = [];
for (let v = st; v < st + len; v++) cards.push(...byVal.get(v).slice(0, 2));
return cards;
}
}
for (let len = 4; len >= 2; len--) {
for (let st = 3; st + len - 1 <= 14; st++) {
let ok = true;
for (let v = st; v < st + len; v++) if (cnt(v) < 3) { ok = false; break; }
if (!ok) continue;
const cards = [];
for (let v = st; v < st + len; v++) cards.push(...byVal.get(v).slice(0, 3));
return cards;
}
}
for (const v of vals) if (cnt(v) === 3) return byVal.get(v).slice(0, 3);
for (const v of vals) if (cnt(v) === 2 && v < 15) return byVal.get(v).slice(0, 2);
for (const v of vals) if (cnt(v) === 1 && v < 16) return [byVal.get(v)[0]];
for (const v of vals) if (cnt(v) === 1) return [byVal.get(v)[0]];
for (const v of vals) if (cnt(v) === 2) return byVal.get(v).slice(0, 2);
for (const v of vals) if (cnt(v) === 4) return byVal.get(v).slice(0, 4);
return [hand[hand.length - 1]];
}
function aiTurn() {
if (G.phase !== 'play') return;
const p = G.current;
if (p === 0) return;
const hand = G.hands[p];
if (hand.length === 0) return;
const needFollow = G.lastPlay && G.lastPlay.player !== p;
if (!needFollow) {
playCards(p, aiChooseLead(p, hand));
return;
}
const lastP = G.lastPlay.player;
const iAmLandlord = p === G.landlord;
const lastIsLandlord = lastP === G.landlord;
if (!iAmLandlord && !lastIsLandlord) { passTurn(p); return; }
const beats = findBeats(hand, G.lastPlay.type);
if (beats.length === 0) { passTurn(p); return; }
const tagged = beats.map(cards => ({ cards, t: getType(cards) })).filter(x => x.t);
const normal = tagged.filter(x => x.t.type !== 'bomb' && x.t.type !== 'rocket');
const bombs = tagged.filter(x => x.t.type === 'bomb' || x.t.type === 'rocket');
if (normal.length > 0) {
normal.sort((a, b) => a.t.value - b.t.value || a.cards.length - b.cards.length);
const pick = normal[0];
const oppLeft = iAmLandlord
? Math.min(G.hands[1].length, G.hands[2].length)
: G.hands[G.landlord].length;
if (pick.t.value <= 12 || oppLeft <= 3 || hand.length <= 6 || pick.t.value >= 15) {
playCards(p, pick.cards);
return;
}
if (Math.random() < 0.75) { playCards(p, pick.cards); return; }
passTurn(p);
return;
}
const enemyIdx = iAmLandlord ? [1, 2] : [G.landlord];
const enemyMin = Math.min(...enemyIdx.map(i => G.hands[i].length));
if (bombs.length > 0 && (enemyMin <= 3 || hand.length <= 5)) {
bombs.sort((a, b) => a.t.value - b.t.value);
playCards(p, bombs[0].cards);
return;
}
passTurn(p);
}
/* =========================================================
★ 结算:不立刻重开,先把三家剩余手牌按人展示出来
========================================================= */
function endGame(winner) {
G.phase = 'over';
const landlordWin = winner === G.landlord;
let extra = '';
if (landlordWin) {
if (G.playCount[1] === 0 && G.playCount[2] === 0) extra = '🌸 春天!';
} else {
if (G.playCount[G.landlord] === 1) extra = '🌸 反春天!';
}
render(); // 先把桌面上最后一手牌、各家剩余张数刷新出来
const title = landlordWin ? '地主胜利' : '农民胜利';
const youWin = (G.landlord === 0) === landlordWin;
/* ---------- 组装结算面板 ---------- */
elResult.innerHTML = '';
const h1 = document.createElement('h1');
h1.textContent = title;
elResult.appendChild(h1);
const pEl = document.createElement('p');
pEl.innerHTML = (extra ? extra + '<br>' : '') +
(youWin ? '<span class="tag-you">你赢了!</span>' : '你输了,再接再厉~');
elResult.appendChild(pEl);
/* ---- 剩余手牌展示区:按 你 / 右家 / 左家 分开列出 ---- */
const section = document.createElement('div');
section.className = 'reveal-section';
const secTitle = document.createElement('div');
secTitle.className = 'reveal-title';
secTitle.textContent = '各 家 剩 余 手 牌';
section.appendChild(secTitle);
[0, 1, 2].forEach(pl => {
const hand = sortDesc(G.hands[pl]); // 大 → 小,方便查看
const row = document.createElement('div');
row.className = 'reveal-row';
// 玩家名 + 标签
const nameEl = document.createElement('div');
nameEl.className = 'reveal-name'
+ (pl === G.landlord ? ' landlord' : '')
+ (pl === winner ? ' winner' : '');
nameEl.innerHTML =
`<span class="who">${NAMES[pl]}` +
(pl === G.landlord ? ' · 地主' : '') +
(pl === winner ? ' 🏆' : '') +
`</span><span class="n">${hand.length} 张</span>`;
row.appendChild(nameEl);
// 牌面(已出完则给个提示)
const cardsWrap = document.createElement('div');
cardsWrap.className = 'reveal-cards';
if (hand.length === 0) {
const em = document.createElement('div');
em.className = 'reveal-empty';
em.textContent = '已出完';
cardsWrap.appendChild(em);
} else {
hand.forEach(c => cardsWrap.appendChild(cardEl(c, true, true)));
}
row.appendChild(cardsWrap);
section.appendChild(row);
});
elResult.appendChild(section);
/* ---- 再来一局 ---- */
const btn = document.createElement('button');
btn.className = 'btn primary';
btn.textContent = '再来一局';
btn.onclick = () => { elOverlay.classList.remove('show'); newGame(); };
elResult.appendChild(btn);
// 稍微延迟一下再弹出,让玩家看清桌面上的最后一手牌
setTimeout(() => elOverlay.classList.add('show'), 600);
}
/* =========================================================
启动
========================================================= */
render();
newGame();
</script>
</body>
</html>Game Source: 斗地主Fight the Landlord
Creator: SwiftGalaxy49
Libraries: none
Complexity: complex (1416 lines, 43.8 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-swiftgalaxy49" to link back to the original. Then publish at arcadelab.ai/publish.