uno / static /index.html
cacodex's picture
Fix auto draw and room deep links
9f14abf verified
Raw
History Blame Contribute Delete
38.8 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UNO Multiplayer</title>
<link rel="stylesheet" href="/uno-style.css">
<style>
.field-stack{display:flex;flex-direction:column;gap:8px}
.text-input{
width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.12);
background:rgba(255,255,255,0.06);color:var(--text-primary);font-size:14px;outline:none;
}
.text-input:focus{border-color:rgba(74,156,212,0.72);box-shadow:0 0 0 3px rgba(74,156,212,0.16)}
.lobby-line{display:flex;align-items:center;justify-content:space-between;gap:12px}
.room-code{
font-size:24px;font-weight:900;letter-spacing:5px;color:#fff;
text-shadow:0 0 18px rgba(74,156,212,0.5);
}
.status-text{min-height:18px;color:var(--text-muted);font-size:12px;line-height:1.4}
.player-list{display:flex;flex-direction:column;gap:6px;max-height:176px;overflow:auto;padding-right:2px}
.lobby-player{
display:flex;align-items:center;justify-content:space-between;gap:10px;
padding:8px 10px;border-radius:10px;background:rgba(255,255,255,0.05);
color:var(--text-primary);font-size:13px;
}
.pill{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#fff;border-radius:999px;padding:3px 7px;background:rgba(74,156,212,0.35)}
.pill.host{background:rgba(243,156,18,0.42)}
.pill.bot{background:rgba(46,204,113,0.32)}
.mini-actions{display:flex;gap:8px}
.small-btn{
border:none;border-radius:10px;padding:9px 12px;cursor:pointer;font-weight:800;font-size:12px;
color:var(--text-primary);background:rgba(255,255,255,0.08);transition:all 0.2s;
}
.small-btn:hover{background:rgba(255,255,255,0.14)}
.small-btn:disabled,.glow-btn:disabled{opacity:0.42;cursor:not-allowed;transform:none;box-shadow:none}
.number-input{max-width:78px;text-align:center}
.toggle-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;color:var(--text-primary)}
.toggle-row input{width:18px;height:18px;accent-color:#2ecc71}
.opponent-me{display:none}
#ai-row.many-opponents{
align-self:flex-start;
width:calc(100% - 300px);
max-width:calc(100% - 300px);
margin-left:72px;
flex-wrap:nowrap;
justify-content:flex-start;
overflow-x:auto;
overflow-y:hidden;
padding:0 0 8px;
scrollbar-width:none;
}
#ai-row.many-opponents::-webkit-scrollbar{display:none}
#ai-row.many-opponents .ai-opponent{flex:0 0 auto}
.flying-card{
position:fixed;z-index:600;pointer-events:none;
transition:left 0.42s cubic-bezier(0.2,0.7,0.3,1),top 0.42s cubic-bezier(0.2,0.7,0.3,1),transform 0.42s cubic-bezier(0.2,0.7,0.3,1),opacity 0.25s ease;
}
#connection-chip{
position:fixed;left:14px;top:12px;z-index:120;color:var(--text-muted);
font-size:11px;letter-spacing:1px;text-transform:uppercase;
background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:999px;padding:6px 10px;
}
#connection-chip.online{color:#2ecc71}
#connection-chip.offline{color:#e63946}
#start-screen.connected{justify-content:flex-start;padding-top:42px;overflow:auto}
#start-screen.connected .uno-logo{font-size:58px}
#start-screen.connected .setup-panel{margin-bottom:32px}
#player-hand .card-slot{cursor:pointer}
@media (max-width: 760px){
#connection-chip{display:none}
#top-row{padding-top:34px}
#rotation-ring{top:8px;right:8px;font-size:9px;gap:5px}
#rotation-ring .ring-arrow{font-size:14px}
#start-screen.connected{padding:24px 12px}
.setup-panel{width:min(92vw,420px)}
.lobby-line{align-items:flex-start;flex-direction:column}
.mini-actions{width:100%}
.mini-actions .small-btn{flex:1}
#ai-row.many-opponents{
align-self:center;
width:100%;
max-width:100%;
margin-left:0;
padding:0 56px 8px 8px;
}
}
</style>
</head>
<body>
<div class="bg-aurora"></div>
<div id="connection-chip" class="offline">offline</div>
<div id="start-screen">
<div class="uno-logo">UNO</div>
<p class="uno-tagline">Online Card Game</p>
<div class="setup-panel">
<div class="field-stack">
<label for="name-input">Name</label>
<input id="name-input" class="text-input" maxlength="24" autocomplete="nickname" placeholder="Player">
</div>
<div id="entry-panel" class="field-stack">
<label>Room Code</label>
<input id="room-input" class="text-input" maxlength="5" autocomplete="off" placeholder="ABCDE">
<div class="btn-group">
<button id="create-room-btn" class="active">Create</button>
<button id="join-room-btn">Join</button>
</div>
</div>
<div id="connected-panel" class="field-stack hidden">
<div class="lobby-line">
<div>
<label>Room</label>
<div id="room-code" class="room-code">-----</div>
</div>
<button id="copy-room-btn" class="small-btn">Copy Code</button>
</div>
<div class="field-stack" id="host-options">
<label>Seats</label>
<div class="lobby-line">
<input id="total-players-input" class="text-input number-input" type="number" min="3" max="15" value="3">
<div class="btn-group difficulty-btns">
<button data-diff="easy">Easy</button>
<button data-diff="medium">Medium</button>
<button data-diff="hard" class="active">Hard</button>
</div>
</div>
<label class="toggle-row">
<span>Bot jump-in</span>
<input id="bot-jump-input" type="checkbox" checked>
</label>
<div class="mini-actions">
<button id="add-bot-btn" class="small-btn">Add Bot</button>
<button id="remove-bot-btn" class="small-btn">Remove Bot</button>
</div>
</div>
<label>Players</label>
<div id="player-list" class="player-list"></div>
<button class="glow-btn" id="start-btn">START GAME</button>
</div>
<div id="status-text" class="status-text"></div>
</div>
</div>
<div id="game-board">
<div id="top-row">
<div id="rotation-ring">
<span id="ring-arrow" class="ring-arrow clockwise">-></span>
<span id="ring-text">Clockwise</span>
</div>
<div id="turn-orbit">
<div class="orbit-track" id="orbit-track"></div>
<div class="orbit-pointer" id="orbit-pointer"></div>
</div>
<div id="ai-row"></div>
</div>
<div id="center-area">
<div class="pile-zone">
<div id="draw-pile">
<div class="draw-stack" id="draw-stack"></div>
</div>
<div id="discard-zone">
<div id="discard-scatter"></div>
</div>
</div>
<div id="color-badge">
<span class="color-dot" id="color-dot"></span>
<span id="color-text">RED</span>
</div>
</div>
<div id="player-area">
<div id="turn-badge">Waiting</div>
<div id="score-row"></div>
<div id="player-hand"></div>
<button id="uno-call-btn">UNO!</button>
</div>
<div id="turn-toast"></div>
<div id="color-wash"></div>
<div id="card-stage"></div>
<div id="color-selector">
<span class="sel-label">Color</span>
<div class="sel-opt red" data-color="red"></div>
<div class="sel-opt blue" data-color="blue"></div>
<div class="sel-opt green" data-color="green"></div>
<div class="sel-opt yellow" data-color="yellow"></div>
</div>
</div>
<div id="win-screen">
<div class="confetti-container" id="confetti-container"></div>
<div class="win-content">
<h1>UNO!</h1>
<div class="winner-name" id="winner-name"></div>
<div class="win-scores" id="win-scores"></div>
<button class="glow-btn" id="play-again-btn">Play Again</button>
</div>
</div>
<script>
const COLORS = ['red','blue','green','yellow'];
const COLOR_NAMES = {red:'RED',blue:'BLUE',green:'GREEN',yellow:'YELLOW',wild:'WILD'};
const COLOR_HEX = {red:'#e63946',blue:'#4a9cd4',green:'#2ecc71',yellow:'#f39c12',wild:'#ffffff'};
const CARD_SYMBOLS = {
'0':'0','1':'1','2':'2','3':'3','4':'4','5':'5','6':'6','7':'7','8':'8','9':'9',
'skip':'⊘','reverse':'⇄','draw2':'+2','wild':'★','wild_draw4':'+4'
};
const DRAW_STACK_MAX_VISIBLE = 8;
let state = null;
let ws = null;
let credentials = null;
let reconnectingFromStorage = false;
let previousPhase = null;
let previousColor = null;
let pendingDeal = false;
let selectedDifficulty = 'hard';
let renameTimer = null;
let lastRenameSent = '';
const STORAGE_KEY = 'uno_multiplayer_session_v1';
const $ = (id) => document.getElementById(id);
const initialPathRoomCode = getPathRoomCode();
function setStatus(text){ $('status-text').textContent = text || ''; }
function setConnection(online){
const chip = $('connection-chip');
chip.textContent = online ? 'online' : 'offline';
chip.className = online ? 'online' : 'offline';
}
function sanitizeRoomCode(value){ return (value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,5); }
function getName(){ return ($('name-input').value || 'Player').trim() || 'Player'; }
function getPathRoomCode(){
try{
const raw = decodeURIComponent(location.pathname || '/').replace(/^\/+|\/+$/g,'');
if(!raw || raw.includes('/')) return '';
const code = raw.trim().toUpperCase();
return /^[A-Z0-9]{5}$/.test(code) ? code : '';
}catch{
return '';
}
}
function updateRoomUrl(code){
const roomCode = sanitizeRoomCode(code);
if(roomCode.length !== 5 || !history?.replaceState) return;
const target = `/${roomCode}`;
if(location.pathname !== target) history.replaceState(null,'',target);
}
function loadSavedSession(){
try{
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if(saved && saved.roomCode && saved.playerId && saved.token) return saved;
}catch{}
return null;
}
function saveSession(extra={}){
if(!credentials) return;
const payload = {
roomCode: credentials.roomCode,
playerId: credentials.playerId,
token: credentials.token,
name: getName(),
savedAt: Date.now(),
...extra
};
try{ localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); }catch{}
}
function clearSavedSession(){
try{ localStorage.removeItem(STORAGE_KEY); }catch{}
}
function restoreEntryUi(session){
if(!session) return;
$('room-input').value = session.roomCode || '';
if(session.name) $('name-input').value = session.name;
}
async function createRoom(){
setStatus('Creating room...');
const payload = {
name: getName(),
totalPlayers: clamp(parseInt($('total-players-input').value || '3',10),3,15),
botDifficulty: selectedDifficulty,
botJumpIn: $('bot-jump-input').checked
};
const res = await fetch('/api/rooms', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload)
});
if(!res.ok){ setStatus(await responseText(res)); return; }
credentials = await res.json();
credentials.name = getName();
saveSession();
$('room-input').value = credentials.roomCode;
updateRoomUrl(credentials.roomCode);
connectSocket();
}
async function joinRoom(){
const code = sanitizeRoomCode($('room-input').value);
if(code.length !== 5){ setStatus('Enter a 5 character room code.'); return; }
setStatus('Joining room...');
const res = await fetch(`/api/rooms/${code}/join`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:getName()})
});
if(!res.ok){ setStatus(await responseText(res)); return; }
credentials = await res.json();
credentials.name = getName();
saveSession();
updateRoomUrl(credentials.roomCode);
connectSocket();
}
async function responseText(res){
try{
const data = await res.json();
return data.detail || data.message || res.statusText;
}catch{
return res.statusText;
}
}
function connectSocket(){
if(!credentials) return;
if(ws) ws.close();
restoreEntryUi(credentials);
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/${credentials.roomCode}?playerId=${encodeURIComponent(credentials.playerId)}&token=${encodeURIComponent(credentials.token)}`;
ws = new WebSocket(url);
ws.onopen = () => {
setConnection(true);
updateRoomUrl(credentials.roomCode);
setStatus(reconnectingFromStorage ? 'Reconnected to your room.' : 'Connected.');
reconnectingFromStorage = false;
if(credentials.name) scheduleRename({immediate:true});
};
ws.onclose = () => { setConnection(false); setStatus(credentials ? 'Connection closed. Refresh or rejoin with your room code.' : 'Not connected.'); };
ws.onerror = () => setStatus('Connection error.');
ws.onmessage = (message) => {
const payload = JSON.parse(message.data);
if(payload.type === 'snapshot') handleSnapshot(payload.state);
if(payload.type === 'event') handleEvent(payload.event);
if(payload.type === 'error') handleSocketError(payload);
};
}
function handleSocketError(payload){
setStatus(payload.message || payload.code || 'Error');
if(['room_not_found','bad_token','player_not_found','bot_socket'].includes(payload.code)){
clearSavedSession();
credentials = null;
state = null;
if(ws) ws.close();
if(initialPathRoomCode) $('room-input').value = initialPathRoomCode;
$('start-screen').style.display = 'flex';
$('start-screen').style.opacity = '1';
$('start-screen').classList.remove('connected');
$('connected-panel').classList.add('hidden');
$('game-board').classList.remove('active');
}
}
function send(type,payload={}){
if(!ws || ws.readyState !== WebSocket.OPEN){ setStatus('Socket is not connected.'); return; }
ws.send(JSON.stringify({type,payload}));
}
function scheduleRename({immediate=false}={}){
const name = getName();
if(credentials) credentials.name = name;
saveSession({name});
clearTimeout(renameTimer);
const run = () => {
const currentName = getName();
if(!credentials || !ws || ws.readyState !== WebSocket.OPEN) return;
if(!currentName || currentName === lastRenameSent) return;
lastRenameSent = currentName;
send('rename',{name:currentName});
};
if(immediate) run();
else renameTimer = setTimeout(run,350);
}
function handleEvent(event){
if(!event) return;
if(event.type === 'card_played'){
animatePlayedCard(event);
const name = event.playerName || 'Player';
showToast(`${name}${event.jumpIn ? ' jumped in' : ' played'}`,'ai-turn');
if(event.chosenColor) setTimeout(()=>triggerColorWash(event.chosenColor,event.playerId),180);
}
if(event.type === 'cards_drawn'){
animateDrawEvent(event);
showToast(`${event.playerName || 'Player'} drew ${event.count}`,'ai-turn');
}
if(event.type === 'color_changed'){
triggerColorWash(event.color,event.playerId);
showToast(`${event.playerName || 'Player'} chose ${COLOR_NAMES[event.color]}`,'player-turn');
}
if(event.type === 'uno_called'){
showToast(`${event.playerName || 'Player'}: UNO!`,'player-turn');
const anchor = getPlayerAnchor(event.playerId);
spawnParticleBurst(anchor.left + anchor.width/2, anchor.top + anchor.height/2, '#f39c12');
}
if(event.type === 'game_started'){
pendingDeal = true;
}
if(event.type === 'player_renamed'){
showToast(`${event.playerName || 'Player'} renamed`,'player-turn');
}
if(['bot_added','bot_removed','player_joined','player_connected','player_disconnected','room_options_changed','player_renamed'].includes(event.type)){
setStatus('');
}
}
function handleSnapshot(nextState){
previousPhase = state ? state.phase : null;
state = nextState;
syncLocalSessionFromState();
renderApp();
if((previousPhase === 'lobby' && state.phase === 'playing') || pendingDeal){
pendingDeal = false;
setTimeout(animateDeal,80);
}
}
function syncLocalSessionFromState(){
const me = state?.players?.find(player=>player.id === state.myPlayerId);
if(!me || !credentials) return;
credentials.roomCode = state.roomCode;
credentials.playerId = state.myPlayerId;
updateRoomUrl(state.roomCode);
if(document.activeElement !== $('name-input')){
$('name-input').value = me.name;
credentials.name = me.name;
lastRenameSent = me.name;
saveSession({name:me.name});
} else {
credentials.name = getName();
saveSession({name:getName()});
}
}
function renderApp(){
if(!state) return;
if(state.phase === 'lobby'){
$('start-screen').style.display = 'flex';
$('start-screen').style.opacity = '1';
$('start-screen').classList.add('connected');
$('connected-panel').classList.remove('hidden');
$('game-board').classList.remove('active');
$('win-screen').classList.remove('show');
renderLobby();
return;
}
$('start-screen').style.display = 'none';
$('game-board').classList.add('active');
renderAll();
if(state.phase === 'ended') showWinScreen();
else $('win-screen').classList.remove('show');
}
function renderLobby(){
$('room-code').textContent = state.roomCode;
const isHost = state.myPlayerId === state.hostPlayerId;
$('host-options').style.display = isHost ? 'flex' : 'none';
$('start-btn').style.display = isHost ? 'block' : 'none';
$('start-btn').disabled = !isHost || state.players.length < 3;
$('total-players-input').value = state.settings.totalPlayers;
$('bot-jump-input').checked = !!state.settings.botJumpIn;
selectedDifficulty = state.settings.botDifficulty;
document.querySelectorAll('.difficulty-btns button').forEach(btn=>{
btn.classList.toggle('active', btn.dataset.diff === selectedDifficulty);
});
$('add-bot-btn').disabled = state.players.length >= state.settings.totalPlayers;
$('remove-bot-btn').disabled = !state.players.some(p=>p.isBot);
const list = $('player-list');
list.innerHTML = '';
state.players.forEach(player=>{
const row = document.createElement('div');
row.className = 'lobby-player';
const left = document.createElement('span');
left.textContent = `${player.name}${player.connected || player.isBot ? '' : ' (offline)'}`;
const right = document.createElement('span');
right.innerHTML = [
player.id === state.hostPlayerId ? '<span class="pill host">Host</span>' : '',
player.isBot ? '<span class="pill bot">Bot</span>' : '',
player.id === state.myPlayerId ? '<span class="pill">You</span>' : ''
].join(' ');
row.appendChild(left);
row.appendChild(right);
list.appendChild(row);
});
}
function renderAll(){
renderAI();
renderPlayerHand();
renderDrawPile();
renderDiscard();
renderColorBadge();
renderDirection();
renderTurnBadge();
renderScores();
renderColorSelector();
}
function getMe(){
return state?.players.find(p=>p.id === state.myPlayerId);
}
function getPlayer(id){
return state?.players.find(p=>p.id === id);
}
function playerDomId(id){
return `player-${String(id).replace(/[^a-zA-Z0-9_-]/g,'_')}`;
}
function isMyTurn(){
return !!state && state.currentPlayerId === state.myPlayerId;
}
function isAwaitingMyColor(){
return !!state && state.awaitingColorPlayerId === state.myPlayerId;
}
function isAutoDrawPending(){
return !!state
&& state.phase === 'playing'
&& state.currentPlayerId === state.myPlayerId
&& !!state.canDraw
&& !(state.legalCardIds || []).length
&& !state.awaitingColorPlayerId;
}
function buildCardFront(card){
const sym = CARD_SYMBOLS[card.value] || card.value;
return `
<span class="corner-pip tl">${sym}</span>
<div class="card-center"><span class="big-symbol">${sym}</span></div>
<span class="corner-pip br">${sym}</span>`;
}
function buildCardEl(card){
const el = document.createElement('div');
el.className = `card ${card.color}`;
el.dataset.cardId = card.id || '';
el.innerHTML = `
<div class="card-inner">
<div class="card-front">${buildCardFront(card)}</div>
<div class="card-back"></div>
</div>`;
return el;
}
function buildCardBackMini(){
const el = document.createElement('div');
el.className = 'card-back-mini';
return el;
}
function getFanLayout(idx,total,spread=24,lift=10){
const fanAngle = total > 1 ? (idx/(total-1))*spread - spread/2 : 0;
const fanY = total > 1 ? Math.abs(Math.sin(idx/(total-1)*Math.PI))*(-lift) : 0;
return {fanAngle,fanY,baseTransform:`rotateZ(${fanAngle}deg) translateY(${fanY}px)`};
}
function getAIHandScale(){
if(window.innerWidth < 480) return 0.36;
if(window.innerWidth < 768) return 0.42;
return state && state.players.length > 10 ? 0.42 : 0.5;
}
function renderAIHandRow(handRow,totalCards){
handRow.innerHTML = '';
if(totalCards === 0){
handRow.innerHTML = '<span style="font-size:11px;color:var(--text-muted)">EMPTY</span>';
return;
}
const visible = totalCards;
const aiScale = getAIHandScale();
const zoneWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ai-hand-zone')) || 220;
const baseCardWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 76;
const displayWidth = baseCardWidth * aiScale;
const fixedStep = Math.max(12, displayWidth * 0.56);
const step = visible <= 6 ? fixedStep : Math.max(5, Math.min(fixedStep, (zoneWidth-displayWidth)/Math.max(visible-1,1)));
const overlap = Math.round(step - baseCardWidth);
handRow.style.setProperty('--ai-card-scale', String(aiScale));
for(let idx=0; idx<visible; idx++){
const slot = document.createElement('div');
slot.className = 'ai-card-slot';
slot.dataset.idx = idx;
const spread = visible <= 6 ? 18 : Math.max(8,18-(visible-6)*0.8);
const {baseTransform} = getFanLayout(idx,visible,spread,6);
slot.dataset.baseTransform = baseTransform;
slot.style.transform = baseTransform;
if(idx > 0) slot.style.marginLeft = `${overlap}px`;
const card = buildCardEl({id:`back_${idx}`,color:'blue',value:'0'});
card.classList.add('flipped');
slot.appendChild(card);
handRow.appendChild(slot);
}
}
function renderAI(){
const row = $('ai-row');
row.innerHTML = '';
row.classList.toggle('many-opponents', state.players.length > 8);
state.players.filter(p=>p.id !== state.myPlayerId).forEach(player=>{
const opp = document.createElement('div');
opp.className = 'ai-opponent' + (state.currentPlayerId === player.id ? ' active-ai' : '');
opp.id = playerDomId(player.id);
const label = document.createElement('div');
label.className = 'ai-label-row' + (state.currentPlayerId === player.id ? ' active' : '');
label.innerHTML = `<span class="active-dot"></span><span class="ai-name">${escapeHtml(player.name)}</span>`;
const count = document.createElement('div');
count.className = 'ai-card-count';
count.textContent = player.rank ? placementLabel(player.rank) : `${player.handCount} cards`;
const hand = document.createElement('div');
hand.className = 'ai-hand-row';
renderAIHandRow(hand, player.handCount);
if(player.rank){
const badge = document.createElement('div');
badge.className = `placement-badge ${placementClass(player.rank)}`;
badge.textContent = placementLabel(player.rank);
hand.appendChild(badge);
}
opp.appendChild(label);
opp.appendChild(count);
opp.appendChild(hand);
row.appendChild(opp);
});
}
function renderPlayerHand(){
const me = getMe();
const container = $('player-hand');
const playerArea = $('player-area');
playerArea.querySelector('.player-placement')?.remove();
container.innerHTML = '';
if(!me) return;
const legal = new Set(state.legalCardIds || []);
const total = me.hand ? me.hand.length : 0;
(me.hand || []).forEach((card,idx)=>{
const slot = document.createElement('div');
slot.className = 'card-slot';
slot.dataset.idx = idx;
slot.dataset.cardId = card.id;
const {baseTransform} = getFanLayout(idx,total,24,10);
slot.style.transform = baseTransform;
if(idx > 0) slot.style.marginLeft = window.innerWidth < 480 ? '-20px' : window.innerWidth < 768 ? '-24px' : '-32px';
const el = buildCardEl(card);
if(legal.has(card.id)) el.classList.add('playable-card');
slot.appendChild(el);
slot.addEventListener('click',()=>activateCard(card,el));
container.appendChild(slot);
});
if(me.rank){
const badge = document.createElement('div');
badge.className = `placement-badge player-placement ${placementClass(me.rank)}`;
badge.textContent = placementLabel(me.rank);
playerArea.appendChild(badge);
}
$('uno-call-btn').classList.toggle('visible', total === 1 && !me.saidUno && state.phase === 'playing');
}
function activateCard(card,el){
const legal = new Set(state.legalCardIds || []);
if(!legal.has(card.id)){
el.classList.remove('shake-anim');
void el.offsetWidth;
el.classList.add('shake-anim');
setTimeout(()=>el.classList.remove('shake-anim'),400);
return;
}
send('play_card',{
cardId:card.id,
expectedTopCardId:state.topCard?.id,
expectedVersion:state.version
});
}
function renderDrawPile(){
const stack = $('draw-stack');
const show = Math.min(state.deckCount || 0, DRAW_STACK_MAX_VISIBLE);
stack.innerHTML = '';
for(let i=0;i<show;i++){
const card = buildCardBackMini();
card.style.left = `${i*3}px`;
card.style.top = `${-i*2.5}px`;
card.style.opacity = String(Math.max(0.45,1-i*0.12));
card.style.zIndex = String(show-i);
stack.appendChild(card);
}
stack.dataset.label = state ? `${state.deckCount}` : '';
const zone = $('draw-pile');
let badge = zone.querySelector('.pending-badge');
if(state.pendingDraw > 0){
if(!badge){
badge = document.createElement('div');
badge.className = 'pending-badge';
zone.appendChild(badge);
}
badge.textContent = `+${state.pendingDraw}`;
badge.style.opacity = '1';
}else if(badge){
badge.remove();
}
}
function renderDiscard(){
const scatter = $('discard-scatter');
const pile = state.discardPile || [];
scatter.innerHTML = '';
pile.forEach((card,idx)=>{
const total = pile.length;
const age = total - 1 - idx;
const el = buildCardEl(card);
el.style.position = 'absolute';
el.style.left = '50%';
el.style.top = '50%';
const ox = (seededRand(idx*3+1)-0.5) * Math.min(70,30+age*5) * 2;
const oy = (seededRand(idx*3+2)-0.5) * Math.min(70,30+age*5) * 2;
const rot = (seededRand(idx*3+3)-0.5) * 40;
el.style.transform = `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px)) rotate(${rot}deg)`;
el.style.filter = `brightness(${Math.max(0.3,1-age*0.07)})`;
el.style.zIndex = idx === total-1 ? 99 : idx;
if(idx === total-1) el.classList.add('discard-land-anim');
scatter.appendChild(el);
});
}
function renderColorBadge(){
const color = state.currentColor || 'red';
const dot = $('color-dot');
const txt = $('color-text');
const badge = $('color-badge');
const hex = COLOR_HEX[color] || '#fff';
dot.style.background = hex;
dot.style.color = hex;
txt.textContent = COLOR_NAMES[color] || color.toUpperCase();
badge.style.borderColor = `${hex}55`;
badge.style.boxShadow = `0 0 24px ${hex}22`;
if(previousColor !== null && previousColor !== color){
badge.classList.remove('color-ripple');
void badge.offsetWidth;
badge.classList.add('color-ripple');
setTimeout(()=>badge.classList.remove('color-ripple'),700);
}
previousColor = color;
}
function renderDirection(){
const arrow = $('ring-arrow');
const text = $('ring-text');
arrow.className = `ring-arrow ${state.direction === -1 ? 'reversed' : 'clockwise'}`;
arrow.textContent = state.direction === -1 ? '<-' : '->';
const next = state.currentPlayerId ? getPlayer(state.currentPlayerId) : null;
text.textContent = next ? `${state.direction === -1 ? 'Counter' : 'Clockwise'} / ${next.name}` : 'Finished';
}
function renderTurnBadge(){
const badge = $('turn-badge');
const current = state.currentPlayerId ? getPlayer(state.currentPlayerId) : null;
if(state.awaitingColorPlayerId){
const chooser = getPlayer(state.awaitingColorPlayerId);
badge.textContent = chooser?.id === state.myPlayerId ? 'Choose a color' : `${chooser?.name || 'Player'} is choosing color`;
badge.className = chooser?.id === state.myPlayerId ? 'your-turn' : '';
return;
}
if(isMyTurn()){
badge.textContent = isAutoDrawPending() ? 'Drawing...' : state.canDraw ? 'Your turn - play or draw' : 'Your turn';
badge.className = 'your-turn';
}else{
badge.textContent = current ? `${current.name} is playing...` : 'Waiting';
badge.className = '';
}
}
function renderScores(){
$('score-row').innerHTML = '';
}
function renderColorSelector(){
const selector = $('color-selector');
selector.classList.toggle('show', isAwaitingMyColor());
}
function showToast(msg,cls){
const toast = $('turn-toast');
toast.textContent = msg;
toast.className = `show ${cls || ''}`;
clearTimeout(toast._tid);
toast._tid = setTimeout(()=>toast.classList.remove('show'),1800);
}
function showWinScreen(){
const firstId = state.finishOrder[0];
const winner = getPlayer(firstId) || state.players.find(p=>p.rank === 1);
$('winner-name').textContent = `${winner ? winner.name : 'Winner'} Takes 1st!`;
const scores = $('win-scores');
scores.innerHTML = '';
state.players
.filter(p=>p.rank)
.sort((a,b)=>a.rank-b.rank)
.forEach(player=>{
const row = document.createElement('div');
row.className = 'win-score-item' + (player.rank === 1 ? ' winner-highlight' : '');
row.textContent = `${placementLabel(player.rank)} - ${player.name}`;
scores.appendChild(row);
});
$('win-screen').classList.add('show');
spawnConfetti();
}
function animateDeal(){
document.querySelectorAll('.ai-hand-row .ai-card-slot,#player-hand .card-slot').forEach((slot,i)=>{
const base = slot.style.transform || 'translateY(0px)';
slot.style.transition = 'none';
slot.style.opacity = '0';
slot.style.transform = `translateY(-40px) ${base}`;
setTimeout(()=>{
slot.style.transition = 'opacity 0.25s, transform 0.3s cubic-bezier(0.34,1.3,0.64,1)';
slot.style.opacity = '1';
slot.style.transform = base;
},80+i*34);
});
}
function animatePlayedCard(event){
const card = event.card;
const from = getCardSourceRect(event.playerId, card.id);
const to = getDiscardCenterAnchor();
animateCardFlight(card,from,to,true);
}
function animateDrawEvent(event){
const from = $('draw-pile').getBoundingClientRect();
const to = getPlayerAnchor(event.playerId);
const cards = event.cards || Array.from({length:event.count || 1},(_,idx)=>({id:`draw_${idx}`,color:'blue',value:'0',back:true}));
cards.forEach((card,idx)=>{
setTimeout(()=>animateCardFlight(card,from,to,!card.back && event.playerId === state?.myPlayerId),idx*90);
});
}
function animateCardFlight(card,fromRect,toRect,faceUp=true){
const flying = buildCardEl(card || {id:'fly',color:'blue',value:'0'});
flying.classList.add('flying-card');
if(!faceUp) flying.classList.add('flipped');
document.body.appendChild(flying);
const start = rectCenterPosition(fromRect);
const end = rectCenterPosition(toRect);
flying.style.left = `${start.left}px`;
flying.style.top = `${start.top}px`;
flying.style.transform = 'scale(0.92) rotate(-8deg)';
flying.style.opacity = '0.96';
requestAnimationFrame(()=>{
flying.style.left = `${end.left}px`;
flying.style.top = `${end.top}px`;
flying.style.transform = 'scale(0.84) rotate(10deg)';
flying.style.opacity = '0.82';
});
setTimeout(()=>flying.remove(),520);
}
function getCardSourceRect(playerId,cardId){
if(playerId === state?.myPlayerId){
const slot = document.querySelector(`#player-hand .card-slot[data-card-id="${cssEscape(cardId)}"]`);
if(slot) return slot.getBoundingClientRect();
}
return getPlayerAnchor(playerId);
}
function getPlayerAnchor(playerId){
const el = playerId === state?.myPlayerId ? $('player-area') : document.getElementById(playerDomId(playerId));
if(el) return el.getBoundingClientRect();
return {left:window.innerWidth/2,top:window.innerHeight/2,width:1,height:1};
}
function getDiscardCenterAnchor(){
const dz = $('discard-zone');
return dz ? dz.getBoundingClientRect() : {left:window.innerWidth/2,top:window.innerHeight/2,width:1,height:1};
}
function rectCenterPosition(rect){
const width = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 76;
const height = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-h')) || 114;
return {
left: rect.left + rect.width/2 - width/2,
top: rect.top + rect.height/2 - height/2
};
}
function triggerColorWash(color,playerId){
const wash = $('color-wash');
const hex = COLOR_HEX[color] || '#ffffff';
let rect = playerId ? getPlayerAnchor(playerId) : {left:window.innerWidth/2,top:window.innerHeight/2,width:1,height:1};
const cx = `${rect.left + rect.width/2}px`;
const cy = `${rect.top + rect.height/2}px`;
wash.style.setProperty('--wash-x',cx);
wash.style.setProperty('--wash-y',cy);
wash.style.setProperty('--wash-color',hex);
wash.classList.remove('active');
void wash.offsetWidth;
wash.classList.add('active');
setTimeout(()=>wash.classList.remove('active'),900);
}
function spawnParticleBurst(x,y,color){
for(let i=0;i<16;i++){
const p = document.createElement('div');
p.className = 'particle';
const angle = (i/16)*Math.PI*2;
const dist = 50 + Math.random()*70;
const size = 4 + Math.random()*6;
p.style.cssText = `left:${x}px;top:${y}px;width:${size}px;height:${size}px;background:${color};--px:${Math.cos(angle)*dist}px;--py:${Math.sin(angle)*dist}px;`;
document.body.appendChild(p);
setTimeout(()=>p.remove(),800);
}
}
function spawnConfetti(){
const c = $('confetti-container');
if(c.children.length) return;
const colors = ['#e63946','#4a9cd4','#2ecc71','#f39c12','#9b59b6','#e91e63','#fff'];
for(let i=0;i<80;i++){
const p = document.createElement('div');
p.className = 'confetti-piece';
p.style.cssText = `left:${Math.random()*100}vw;background:${colors[Math.floor(Math.random()*colors.length)]};width:${6+Math.random()*10}px;height:${6+Math.random()*10}px;animation-duration:${2+Math.random()*2}s;animation-delay:${Math.random()*2}s;`;
c.appendChild(p);
}
}
function seededRand(seed){
const x = Math.sin(seed*9301+49297)*233280;
return x - Math.floor(x);
}
function clamp(value,min,max){ return Math.min(max,Math.max(min,value)); }
function placementLabel(rank){
if(rank === 1) return 'Gold Crown';
if(rank === 2) return 'Silver Crown';
if(rank === 3) return 'Bronze Crown';
return `${rank}th`;
}
function placementClass(rank){
if(rank === 1) return 'gold';
if(rank === 2) return 'silver';
if(rank === 3) return 'bronze';
return 'place';
}
function escapeHtml(text){
return String(text).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
}
function cssEscape(value){
return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g,'\\"');
}
$('name-input').addEventListener('input',()=>scheduleRename());
$('create-room-btn').addEventListener('click',createRoom);
$('join-room-btn').addEventListener('click',joinRoom);
$('room-input').addEventListener('input',e=>{ e.target.value = sanitizeRoomCode(e.target.value); });
$('copy-room-btn').addEventListener('click',async()=>{
if(!state) return;
await navigator.clipboard?.writeText(state.roomCode);
setStatus('Room code copied.');
});
$('start-btn').addEventListener('click',()=>send('start_game'));
$('add-bot-btn').addEventListener('click',()=>send('add_bot'));
$('remove-bot-btn').addEventListener('click',()=>send('remove_bot'));
$('total-players-input').addEventListener('change',()=>{
send('set_room_options',{
totalPlayers:clamp(parseInt($('total-players-input').value || '3',10),3,15),
botDifficulty:selectedDifficulty,
botJumpIn:$('bot-jump-input').checked
});
});
$('bot-jump-input').addEventListener('change',()=>{
send('set_room_options',{
totalPlayers:clamp(parseInt($('total-players-input').value || '3',10),3,15),
botDifficulty:selectedDifficulty,
botJumpIn:$('bot-jump-input').checked
});
});
document.querySelectorAll('.difficulty-btns button').forEach(btn=>{
btn.addEventListener('click',()=>{
selectedDifficulty = btn.dataset.diff;
document.querySelectorAll('.difficulty-btns button').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
if(state && state.phase === 'lobby'){
send('set_room_options',{
totalPlayers:clamp(parseInt($('total-players-input').value || '3',10),3,15),
botDifficulty:selectedDifficulty,
botJumpIn:$('bot-jump-input').checked
});
}
});
});
$('draw-pile').addEventListener('click',()=>{ if(state?.canDraw) send('draw'); });
$('uno-call-btn').addEventListener('click',()=>send('call_uno'));
document.querySelectorAll('#color-selector .sel-opt').forEach(opt=>{
opt.addEventListener('click',()=>send('choose_color',{color:opt.dataset.color}));
});
$('play-again-btn').addEventListener('click',()=>send('restart'));
document.addEventListener('keydown',event=>{
if(event.key.toLowerCase() === 'u') send('call_uno');
if(event.key === ' ' && state?.canDraw){ event.preventDefault(); send('draw'); }
});
window.addEventListener('resize',()=>{ if(state?.phase === 'playing') renderAll(); });
window.render_game_to_text = () => JSON.stringify({
roomCode: state?.roomCode,
phase: state?.phase,
myPlayerId: state?.myPlayerId,
currentPlayerId: state?.currentPlayerId,
direction: state?.direction,
currentColor: state?.currentColor,
pendingDraw: state?.pendingDraw,
awaitingColorPlayerId: state?.awaitingColorPlayerId,
topCard: state?.topCard,
deckCount: state?.deckCount,
players: (state?.players || []).map(p=>({
id:p.id,name:p.name,isBot:p.isBot,handCount:p.handCount,finished:p.finished,rank:p.rank,
isMe:p.id === state?.myPlayerId
})),
myHand: getMe()?.hand || [],
legalCardIds: state?.legalCardIds || [],
canDraw: !!state?.canDraw,
autoDrawPending: isAutoDrawPending()
});
window.advanceTime = () => { if(state) renderAll(); };
setConnection(false);
const savedSession = loadSavedSession();
if(initialPathRoomCode) $('room-input').value = initialPathRoomCode;
if(savedSession && (!initialPathRoomCode || sanitizeRoomCode(savedSession.roomCode) === initialPathRoomCode)){
credentials = savedSession;
restoreEntryUi(savedSession);
reconnectingFromStorage = true;
setStatus('Reconnecting to your room...');
connectSocket();
}else{
$('name-input').value = savedSession?.name || `Player ${Math.floor(100 + Math.random()*900)}`;
if(initialPathRoomCode){
$('room-input').value = initialPathRoomCode;
setStatus('Room code loaded. Join when ready.');
}
}
</script>
</body>
</html>