| <!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> |
|
|