Files
drinking-game/live.html
gongch 6d1dd5d56c Initial commit: 酒桌派对 / 大话骰 multiplayer drinking-game hub
- server.py: zero-dependency stdlib backend (HTTP + WebSocket /ws), 大话骰 rule engine, server-driven bots
- live.html: real MVP frontend wired to the backend over WebSocket
- index.html/app.js/style.css: older static prototype
- deploy/: systemd unit + Nginx reverse proxy + Let's Encrypt setup.sh
- test_*.py: standalone HTTP / in-process test scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:05:10 +08:00

292 lines
44 KiB
HTML
Raw Permalink 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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>酒桌派对 Live MVP</title>
<link rel="stylesheet" href="style.css?v=4" />
<style>
html,body{min-height:100%;} body.in-room{overflow:hidden}.live-note{margin:10px 0 14px;padding:12px 14px;border:1px solid rgba(67,233,123,.25);border-radius:18px;background:rgba(67,233,123,.1);color:#c9ffe2;font-size:13px;line-height:1.5}.live-tabs{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:12px 0}.live-tabs button{padding:10px 8px;font-size:12px}.api-error{background:rgba(255,77,109,.14);border-color:rgba(255,77,109,.3);color:#ffd1d9}.hidden{display:none!important}.disabled{opacity:.45;pointer-events:none}.soft-disabled{opacity:.55}.muted-btn{background:rgba(255,255,255,.1);color:#fff;box-shadow:none;border:1px solid var(--line)}.danger{background:linear-gradient(135deg,#ff8a8a,#ff4d6d);color:#fff}.mini{font-size:12px;color:var(--muted);line-height:1.45}.state-pill{display:inline-block;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,.1);font-size:12px}.thinking{background:linear-gradient(90deg,rgba(255,209,102,.22),rgba(255,138,42,.42),rgba(255,209,102,.22));background-size:200% 100%;animation:thinkingMove 1s linear infinite}.thinking:after{content:" · 思考中"}@keyframes thinkingMove{to{background-position:-200% 0}}
/* fixed game room */
.game-screen{position:fixed;inset:0;z-index:40;height:100dvh;padding:calc(env(safe-area-inset-top) + 8px) 12px calc(env(safe-area-inset-bottom) + 8px);background:radial-gradient(circle at 20% 0%,rgba(255,138,42,.24),transparent 34%),radial-gradient(circle at 100% 14%,rgba(255,77,109,.22),transparent 34%),linear-gradient(180deg,#160d22 0%,#080610 62%,#040308 100%);display:grid;grid-template-rows:48px 92px minmax(190px,1fr) 238px 60px;gap:8px;overflow:hidden}.game-topbar{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:center}.round-btn{width:42px;height:42px;padding:0;display:grid;place-items:center;border-radius:16px}.room-title strong{display:block;font-size:18px;letter-spacing:.04em}.room-title small{display:block;color:var(--muted);font-weight:800}.top-actions{display:flex;gap:6px}.top-actions button{padding:9px 10px;font-size:12px}.players-rail{display:flex;gap:8px;overflow-x:auto;overflow-y:hidden;padding:2px 0 8px;scrollbar-width:none}.players-rail::-webkit-scrollbar{display:none}.seat{min-width:92px;height:82px;padding:10px;border:1px solid var(--line);border-radius:20px;background:rgba(255,255,255,.075);position:relative;overflow:hidden}.seat strong{display:block;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.seat span{display:block;margin-top:4px;color:var(--muted);font-size:11px}.seat.turn{border-color:rgba(255,209,102,.78);box-shadow:0 0 0 2px rgba(255,209,102,.14),0 0 26px rgba(255,209,102,.25);animation:currentPulse 1.2s ease-in-out infinite}.seat.me{border-color:rgba(67,233,123,.48)}.seat .avatar{font-size:23px;margin-bottom:4px}@keyframes currentPulse{0%,100%{transform:scale(.99)}50%{transform:scale(1.02)}}
.table-panel{min-height:0;border:1px solid var(--line);border-radius:28px;background:linear-gradient(145deg,rgba(255,255,255,.13),rgba(255,255,255,.045));box-shadow:0 24px 70px rgba(0,0,0,.35);padding:14px;display:grid;grid-template-rows:auto 1fr auto;gap:10px}.call-board{text-align:center;border-radius:22px;background:rgba(0,0,0,.24);padding:14px 12px}.call-board p{margin:0 0 4px;color:var(--muted);font-weight:800}.call-board h1{font-size:38px;margin:0;line-height:1}.turn-line{margin-top:8px;color:var(--muted);font-weight:900}.my-dice{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;min-height:68px}.rule-mini{padding:10px 12px;border-radius:18px;background:rgba(255,255,255,.07);border:1px solid var(--line)}.rule-mini p{margin:0;color:var(--muted);font-size:12px;line-height:1.45}
.picker-panel{min-height:0;border:1px solid var(--line);border-radius:28px;background:rgba(255,255,255,.075);padding:12px;display:grid;grid-template-rows:48px 50px 44px 1fr;gap:8px}.count-stepper{display:grid;grid-template-columns:44px 1fr 44px 44px 44px;gap:7px;align-items:center}.count-stepper button{height:46px;padding:0}.count-display{position:relative;height:46px;display:grid;place-items:center;border-radius:16px;background:rgba(255,255,255,.09);border:1px solid var(--line);font-size:28px;font-weight:900;line-height:1}.count-display:before{content:'数量';font-size:10px;color:var(--muted);font-weight:800;position:absolute;transform:translateY(-22px)}.mode-btn{height:46px!important;border-radius:15px!important;padding:0!important;background:rgba(255,255,255,.1)!important;color:#fff!important;box-shadow:none!important;border:1px solid var(--line)!important;font-size:15px!important}.mode-btn.active{background:linear-gradient(135deg,var(--gold),var(--orange))!important;color:#1b1005!important;border-color:rgba(255,209,102,.85)!important}.mode-btn.locked{opacity:.45}.point-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:6px}.point-chip{height:46px;padding:0;border-radius:14px;background:rgba(255,255,255,.1);box-shadow:none;border:1px solid var(--line);display:grid;place-items:center}.point-chip.active{background:linear-gradient(135deg,var(--gold),var(--orange));border-color:rgba(255,209,102,.75)}.call-preview{display:grid;place-items:center;padding:6px 10px;border-radius:16px;background:rgba(255,138,42,.13);border:1px solid rgba(255,209,102,.22);font-weight:900;text-align:center;gap:4px}.mode-toggle{display:flex;gap:6px;justify-content:center}.mode-toggle button{padding:4px 10px;font-size:11px;border-radius:999px;background:rgba(255,255,255,.1);color:#fff;box-shadow:none;border:1px solid var(--line)}.mode-toggle button.active{background:linear-gradient(135deg,var(--gold),var(--orange));color:#1b1005}.action-bar{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px}.action-bar button{height:52px;padding:0;min-width:0;font-size:15px}.action-bar .adv-btn{background:rgba(255,255,255,.1);color:#fff;box-shadow:none;border:1px solid var(--line);font-size:13px}.action-bar .soft-disabled{font-size:14px}.room-dock{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.room-dock button{height:52px;padding:0;background:rgba(255,255,255,.1);color:#fff;box-shadow:none;border:1px solid var(--line);font-size:13px}.room-dock button.active{background:linear-gradient(135deg,var(--gold),var(--orange));color:#1b1005;border-color:rgba(255,209,102,.85)}.top-actions button.hidden-action{display:none}
/* dice */
.die.face,.mini-die.face{background:#fff!important;border:1px solid rgba(0,0,0,.14)!important;box-shadow:0 10px 22px rgba(0,0,0,.18);position:relative;color:#111}.die.face{width:54px;height:54px;border-radius:15px}.mini-die.face{width:30px;height:30px;border-radius:9px}.point-chip .mini-die.face{box-shadow:none}.pip{position:absolute;width:9px;height:9px;border-radius:50%;background:#111}.mini-die.face .pip{width:5px;height:5px}.pip.red{background:#e11d48}.p1{left:50%;top:50%;transform:translate(-50%,-50%)}.p2{left:24%;top:24%}.p3{right:24%;bottom:24%}.p4{right:24%;top:24%}.p5{left:24%;bottom:24%}.p6{left:24%;top:50%;transform:translateY(-50%)}.p7{right:24%;top:50%;transform:translateY(-50%)}body.opening-dice .die{animation:diceShake .12s linear infinite}@keyframes diceShake{0%{transform:rotate(-8deg) translateX(-2px)}50%{transform:rotate(8deg) translateX(2px)}100%{transform:rotate(-8deg) translateX(-2px)}}
/* sheets and result */
.sheet-mask{position:fixed;inset:0;display:none;background:rgba(0,0,0,.42);z-index:70;align-items:end}.sheet-mask.show{display:flex}.sheet{width:100%;max-height:72dvh;padding:10px 14px calc(env(safe-area-inset-bottom) + 14px);border-radius:28px 28px 0 0;background:#151020;border:1px solid var(--line);box-shadow:0 -24px 80px rgba(0,0,0,.65);overflow:auto}.grabber{width:46px;height:5px;border-radius:999px;background:rgba(255,255,255,.22);margin:0 auto 12px}.sheet-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.sheet-head h2{font-size:24px}.history{display:grid;gap:8px}.history-item{position:relative;padding:10px 12px 10px 28px;border-radius:14px;background:rgba(255,255,255,.07);font-size:13px;color:var(--muted)}.history-item:before{content:"";position:absolute;left:10px;top:15px;width:8px;height:8px;border-radius:50%;background:#ffd166;box-shadow:0 0 12px rgba(255,209,102,.8)}.history-item.open:before{background:#ff4d6d}.history-item.call:before{background:#38bdf8}.history-item.system:before{background:#43e97b}.history-item.split:before{background:#f97316}.history-item.counter:before{background:#fb7185}.history-item.challenge:before{background:#f59e0b}.history-item.decline:before{background:#a78bfa}.sheet-list{display:grid;gap:10px}.sheet-card{padding:12px;border:1px solid var(--line);border-radius:18px;background:rgba(255,255,255,.06)}.sheet-card h3{margin:0 0 6px}.sheet-card p{margin:0;color:var(--muted)}.sheet-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}.copy-row{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:10px}.copy-row input{height:42px;font-size:12px}.settings-grid{display:grid;gap:12px}.settings-grid label{display:grid;gap:8px;color:var(--muted);font-size:13px}.settings-grid select{height:46px}.challenge-mask{position:fixed;inset:0;display:none;place-items:center;background:rgba(4,4,10,.82);backdrop-filter:blur(12px);z-index:75;padding:18px}.challenge-mask.show{display:grid}.challenge-card{width:min(100%,420px);border:1px solid rgba(255,77,109,.38);border-radius:30px;background:linear-gradient(180deg,rgba(34,16,28,.98),rgba(18,10,18,.98));box-shadow:0 24px 80px rgba(0,0,0,.82),0 0 0 1px rgba(255,77,109,.18) inset;padding:18px;animation:challengePop .22s ease-out}.challenge-card.warn{animation:challengeShake .45s ease-out}.challenge-card h2{margin:4px 0 10px;font-size:28px}.challenge-call{padding:14px;border-radius:20px;background:rgba(255,255,255,.08);text-align:center;font-weight:900}.challenge-actions{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:14px}.challenge-actions.two{grid-template-columns:1fr 1fr}.challenge-sub{color:rgba(255,247,237,.86);font-size:13px;line-height:1.5}.notice-mask{position:fixed;inset:0;display:none;place-items:center;background:rgba(4,4,10,.72);backdrop-filter:blur(10px);z-index:95;padding:18px}.notice-mask.show{display:grid}.notice-card{width:min(100%,380px);border:1px solid var(--line);border-radius:26px;background:linear-gradient(180deg,rgba(28,18,34,.98),rgba(14,10,18,.98));box-shadow:0 24px 80px rgba(0,0,0,.8);padding:18px}.notice-card h3{margin:0 0 8px;font-size:22px}.notice-card p{margin:0;color:rgba(255,247,237,.88);line-height:1.6}.notice-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}.notice-actions.one{grid-template-columns:1fr}.result-mask{position:fixed;inset:0;display:none;place-items:end center;background:rgba(0,0,0,.55);z-index:80;padding:14px}.result-mask.show{display:grid}.result-dialog{width:min(100%,432px);max-height:86vh;overflow:auto;border:1px solid var(--line);border-radius:30px;background:#151020;padding:20px;box-shadow:0 24px 80px rgba(0,0,0,.7)}.result-mask.show .result-dialog{animation:resultPop .28s cubic-bezier(.2,1.4,.4,1)}.result-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}.dice-summary{display:grid;gap:10px;margin-top:14px}.dice-user{padding:12px;border-radius:18px;border:1px solid var(--line);background:rgba(255,255,255,.06)}.dice-user h4{margin:0 0 8px}.mini-dice{display:flex;gap:6px;flex-wrap:wrap}.punish{animation:drinkGlow 1.1s ease-in-out infinite alternate}.result-winners{margin:12px 0 8px;padding:11px 14px;border-radius:16px;background:rgba(67,233,123,.12);border:1px solid rgba(67,233,123,.32);color:#c9ffe2;font-weight:900;text-align:center;font-size:15px}.drink-list{display:grid;gap:8px;margin:6px 0 4px}.drink-row{display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-radius:14px;background:rgba(255,138,42,.14);border:1px solid rgba(255,209,102,.3);font-weight:800;font-size:16px}.drink-row strong{color:#ffd166}.drink-row span:last-child{color:#ffd166;font-weight:900}.call-list{display:grid;gap:7px;margin:4px 0 8px}.call-row{display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:13px;background:rgba(255,255,255,.06);border:1px solid var(--line);font-size:14px}.call-who{font-weight:900;min-width:60px}.call-what{flex:1;color:var(--muted);font-weight:800;display:flex;align-items:center;gap:5px}.call-act{color:#fff;font-weight:800;font-size:13px;white-space:nowrap}.call-tag{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:900;background:rgba(255,255,255,.12);white-space:nowrap}.call-tag.ok{background:rgba(67,233,123,.2);color:#9affc4}.call-tag.bad{background:rgba(255,77,109,.2);color:#ffb3c1}.drink-fx{position:fixed;inset:0;pointer-events:none;z-index:90;overflow:hidden}.drink-emoji{position:absolute;left:50%;top:62%;font-size:34px;animation:drinkFly 1.2s ease-out forwards}@keyframes resultPop{from{transform:translateY(40px) scale(.94);opacity:0}to{transform:none;opacity:1}}@keyframes challengePop{from{transform:scale(.95);opacity:0}to{transform:none;opacity:1}}@keyframes challengeShake{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}40%{transform:translateX(6px)}60%{transform:translateX(-4px)}80%{transform:translateX(3px)}}@keyframes drinkFly{0%{transform:translate(-50%,0) scale(.7);opacity:0}12%{opacity:1}100%{transform:translate(var(--dx),-360px) rotate(var(--rot)) scale(1.35);opacity:0}}@keyframes drinkGlow{from{box-shadow:0 0 0 rgba(255,138,42,0)}to{box-shadow:0 0 28px rgba(255,138,42,.42)}}
@media(max-height:760px){.game-screen{grid-template-rows:44px 78px minmax(160px,1fr) 210px 54px;gap:6px}.seat{height:70px;min-width:84px;padding:8px}.call-board h1{font-size:32px}.die.face{width:46px;height:46px}.picker-panel{grid-template-rows:42px 44px 38px 1fr}.action-bar button,.room-dock button{height:48px}}
</style>
</head>
<body>
<main class="app-shell" id="lobbyShell">
<header class="app-top"><div class="brand"><span class="logo">🍻</span><div><strong>酒桌派对</strong><small>Live MVP · 固定游戏桌版</small></div></div><button class="ghost-btn small" onclick="refreshRooms()">刷新</button></header>
<section id="lobby">
<div class="hero-card"><p class="eyebrow">Real MVP · Fixed Game Table</p><h1>大话骰一屏游戏桌</h1><p class="lead">房间页固定一屏,核心操作不用滚动;历史、规则、玩家和设置放到底部抽屉。</p><div class="join-panel"><label>你的昵称</label><div class="input-row"><input id="name" value="小鱼" /><button onclick="createRoom('dice')">开房</button></div></div></div>
<div id="msg" class="live-note">提示N 人局最低从 N 个 1 开始;斋局 1 最大;破斋必须翻倍。</div>
<div class="live-tabs"><button onclick="createRoom('dice')">大话骰</button><button onclick="createRoom('poker')">德州扑克</button><button onclick="createRoom('six')">六张牌</button><button onclick="createRoom('catch')">抓二游</button></div>
<section class="room-section"><div class="section-head compact"><div><p class="eyebrow">Open Rooms</p><h2>当前房间</h2></div></div><div class="room-list" id="rooms"></div></section>
</section>
</main>
<section id="roomView" class="game-screen hidden">
<header class="game-topbar">
<button class="round-btn ghost-btn" onclick="backLobby()"></button>
<div class="room-title"><strong id="topRoomCode">----</strong><small id="topRoomState">等待中</small></div>
<div class="top-actions"><button id="topReadyBtn" class="ghost-btn" onclick="toggleReady()">准备</button><button id="topStartBtn" onclick="startGame()">开始</button></div>
</header>
<div class="players-rail" id="playersRail"></div>
<section class="table-panel">
<div class="call-board"><p>当前叫点</p><h1 id="callText">未开始</h1><div class="turn-line" id="turnText">等待开始</div></div>
<div class="my-dice" id="myDice"></div>
<div class="rule-mini"><p id="callTip">第一手最低 N 个 1。</p></div>
</section>
<section class="picker-panel" id="dicePanel">
<div class="count-stepper"><button class="muted-btn" onclick="adjustCallCount(-1)"></button><div class="count-display"><span id="callCountText">1</span></div><button class="muted-btn" onclick="adjustCallCount(1)"></button><button id="zhaiBtn" class="mode-btn" onclick="setCallMode('斋')"></button><button id="feiBtn" class="mode-btn" onclick="setCallMode('飞')"></button></div>
<div class="point-grid" id="pointGrid"></div>
<div class="call-preview" id="callPreview">准备叫骰</div>
<div class="action-bar" id="actionBar"></div>
<input id="callCount" type="hidden" value="1" /><input id="callPoint" type="hidden" value="1" /><input id="callMode" type="hidden" value="" />
</section>
<nav class="room-dock"><button onclick="openSheet('history')">历史</button><button onclick="openSheet('rules')">规则</button><button onclick="openSheet('players')">玩家</button><button onclick="openSheet('settings')">设置</button></nav>
</section>
<div class="sheet-mask" id="sheetMask" onclick="maskClose(event)"><div class="sheet"><div class="grabber"></div><div class="sheet-head"><h2 id="sheetTitle">详情</h2><button class="ghost-btn small" onclick="closeSheet()">关闭</button></div><div id="sheetContent"></div></div></div>
<div class="notice-mask" id="noticeMask"><div class="notice-card"><h3 id="noticeTitle">提示</h3><p id="noticeBody"></p><div class="notice-actions one" id="noticeActions"></div></div></div>
<div class="challenge-mask" id="challengeMask"><div class="challenge-card" id="challengeCard"><div class="eyebrow">Challenge</div><h2 id="challengeTitle">等待应战</h2><p class="challenge-sub" id="challengeDesc"></p><div class="challenge-call" id="challengeCall"></div><div class="challenge-actions" id="challengeActions"></div></div></div>
<div class="result-mask" id="resultMask"><div class="result-dialog"><div id="resultContent"></div><div class="result-actions"><button onclick="startGame();closeResult()">再来一局</button><button class="ghost-btn" onclick="closeResult()">关闭</button></div></div></div>
<script>
let currentCode=localStorage.getItem('dg_current_code')||'';
let currentPlayer=null,lastResultKey='',lastRoom=null,lastSuggestKey='',activeSheet='';
let isOpeningDice=false;
const clientId=localStorage.getItem('dg_client_id')||('p'+Math.random().toString(36).slice(2,12)); localStorage.setItem('dg_client_id',clientId);
const $=id=>document.getElementById(id); const roomPlayerKey=code=>'dg_player_'+code;
const pipMap={1:['p1'],2:['p2','p3'],3:['p2','p1','p3'],4:['p2','p4','p5','p3'],5:['p2','p4','p1','p5','p3'],6:['p2','p6','p5','p4','p7','p3']};
function dieHtml(n,mini=false){const red=(Number(n)===1||Number(n)===4);return `<div class="${mini?'mini-die':'die'} face">${(pipMap[n]||[]).map(pos=>`<span class="pip ${pos} ${red?'red':''}"></span>`).join('')}</div>`}
const savePlayer=(code,p)=>{currentPlayer=p;localStorage.setItem(roomPlayerKey(code),JSON.stringify(p));localStorage.setItem('dg_current_code',code)};
const loadPlayer=code=>{try{return JSON.parse(localStorage.getItem(roomPlayerKey(code))||'null')}catch{return null}};
function syncCurrentPlayer(room){if(!currentPlayer)currentPlayer=loadPlayer(room.code);if(currentPlayer){const fresh=room.players.find(p=>p.id===currentPlayer.id);if(fresh){currentPlayer={...currentPlayer,...fresh};localStorage.setItem(roomPlayerKey(room.code),JSON.stringify(currentPlayer))}}}
let noticeResolver=null;
function showNotice(msg,title='提示',buttons=[{label:'知道了',value:true,cls:''}]){ $('noticeTitle').textContent=title; $('noticeBody').textContent=String(msg||''); const box=$('noticeActions'); box.className='notice-actions'+(buttons.length===1?' one':''); box.innerHTML=buttons.map((b,i)=>`<button class="${b.cls||''}" onclick="resolveNotice(${i})">${b.label}</button>`).join(''); $('noticeMask').classList.add('show'); return new Promise(r=>{ noticeResolver=(idx)=>{ const btn=buttons[idx]; $('noticeMask').classList.remove('show'); r(btn.value); }; }); }
function resolveNotice(i){ if(noticeResolver) noticeResolver(i); }
const alert=(msg)=>showNotice(msg,'提示');
const appConfirm=(msg)=>showNotice(msg,'请确认',[{label:'取消',value:false,cls:'ghost-btn'},{label:'确定',value:true,cls:''}]);
function setMsg(t,err=false){$('msg').textContent=t;$('msg').className=err?'live-note api-error':'live-note'}
let ws=null, wsReady=false, wsQueue=[];
function connectWS(){
const proto=location.protocol==='https:'?'wss':'ws';
ws=new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen=()=>{wsReady=true;const q=wsQueue;wsQueue=[];q.forEach(m=>ws.send(m));send('rooms',{});const known=currentCode&&loadPlayer(currentCode);const urlRoom=new URLSearchParams(location.search).get('room');if(known)send('join',{code:currentCode,name:known.name,playerId:known.id});else if(urlRoom)joinRoom(urlRoom.toUpperCase());};
ws.onmessage=e=>{try{handleWS(JSON.parse(e.data))}catch(err){}};
ws.onclose=()=>{wsReady=false;setTimeout(connectWS,1200)};
ws.onerror=()=>{try{ws.close()}catch(e){}};
}
function send(type,payload){const m=JSON.stringify(Object.assign({type},payload||{}));if(wsReady&&ws&&ws.readyState===1)ws.send(m);else wsQueue.push(m)}
function handleWS(msg){
if(msg.type==='room'){showRoom(msg.room)}
else if(msg.type==='rooms'){renderRoomList(msg.rooms)}
else if(msg.type==='joined'){currentCode=msg.room.code;savePlayer(currentCode,msg.player);showRoom(msg.room);history.replaceState(null,'','?room='+currentCode)}
else if(msg.type==='error'){if(currentCode)alert(msg.error);else setMsg(msg.error,true)}
else if(msg.type==='left'){backLobby()}
else if(msg.type==='closed'){if(currentCode)backLobby()}
}
function renderRoomList(list){$('rooms').innerHTML=(list||[]).map(r=>`<article class="room-card"><div><h3>${r.gameName} · ${r.code}</h3><p>${r.status} · ${r.players.length}/${r.maxPlayers}${r.locked?'· 已锁定':''}</p></div><button onclick="joinRoom('${r.code}')">加入</button></article>`).join('')||'<p class="lead">暂无房间</p>'}
function refreshRooms(){send('rooms',{})}
function createRoom(game){send('create',{game,name:$('name').value,playerId:clientId})}
function joinRoom(code){const known=loadPlayer(code);send('join',{code,name:$('name').value,playerId:known?.id||clientId})}
function isHost(){return !!currentPlayer?.host} function isMyTurn(room){return room.status==='playing'&&currentPlayer&&room.turnPlayerId===currentPlayer.id}
function nextLegalCall(room){const call=room.currentCall;if(!call)return{count:room.diceRule.minCount,point:1};if(call.mode==='斋'){const order=[2,3,4,5,6,1],idx=order.indexOf(Number(call.point));if(idx>=0&&idx<order.length-1)return{count:call.count,point:order[idx+1]};return{count:call.count+1,point:2}}if(call.point<6)return{count:call.count,point:call.point+1};return{count:call.count+1,point:1}}
function showRoom(room){lastRoom=room;syncCurrentPlayer(room);document.body.classList.add('in-room');$('lobbyShell').classList.add('hidden');$('roomView').classList.remove('hidden');currentCode=room.code;renderTop(room);renderPlayers(room);renderTable(room);renderPicker(room);renderChallengeOverlay(room);renderResult(room);if(activeSheet)renderSheet(activeSheet)}
function renderTop(room){
$('topRoomCode').textContent=room.code+' · 第 '+room.round+' 局';
const statusText=room.status==='waiting'?'等待中':room.status==='playing'?'游戏中':'已结算';
$('topRoomState').textContent=`${room.gameName} · ${statusText} · ${room.players.length}/${room.maxPlayers}`;
const startBtn=$('topStartBtn'), readyBtn=$('topReadyBtn');
if(room.status==='playing'){
startBtn.textContent='进行中'; startBtn.className='disabled';
readyBtn.textContent='回合'; readyBtn.className='ghost-btn disabled';
}else{
startBtn.textContent=room.status==='result'?'再来':'开始'; startBtn.className=isHost()?'':'disabled';
readyBtn.textContent=currentPlayer?.ready?'取消':'准备'; readyBtn.className='ghost-btn';
}
}
function renderPlayers(room){
$('playersRail').innerHTML=room.players.map(p=>`<div data-player-id="${p.id}" class="seat ${p.id===room.turnPlayerId?'turn':''} ${currentPlayer&&p.id===currentPlayer.id?'me':''}"><div class="avatar">${p.host?'👑':p.bot?'🤖':'🙂'}</div><strong>${p.name}${p.id===room.turnPlayerId?' · 轮到':''}</strong><span>${p.ready?'已准备':'未准备'} · ${p.cups}杯</span></div>`).join('');
requestAnimationFrame(()=>{const el=$('playersRail').querySelector(`[data-player-id="${room.turnPlayerId}"]`); if(el) el.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});});
}
function renderTable(room){
const call=room.currentCall;$('callText').textContent=call?`${call.count}${call.point} · ${call.mode}`:'未开始';
const p=room.pendingAction;
if(p){
if(p.stage==='wait_caller_counter') $('turnText').innerHTML=`${p.initiatorName} 要开骰,等待 ${p.targetName} 是否反劈`;
else if(p.stage==='wait_caller_accept') $('turnText').innerHTML=`${p.initiatorName}${p.targetName},等待受/不受/反劈`;
else $('turnText').innerHTML=`${p.targetName} 反劈,等待 ${p.initiatorName} 受/不受`;
}else{
$('turnText').innerHTML=room.status==='playing'?`轮到 ${room.turnPlayerName}${room.turnPlayerBot?' <span class="state-pill thinking">机器人</span>':''}`:room.status==='result'?'本局已结算':'等待房主开始';
}
const me=room.players.find(p=>currentPlayer&&p.id===currentPlayer.id);$('myDice').innerHTML=me&&me.dice.length?me.dice.map(d=>dieHtml(d)).join(''):'<p class="mini">游戏中只显示自己的骰子;结算后公开全部。</p>';const n=nextLegalCall(room);$('callTip').textContent=p?'当前处于开骰/劈应战阶段,请等待相关玩家选择。':(call?`上一手:${call.by}${call.count}${call.point}${call.mode})。推荐:${n.count}${n.point}${call.mode==='斋'&&n.count>=call.count*2?'(破斋飞)':''}`:`${room.players.length}人局:最低 ${room.diceRule.minCount} 个1斋局 2<3<4<5<6<1破斋翻倍。`)
}
function setCallValues(c,p){$('callCount').value=String(c);$('callPoint').value=String(p);$('callCountText').textContent=String(c)}
function setCallMode(mode){ if(!lastRoom) return; if(Number($('callPoint').value)===1) mode='斋'; $('callMode').value=mode; renderPointChips(lastRoom)}
function selectedMode(room){const c=Number($('callCount').value);const pt=Number($('callPoint').value);const manual=$('callMode')?.value;if(pt===1)return '斋';if(manual==='斋'||manual==='飞')return manual;const old=room.currentCall;if(!old)return c>=room.diceRule.flyFrom?'飞':'斋';if(old.mode==='飞')return '飞';return c>=old.count*2?'飞':'斋'}
function callValidation(room){
const c=Number($('callCount').value), pt=Number($('callPoint').value), mode=selectedMode(room), old=room.currentCall;
if(pt===1 && $('callMode')?.value==='飞') return {ok:false,msg:'叫 1 只能是斋,不能飞'};
if(!old && c===room.diceRule.minCount && pt!==1) return {ok:false,msg:`首叫最低数量只能叫 ${room.diceRule.minCount} 个 1`};
if(!old && mode==='飞' && c<=room.diceRule.minCount+1) return {ok:false,msg:`首轮低数量阶段只能叫斋,${room.diceRule.minCount}${room.diceRule.minCount+1} 个不能直接飞`};
if(old){
if(c<old.count) return {ok:false,msg:`不能降数量,至少 ${old.count}`};
if(old.mode==='斋' && mode==='飞' && c<old.count*2) return {ok:false,msg:`破斋至少 ${old.count*2} 个,当前 ${c} 个不够`};
if(old.mode==='飞' && mode==='飞' && c===old.count && pt<=old.point) return {ok:false,msg:`继续飞同数量需叫 ${old.point+1} 点以上,或切到斋`};
if(old.mode==='飞' && mode==='斋' && c<old.count) return {ok:false,msg:`平斋/切斋不能降数量,至少 ${old.count}`};
}
return {ok:true,msg:''};
}
function renderPicker(room){
const key=JSON.stringify(room.currentCall||{})+'-'+room.turnPlayerId+'-'+room.round+'-'+JSON.stringify(room.pendingAction||{});
if(key!==lastSuggestKey){lastSuggestKey=key;$('callMode').value='';const n=nextLegalCall(room);setCallValues(n.count,n.point)}
renderPointChips(room);
renderActions(room);
}
function recentCallerCount(room){const s=new Set();(room.history||[]).forEach(h=>{if(h.type==='call'&&h.player)s.add(h.player)});return s.size}
function renderActions(room){
const bar=$('actionBar'); if(!bar) return;
const set=(cols,html)=>{bar.style.gridTemplateColumns=`repeat(${cols},1fr)`;bar.innerHTML=html};
// 应答阶段由全屏挑战弹层接管,动作行让位
if(room.pendingAction){ set(1,'<button class="soft-disabled">应答进行中…</button>'); return; }
if(room.status!=='playing'){ set(1,`<button class="soft-disabled">${room.status==='result'?'本局已结算':'等待房主开始'}</button>`); return; }
const me=currentPlayer?.id, hasCall=!!room.currentCall, btns=[];
if(isMyTurn(room)){
const v=callValidation(room);
btns.push(v.ok?'<button onclick="callDice()">叫点</button>':'<button class="soft-disabled">叫点</button>');
if(hasCall){
btns.push('<button id="openBtn" class="danger" onclick="confirmOpenDice()">开骰</button>');
btns.push('<button class="muted-btn" onclick="splitDice()">劈</button>');
const callers=recentCallerCount(room);
if(room.players.length>=4 && callers>=3){
btns.push('<button class="adv-btn" onclick="multiAction(\'open3\')">开三家</button>');
btns.push('<button class="adv-btn" onclick="multiAction(\'split3\')">劈三家</button>');
}else if(room.players.length>=3 && callers>=2){
btns.push('<button class="adv-btn" onclick="multiAction(\'open2\')">开两家</button>');
btns.push('<button class="adv-btn" onclick="multiAction(\'split2\')">劈两家</button>');
}
}
}else{
// 没轮到我:满足越位条件可抢开
if(hasCall && me && me!==room.currentCall.byId && me!==room.turnPlayerId)
btns.push('<button class="adv-btn" onclick="stealSplit()">抢开</button>');
btns.push(`<button class="soft-disabled">${hasCall?`等待 ${room.turnPlayerName}`:'等待叫点'}</button>`);
}
set(btns.length, btns.join(''));
}
function updateModeButtons(room, canPingZhai, finalMode, point){
const z=$('zhaiBtn'), f=$('feiBtn'); if(!z||!f) return;
z.className='mode-btn'; f.className='mode-btn'; z.disabled=false; f.disabled=false;
z.classList.toggle('active', finalMode==='斋');
f.classList.toggle('active', finalMode==='飞');
if(Number(point)===1){ f.classList.add('locked'); f.disabled=true; }
}
function renderPointChips(room){
let point=Number($('callPoint').value||1), mode=selectedMode(room), order=mode==='斋'?[2,3,4,5,6,1]:[1,2,3,4,5,6];
const c=Number($('callCount').value);
const firstMin=!room.currentCall && c===room.diceRule.minCount;
if(firstMin && point!==1){ point=1; $('callPoint').value='1'; }
$('pointGrid').innerHTML=order.map(p=>{const locked=firstMin&&p!==1; return `<button class="point-chip ${p===point?'active':''} ${locked?'disabled':''}" ${locked?'disabled':''} onclick="selectPoint(${p})">${dieHtml(p,true)}</button>`}).join('');
const pt=Number($('callPoint').value), finalMode=selectedMode(room), validation=callValidation(room);
const canPingZhai=room.currentCall&&room.currentCall.mode==='飞'&&c>=room.currentCall.count;
updateModeButtons(room, canPingZhai, finalMode, pt);
$('callPreview').innerHTML=validation.ok?`<div>将叫:${c}${pt} · ${finalMode}${firstMin?'首叫最低只能叫1':finalMode==='飞'?'1万能':'1最大不万能'}</div>`:`<div>不能叫:${validation.msg}</div>`;
}
function adjustCallCount(delta){if(!lastRoom)return;let c=Number($('callCount').value||lastRoom.diceRule.minCount)+delta;c=Math.max(lastRoom.diceRule.minCount,Math.min(lastRoom.diceRule.maxCount,c));setCallValues(c,Number($('callPoint').value||1));renderPointChips(lastRoom)}
function selectPoint(p){if(!lastRoom)return;setCallValues(Number($('callCount').value||lastRoom.diceRule.minCount),p);renderPointChips(lastRoom)}
function callDice(){if(lastRoom){const v=callValidation(lastRoom);if(!v.ok)return alert(v.msg);}send('call',{count:Number($('callCount').value),point:Number($('callPoint').value),mode:selectedMode(lastRoom)})}
function splitDice(){send('split',{})}
function stealSplit(){send('steal',{})}
function multiAction(kind){closeSheet();send('multi',{kind})}
function respondChallenge(response){send('respond',{response})}
function respondMulti(response){send('respondMulti',{response})}
function confirmOpenDice(){if(!lastRoom?.currentCall)return alert('还没有人叫点,不能开骰。');openDice()}
function openDice(){if(isOpeningDice)return;isOpeningDice=true;document.body.classList.add('opening-dice');const ob=$('openBtn');if(ob)ob.textContent='开骰中...';setTimeout(()=>{document.body.classList.remove('opening-dice');isOpeningDice=false;send('open',{})},650)}
function startGame(){closeResult();closeChallenge();send('start',{})}
function toggleReady(){send('ready',{})}
function addBot(){send('addBot',{})}
function toggleLock(){send('lock',{})}
function saveSettings(){send('settings',{cupsPerLoss:$('cupsSelect').value})}
function kickPlayer(id){send('kick',{targetId:id})}
async function leaveRoom(){if(!await appConfirm('确定退出房间?'))return;localStorage.removeItem(roomPlayerKey(currentCode));send('leave',{})}
function renderHistory(room){return (room.history||[]).length?`<div class="history">${room.history.slice().reverse().map(h=>`<div class="history-item ${h.type||'system'}">${h.text}</div>`).join('')}</div>`:'<p class="mini">暂无记录</p>'}
function diceHtml(players){return `<div class="dice-summary">${players.map(p=>`<div class="dice-user"><h4>${p.host?'👑':p.bot?'🤖':'🙂'} ${p.name} · ${p.cups}杯</h4><div class="mini-dice">${(p.dice||[]).length?p.dice.map(d=>dieHtml(d,true)).join(''):'<span class="mini">未公开</span>'}</div></div>`).join('')}</div>`}
function closeChallenge(){ $('challengeMask').classList.remove('show'); }
function openResult(room){
closeChallenge(); const lr=room.lastResult;
const diceSection=lr.revealDice!==false?`<h3>所有人的骰子</h3>${diceHtml(room.players)}`:'<p class="mini">本局因不受结束,不公开骰子。</p>';
let body, totalCups;
if(lr.multi){
const winners=lr.winners||[], drinks=lr.drinks||[], calls=lr.calls||[];
totalCups=drinks.reduce((s,d)=>s+Number(d.cups||0),0);
const winHtml=winners.length?`<div class="result-winners">🏆 赢家:${winners.join('、')}</div>`:'';
const callHtml=calls.length?`<h3>本局叫点</h3><div class="call-list">${calls.map(c=>{const tag=c.win===true?'<span class="call-tag ok">成立</span>':c.win===false?'<span class="call-tag bad">不成立</span>':(c.actual==null?'<span class="call-tag">不受</span>':'');const act=c.actual!=null?`<span class="call-act">实际 ${c.actual}</span>`:'';return `<div class="call-row"><span class="call-who">${c.name}</span><span class="call-what">叫 ${c.count}${dieHtml(c.point,true)} · ${c.mode||''}</span>${act}${tag}</div>`}).join('')}</div>`:'';
const drinkHtml=drinks.length?drinks.map(d=>`<div class="drink-row"><span>🍺 <strong>${d.name}</strong></span><span>${d.cups} 杯</span></div>`).join(''):'<p class="mini">本局无人喝酒</p>';
body=`<p class="eyebrow">Open Dice</p><h2>多家结算结果</h2>${winHtml}${callHtml}<div class="drink-list">${drinkHtml}</div>${diceSection}`;
}else{
totalCups=lr.cups||room.settings?.cupsPerLoss||1;
const detail=lr.revealDice!==false?`<span class="call-who">${lr.call.by||'叫骰'}</span> 叫 ${lr.call.count}${dieHtml(lr.call.point,true)} · ${lr.call.mode} 实际 ${lr.actual} 个<br>`:'';
body=`<p class="eyebrow">Open Dice</p><h2>开骰结果</h2><p class="lead">${lr.text}</p><div class="punish">${detail}🍺 ${lr.loser}${totalCups} 杯</div>${diceSection}`;
}
$('resultContent').innerHTML=body; $('resultMask').classList.add('show'); createDrinkEffect(totalCups);
}
function renderChallengeOverlay(room){
const p=room.pendingAction, mask=$('challengeMask'); if(!p){mask.classList.remove('show'); return;}
const me=currentPlayer?.id; let title='等待应战', desc='', actions='', two=false;
const currentTarget=(p.targets&&p.targets[p.currentIndex||0])||null;
const call=p.call||currentTarget?.call;
if(call) $('challengeCall').textContent=`当前叫点:${call.count}${call.point} · ${call.mode}`;
if(p.type==='open2' || p.type==='split2'){
if(p.stage==='wait_target' && currentTarget){
title=p.type==='open2'?'开两家应答':'劈两家应答';
desc=`${p.initiatorName} 正在挑战 ${currentTarget.playerName},请完成该家应答。`;
if(me===currentTarget.playerId){
if(p.type==='open2'){ actions=`<button onclick="respondMulti('no_counter')">不反</button><button class="danger" onclick="respondMulti('counter')">反劈</button>`; two=true; }
else { actions=`<button onclick="respondMulti('decline')">不受</button><button class="danger" onclick="respondMulti('accept')">受</button><button class="muted-btn" onclick="respondMulti('counter')">反劈</button>`; }
}
} else if(p.stage==='wait_initiator' && currentTarget){
title='两家反劈应答'; desc=`${currentTarget.playerName} 已反劈,${p.initiatorName} 请选择 受 / 不受。`;
if(me===p.initiatorId){ actions=`<button onclick="respondMulti('decline')">不受</button><button class="danger" onclick="respondMulti('accept')">受</button>`; two=true; }
}
} else if(p.stage==='wait_caller_counter'){
title='开骰应答'; desc=`${p.initiatorName} 要开骰,${p.targetName} 请选择是否反劈。`;
if(me===p.targetId){ actions=`<button onclick="respondChallenge('no_counter')">不反</button><button class="danger" onclick="respondChallenge('counter')">反劈</button>`; two=true; }
}else if(p.stage==='wait_caller_accept'){
title=(p.type==='steal_split'?'抢开应答':'劈应答'); desc=`${p.initiatorName} ${p.type==='steal_split'?'抢开':'劈'} ${p.targetName},请选择 受 / 不受 / 反劈。`;
if(me===p.targetId){ actions=`<button onclick="respondChallenge('decline')">不受</button><button class="danger" onclick="respondChallenge('accept')">受</button><button class="muted-btn" onclick="respondChallenge('counter')">反劈</button>`; }
}else if(p.stage==='wait_opener_accept'){
title='反劈应答'; desc=`${p.targetName} 已反劈,${p.initiatorName} 请选择 受 / 不受。`;
if(me===p.initiatorId){ actions=`<button onclick="respondChallenge('decline')">不受</button><button class="danger" onclick="respondChallenge('accept')">受</button>`; two=true; }
}
$('challengeTitle').textContent=title; $('challengeDesc').textContent=desc; $('challengeActions').className='challenge-actions'+(two?' two':''); $('challengeActions').innerHTML=actions || '<button class="muted-btn">等待对方选择</button>'; $('challengeCard').classList.add('warn'); setTimeout(()=>$('challengeCard').classList.remove('warn'),450); mask.classList.add('show');
}
function renderResult(room){if(room.lastResult){const key=room.code+'-'+room.round+'-'+room.updatedAt;if(lastResultKey!==key){lastResultKey=key;openResult(room)}}}
function closeResult(){$('resultMask').classList.remove('show')}
function createDrinkEffect(cups=1){const box=document.createElement('div');box.className='drink-fx';const total=Math.min(18,6+Number(cups)*4);for(let i=0;i<total;i++){const e=document.createElement('span');e.className='drink-emoji';e.textContent=i%3===0?'🍻':'🍺';e.style.setProperty('--dx',(Math.random()*320-160)+'px');e.style.setProperty('--rot',(Math.random()*480-240)+'deg');e.style.left=(45+Math.random()*10)+'%';e.style.animationDelay=(Math.random()*.22)+'s';box.appendChild(e)}document.body.appendChild(box);setTimeout(()=>box.remove(),1500)}
function setActiveDock(type){document.querySelectorAll('.room-dock button').forEach(btn=>btn.classList.toggle('active', btn.textContent.trim()===({history:'历史',rules:'规则',players:'玩家',settings:'设置'}[type]||'')));}
function openSheet(type){activeSheet=type;renderSheet(type);setActiveDock(type);$('sheetMask').classList.add('show')}
function closeSheet(){activeSheet='';setActiveDock('');$('sheetMask').classList.remove('show')} function maskClose(e){if(e.target.id==='sheetMask')closeSheet()}
function renderSheet(type){
if(!lastRoom) return; const room=lastRoom; let title='', html='';
if(type==='history'){ title='叫骰历史'; html=renderHistory(room); }
if(type==='rules'){ title='大话骰规则'; html=`<div class="sheet-card"><h3>斋 / 飞</h3><p>${room.players.length}人局最低 ${room.diceRule.minCount} 个1。斋局点数2<3<4<5<6<1。破斋必须翻倍飞后1为万能。</p></div><div class="sheet-card"><h3>进阶规则</h3><p>抢开=越位发起的劈;开/劈两家取最近两位叫骰人;开/劈三家取最近三位叫骰人三家仅限4人局以上。</p></div><div class="sheet-card"><h3>当前惩罚</h3><p>输家喝 ${room.settings?.cupsPerLoss||1} 杯。</p></div>`; }
if(type==='players'){ title='玩家管理'; const host=isHost(); html=`<div class="sheet-list">${room.players.map(p=>{const canKick=host&&currentPlayer&&p.id!==currentPlayer.id&&room.status!=='playing'; const st=p.stats||{}; return `<div class="sheet-card"><h3>${p.host?'👑':p.bot?'🤖':'🙂'} ${p.name}${p.host?' · 房主':''}</h3><p>${p.ready?'':''} · ${p.cups}${p.bot?' · ':''}</p><p class="mini"> ${st.splits||0} · ${st.counters||0} · ${st.declines||0} · ${st.cupsDrunk||0} </p>${host&&currentPlayer&&p.id!==currentPlayer.id?`<div class="sheet-actions"><button class="danger ${canKick?'':'disabled'}" onclick="kickPlayer('${p.id}')"></button><span class="mini">${room.status==='playing'?'':''}</span></div>`:''}</div>`}).join('')}</div><div class="sheet-actions"><button onclick="addBot()" class="muted-btn ${host&&room.status!=='playing'?'':'disabled'}"></button><button onclick="leaveRoom()" class="ghost-btn">退</button></div>`; }
if(type==='settings'){ title='房间设置'; const invite=location.origin+location.pathname+'?room='+room.code; html=`<div class="settings-grid"><label>输家惩罚<select id="cupsSelect" onchange="saveSettings()" ${!isHost()||room.status==='playing'?'disabled':''}><option value="1">1杯</option><option value="2">2杯</option><option value="3">3杯</option><option value="4">4杯</option><option value="5">5杯</option></select></label><div class="copy-row"><input id="inviteLink" readonly value="${invite}"/><button onclick="copyInvite()">复制</button></div><div class="sheet-actions"><button onclick="toggleLock()" class="muted-btn ${isHost()?'':'disabled'}">${room.locked?'开放加入':'暂停加入'}</button><button onclick="backLobby()" class="ghost-btn">返回大厅</button></div></div>`; }
$('sheetTitle').textContent=title; $('sheetContent').innerHTML=html; if(type==='settings'&&$('cupsSelect')) $('cupsSelect').value=String(room.settings?.cupsPerLoss||1)
}
function copyInvite(){const input=$('inviteLink');input.select();document.execCommand('copy');alert('邀请链接已复制')}
function backLobby(){currentCode='';localStorage.removeItem('dg_current_code');closeResult();closeChallenge();closeSheet();document.body.classList.remove('in-room');$('roomView').classList.add('hidden');$('lobbyShell').classList.remove('hidden');history.replaceState(null,'',location.pathname);refreshRooms()}
connectWS();
</script>
</body>
</html>