uno / uno.html
cacodex's picture
Implement multiplayer UNO WebSocket Space
86ff9a7 verified
Raw
History Blame Contribute Delete
118 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</title>
<style>
/* ============================================================
CSS VARIABLES & THEME
============================================================ */
:root {
--bg-deep: #080818;
--bg-surface: #10102a;
--bg-glass: rgba(255,255,255,0.04);
--color-red: #e63946;
--color-blue: #4a9cd4;
--color-green: #2ecc71;
--color-yellow: #f39c12;
--card-face: #f8f6ee;
--text-primary: #f0f0f8;
--text-muted: #6666aa;
--card-w: 76px;
--card-h: 114px;
--card-r: 10px;
--ai-hand-zone: 220px;
}
/* ============================================================
RESET & BASE
============================================================ */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{
font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
background:var(--bg-deep);
color:var(--text-primary);
}
/* ============================================================
BACKGROUND ATMOSPHERE
============================================================ */
.bg-aurora{
position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;
}
.bg-aurora::before{
content:'';position:absolute;
width:700px;height:700px;border-radius:50%;
background:radial-gradient(circle,rgba(74,156,212,0.1) 0%,transparent 70%);
top:-200px;left:-200px;animation:aurora1 14s ease-in-out infinite;
}
.bg-aurora::after{
content:'';position:absolute;
width:600px;height:600px;border-radius:50%;
background:radial-gradient(circle,rgba(230,57,70,0.08) 0%,transparent 70%);
bottom:-150px;right:-100px;animation:aurora2 18s ease-in-out infinite;
}
@keyframes aurora1{
0%,100%{transform:translate(0,0) scale(1)}
50%{transform:translate(100px,80px) scale(1.2)}
}
@keyframes aurora2{
0%,100%{transform:translate(0,0) scale(1)}
50%{transform:translate(-80px,-60px) scale(1.15)}
}
/* ============================================================
START SCREEN
============================================================ */
#start-screen{
position:fixed;inset:0;z-index:100;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:32px;
background:var(--bg-deep);
}
.uno-logo{
font-size:88px;font-weight:900;letter-spacing:-2px;line-height:1;
background:linear-gradient(135deg,#e63946,#f39c12,#2ecc71,#4a9cd4);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
background-clip:text;
filter:drop-shadow(0 4px 24px rgba(230,57,70,0.5));
animation:logoFloat 3s ease-in-out infinite;
}
@keyframes logoFloat{
0%,100%{transform:translateY(0)}
50%{transform:translateY(-10px)}
}
.uno-tagline{color:var(--text-muted);font-size:14px;letter-spacing:5px;text-transform:uppercase}
.setup-panel{
background:rgba(255,255,255,0.04);
border:1px solid rgba(255,255,255,0.08);
border-radius:24px;padding:32px 40px;
backdrop-filter:blur(20px);
display:flex;flex-direction:column;gap:18px;min-width:340px;
}
.setup-panel label{font-size:11px;letter-spacing:3px;text-transform:uppercase;color:var(--text-muted);text-align:left}
.btn-group{display:flex;gap:8px}
.btn-group button{
flex:1;padding:9px 0;border:none;border-radius:10px;cursor:pointer;
font-size:13px;font-weight:600;transition:all 0.2s;
background:rgba(255,255,255,0.06);color:var(--text-primary);
}
.btn-group button:hover{background:rgba(255,255,255,0.12)}
.btn-group button.active{
background:linear-gradient(135deg,#4a9cd4,#2ecc71);
color:#fff;box-shadow:0 4px 16px rgba(74,156,212,0.3);
}
.glow-btn{
padding:14px 36px;border:none;border-radius:14px;cursor:pointer;
font-size:16px;font-weight:800;letter-spacing:2px;
background:linear-gradient(135deg,#e63946,#f39c12);
color:#fff;transition:all 0.3s;
box-shadow:0 4px 24px rgba(230,57,70,0.4);
}
.glow-btn:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(230,57,70,0.6)}
.glow-btn:active{transform:translateY(0)}
/* ============================================================
GAME BOARD — FULL VIEWPORT
============================================================ */
#game-board{
display:none;position:fixed;inset:0;z-index:10;
flex-direction:column;
}
#game-board.active{display:flex}
/* ============================================================
TOP ROW — ALL AI OPPONENTS (horizontal)
============================================================ */
#top-row{
display:flex;flex-direction:column;align-items:center;
padding:12px 16px 6px;
background:linear-gradient(to bottom,rgba(0,0,0,0.3),transparent);
min-height:130px;
position:relative;
}
/* Rotation order ring */
#rotation-ring{
position:absolute;top:8px;right:20px;
display:flex;align-items:center;gap:8px;
font-size:11px;color:var(--text-muted);letter-spacing:1px;
}
#rotation-ring .ring-arrow{
font-size:18px;transition:transform 0.4s;
}
#rotation-ring .ring-arrow.reversed{transform:scaleX(-1) rotate(-45deg)}
#rotation-ring .ring-arrow.clockwise{transform:rotate(45deg)}
#ai-row{
display:flex;gap:14px;align-items:flex-end;justify-content:center;
flex-wrap:wrap;
}
.ai-opponent{
display:flex;flex-direction:column;align-items:center;gap:5px;
min-width:120px;padding:6px 10px;border-radius:12px;
transition:all 0.3s;
}
.ai-label-row{
display:flex;align-items:center;gap:6px;
font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
color:var(--text-muted);
}
.ai-name{white-space:nowrap}
.ai-label-row.active{color:#2ecc71}
.ai-label-row .active-dot{
width:7px;height:7px;border-radius:50%;background:#e63946;
animation:pulse 1s ease-in-out infinite;display:none;
}
.ai-label-row.active .active-dot{display:block}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}}
.ai-card-count{
font-size:10px;color:var(--text-muted);letter-spacing:1px;
background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:8px;
}
.placement-badge{
position:absolute;
left:50%;top:50%;
transform:translate(-50%,-50%);
padding:8px 14px;
border-radius:999px;
font-size:12px;font-weight:900;letter-spacing:1.5px;
text-transform:uppercase;
border:1px solid rgba(255,255,255,0.22);
backdrop-filter:blur(10px);
box-shadow:0 10px 28px rgba(0,0,0,0.32);
z-index:4;
white-space:nowrap;
}
.placement-badge.gold{
color:#fff8d5;
background:linear-gradient(135deg,rgba(243,156,18,0.86),rgba(255,214,102,0.72));
}
.placement-badge.silver{
color:#f5f8ff;
background:linear-gradient(135deg,rgba(148,163,184,0.9),rgba(226,232,240,0.72));
}
.placement-badge.bronze{
color:#fff1e8;
background:linear-gradient(135deg,rgba(180,83,9,0.9),rgba(251,146,60,0.72));
}
.placement-badge.place{
color:#fff;
background:rgba(15,23,42,0.82);
}
.player-placement{
top:32%;
}
.uno-shout{
animation:unoShout 0.75s cubic-bezier(0.22,0.9,0.3,1);
}
@keyframes unoShout{
0%{transform:scale(0.88);filter:brightness(0.9)}
35%{transform:scale(1.16);filter:brightness(1.22)}
100%{transform:scale(1);filter:brightness(1)}
}
.ai-hand-row{
display:flex;align-items:flex-end;justify-content:center;
height:74px;position:relative;
width:var(--ai-hand-zone);
padding:0 10px 2px;
overflow:visible;
z-index:20;
}
.ai-hand-row .ai-card-slot{
display:flex;align-items:flex-end;justify-content:center;
flex-shrink:0;
transition:transform 0.24s cubic-bezier(0.34,1.2,0.64,1), opacity 0.22s ease;
position:relative;
z-index:1;
}
.ai-hand-row .ai-card-slot:first-child{margin-left:0}
.ai-hand-row .ai-card-slot::before{
content:'';
position:absolute;
left:50%;bottom:0;
width:var(--card-w);height:var(--card-h);
border-radius:var(--card-r);
border:2px solid rgba(255,255,255,0.15);
background:
repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(255,255,255,0.03) 8px,rgba(255,255,255,0.03) 16px),
radial-gradient(circle at 50% 50%,rgba(255,255,255,0.1) 0%,transparent 60%),
linear-gradient(135deg,#1a3a6a,#2a5a9a);
transform:translateX(-50%) scale(var(--ai-card-scale,0.5));
transform-origin:bottom center;
box-shadow:0 6px 18px rgba(0,0,0,0.22);
z-index:0;
}
.ai-hand-row .card{
pointer-events:none;
transform:scale(0.5);
transform-origin:bottom center;
filter:brightness(1) saturate(1);
opacity:1;
position:relative;
z-index:1;
}
#turn-orbit{
position:fixed;inset:0;z-index:35;pointer-events:none;
}
#turn-orbit .orbit-track{
position:absolute;
left:50%;top:49%;
width:min(84vw,1000px);
height:min(60vh,500px);
transform:translate(-50%,-50%);
border:1px dashed rgba(255,255,255,0.1);
border-radius:50%;
opacity:0.5;
}
#turn-orbit .orbit-pointer{
position:absolute;
width:0;height:0;
border-left:10px solid transparent;
border-right:10px solid transparent;
border-bottom:22px solid #2ecc71;
filter:drop-shadow(0 0 10px rgba(46,204,113,0.55));
transform-origin:50% 70%;
transition:opacity 0.25s ease, border-bottom-color 0.2s ease, filter 0.2s ease;
}
/* ============================================================
CENTER AREA
============================================================ */
#center-area{
flex:1;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:12px;
position:relative;
}
#direction-arrow{
font-size:22px;color:var(--text-muted);opacity:0.5;
transition:transform 0.4s cubic-bezier(0.34,1.2,0.64,1);
user-select:none;
}
#direction-arrow.reversed{transform:scaleX(-1) rotate(-45deg)}
#direction-arrow.clockwise{transform:rotate(45deg)}
.pile-zone{
display:flex;gap:28px;align-items:center;justify-content:center;
}
#draw-pile{
cursor:pointer;position:relative;
transition:transform 0.2s;
}
#draw-pile:hover{transform:scale(1.06)}
.pending-badge{
position:absolute;top:-10px;right:-10px;
background:#e63946;color:#fff;
font-size:12px;font-weight:900;line-height:1;
padding:4px 7px;border-radius:10px;
box-shadow:0 2px 8px rgba(230,57,70,0.6);
z-index:10;pointer-events:none;
animation:pendingPulse 0.8s ease-in-out infinite alternate;
min-width:24px;text-align:center;
}
@keyframes pendingPulse{
from{transform:scale(1)}
to{transform:scale(1.15)}
}
.draw-stack{
width:var(--card-w);height:var(--card-h);
position:relative;
}
.draw-stack .card-back-mini{
position:absolute;top:0;left:0;
width:var(--card-w);height:var(--card-h);
border:2px solid rgba(255,255,255,0.12);
border-radius:var(--card-r);
background:
repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(255,255,255,0.03) 8px,rgba(255,255,255,0.03) 16px),
radial-gradient(circle at 50% 50%,rgba(255,255,255,0.1) 0%,transparent 60%),
linear-gradient(135deg,#1a3a6a,#2a5a9a);
box-shadow:0 6px 18px rgba(0,0,0,0.22);
transform-origin:center center;
transition:left 0.26s cubic-bezier(0.22,0.9,0.3,1), top 0.26s cubic-bezier(0.22,0.9,0.3,1), opacity 0.24s ease, transform 0.24s cubic-bezier(0.22,0.9,0.3,1), filter 0.24s ease;
}
.draw-stack .card-back-mini.stack-enter{
opacity:0;
transform:translateY(22px) scale(0.82);
}
.draw-stack .card-back-mini.stack-fly-out{
animation:drawPileFlyOut 0.24s cubic-bezier(0.22,0.9,0.3,1) forwards;
}
@keyframes drawPileFlyOut{
0%{
opacity:1;
transform:translate(0,0) scale(1) rotate(0deg);
filter:brightness(1);
}
55%{
opacity:0.88;
transform:translate(5px,-6px) scale(0.98) rotate(2deg);
filter:brightness(1.05);
}
100%{
opacity:0;
transform:translate(10px,-12px) scale(0.94) rotate(4deg);
filter:brightness(1.08);
}
}
.draw-stack::after{
content:'抽牌';position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);
font-size:10px;letter-spacing:2px;color:var(--text-muted);white-space:nowrap;
}
.draw-stack[data-label]::after{content:attr(data-label)}
#discard-zone{
width:calc(var(--card-w) + 240px);
height:calc(var(--card-h) + 240px);
position:relative;
}
#discard-scatter{
position:absolute;inset:0;
}
/* ============================================================
CARD DESIGN
============================================================ */
.card{
width:var(--card-w);height:var(--card-h);
perspective:1000px;cursor:pointer;flex-shrink:0;
transform-origin:bottom center;position:relative;
}
.card.disabled{pointer-events:none;opacity:0.4}
.card-inner{
position:relative;width:100%;height:100%;
transform-style:preserve-3d;
transition:transform 0.45s cubic-bezier(0.4,0.2,0.2,1);
}
.card.flipped .card-inner{transform:rotateY(180deg)}
.card-front,.card-back{
position:absolute;inset:0;border-radius:var(--card-r);
backface-visibility:hidden;overflow:hidden;
display:flex;align-items:center;justify-content:center;
}
.card-back{
background:linear-gradient(135deg,#1a3a6a,#2a5a9a);
border:2px solid rgba(255,255,255,0.15);
transform:rotateY(180deg);
background-image:
repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(255,255,255,0.03) 8px,rgba(255,255,255,0.03) 16px),
radial-gradient(circle at 50% 50%,rgba(255,255,255,0.1) 0%,transparent 60%);
}
.card-back::after{
content:'UNO';font-family:Arial Black,sans-serif;
font-size:16px;color:rgba(255,255,255,0.5);letter-spacing:2px;
}
.card-front{
background:var(--card-face);
border:2px solid currentColor;
flex-direction:column;
box-shadow:0 6px 24px rgba(0,0,0,0.5);
}
.card.red{color:var(--color-red)}
.card.blue{color:var(--color-blue)}
.card.green{color:var(--color-green)}
.card.yellow{color:var(--color-yellow)}
.card.wild .card-front{
background:linear-gradient(135deg,var(--color-red),var(--color-blue),var(--color-green),var(--color-yellow));
border-color:rgba(255,255,255,0.4);
color:#fff;
}
.corner-pip{
position:absolute;font-size:11px;font-weight:800;line-height:1;
color:currentColor;
}
.corner-pip.tl{top:4px;left:5px}
.corner-pip.br{bottom:4px;right:5px;transform:rotate(180deg)}
.card-center{
display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:1px;z-index:1;position:relative;
}
.card-center .big-symbol{
font-size:36px;font-weight:900;line-height:1;
color:currentColor;text-shadow:0 2px 4px rgba(0,0,0,0.15);
}
.card.wild .card-front::before{
content:'';position:absolute;inset:0;
background:rgba(0,0,0,0.12);border-radius:var(--card-r);z-index:0;
}
.card.wild .corner-pip,.card.wild .card-center{color:#fff;text-shadow:0 1px 4px rgba(0,0,0,0.4);z-index:2}
/* ============================================================
CARD ANIMATIONS
============================================================ */
@keyframes dealIn{
0%{opacity:0;transform:translateY(-200px) scale(0.6) rotateZ(-10deg)}
60%{opacity:1;transform:translateY(10px) scale(1.03) rotateZ(3deg)}
100%{opacity:1;transform:translateY(0) scale(1) rotateZ(0)}
}
.card.deal-anim{animation:dealIn 0.35s cubic-bezier(0.34,1.56,0.64,1) forwards}
/* Card playing arc — goes from hand to discard */
@keyframes cardPlayArc{
0%{transform:translate(0,0) scale(1) rotateZ(0)}
20%{transform:translate(0,-40px) scale(1.05) rotateZ(-4deg)}
60%{transform:translate(var(--arc-x,0),-180px) scale(0.88) rotateZ(var(--arc-r,0deg))}
85%{transform:translate(var(--arc-x,0),-220px) scale(0.82)}
100%{transform:translate(var(--arc-x,0),-220px) scale(0.72);opacity:0}
}
.card.card-play-anim{
animation:cardPlayArc 0.42s cubic-bezier(0.2,0.7,0.3,1) forwards;
pointer-events:none;
}
/* Discard card lands on pile */
@keyframes discardLand{
0%{
transform:translate(calc(-50% + var(--discard-x,0px)), calc(-50% - 56px)) rotate(var(--discard-rot,0deg)) scale(0.74);
opacity:0.78;
}
55%{
transform:translate(calc(-50% + var(--discard-x,0px)), calc(-50% + 10px + var(--discard-y,0px))) rotate(var(--discard-rot,0deg)) scale(1.04);
}
100%{
transform:translate(calc(-50% + var(--discard-x,0px)), calc(-50% + var(--discard-y,0px))) rotate(var(--discard-rot,0deg)) scale(1);
opacity:1;
}
}
.card.discard-land-anim{animation:discardLand 0.38s cubic-bezier(0.34,1.2,0.64,1) forwards}
/* Card flies from draw pile to player hand */
@keyframes cardFlyToHand{
0%{transform:translate(var(--fd-x,0),var(--fd-y,0)) scale(0.8);opacity:0}
30%{transform:translate(calc(var(--fd-x,0)*0.3),calc(var(--fd-y,0)*0.3-20px)) scale(0.9);opacity:1}
100%{transform:translate(0,0) scale(1);opacity:1}
}
.card.fly-in-anim{
animation:cardFlyToHand 0.3s cubic-bezier(0.34,1.3,0.64,1) forwards;
}
@keyframes pileShake{
0%,100%{transform:translateX(0) rotate(0)}
20%{transform:translateX(-6px) rotate(-3deg)}
40%{transform:translateX(6px) rotate(3deg)}
60%{transform:translateX(-4px) rotate(-2deg)}
80%{transform:translateX(4px) rotate(2deg)}
}
.pile-shake-anim{animation:pileShake 0.45s ease-in-out}
@keyframes cardShake{
0%,100%{transform:translateX(0) rotate(0)}
25%{transform:translateX(-5px) rotate(-3deg)}
75%{transform:translateX(5px) rotate(3deg)}
}
.card.shake-anim{animation:cardShake 0.35s ease-in-out}
@keyframes cardSlideIn{
0%{transform:translateY(20px) scale(0.9);opacity:0}
100%{transform:translateY(0) scale(1);opacity:1}
}
.card.slide-in-anim{animation:cardSlideIn 0.25s cubic-bezier(0.34,1.3,0.64,1) forwards}
/* ============================================================
PLAYER HAND — COMPACT OVERLAPPING
============================================================ */
#player-area{
display:flex;flex-direction:column;align-items:center;
padding:0 16px 12px;gap:8px;
min-height:180px;
position:relative;
}
#turn-badge{
display:none;
font-size:11px;letter-spacing:3px;text-transform:uppercase;
color:var(--text-muted);font-weight:700;
}
#turn-badge.your-turn{color:#2ecc71}
#score-row{
display:none;gap:20px;font-size:12px;color:var(--text-muted);
}
#score-row b{color:var(--text-primary)}
/* Player hand — slot-based: slot handles fan (stable), JS handles ripple on hover */
#player-hand{
display:flex;align-items:flex-end;justify-content:center;
position:relative;min-height:130px;padding:0 60px;
width:min(100%,960px);
z-index:20;
}
/* Each card in a slot — slot handles fan position */
#player-hand .card-slot{
display:flex;align-items:flex-end;justify-content:center;
flex-shrink:0;
margin-left:-34px;
will-change:transform;
}
#player-hand .card-slot:first-child{margin-left:0}
/* Playable card highlight */
#player-hand .card-slot .card.playable-card{
box-shadow:0 0 0 2.5px rgba(255,255,255,0.55),0 6px 20px rgba(255,255,255,0.18);
}
/* Playable card highlight */
#player-hand .card.playable-card{
box-shadow:0 0 0 2.5px rgba(255,255,255,0.55),0 6px 20px rgba(255,255,255,0.18);
}
#player-hand .card.touch-selected{
box-shadow:0 0 0 3px rgba(255,255,255,0.68),0 10px 28px rgba(255,255,255,0.22);
}
#player-hand .card{
will-change:filter;
}
#uno-call-btn{
display:none!important;padding:9px 28px;border:none;border-radius:12px;cursor:pointer;
font-size:13px;font-weight:900;letter-spacing:3px;
background:linear-gradient(135deg,#e63946,#f39c12);
color:#fff;box-shadow:0 4px 20px rgba(230,57,70,0.5);
animation:unoGlow 0.7s ease-in-out infinite alternate;
}
#uno-call-btn.visible{display:block}
@keyframes unoGlow{
from{box-shadow:0 4px 16px rgba(230,57,70,0.5)}
to{box-shadow:0 4px 32px rgba(230,57,70,0.9),0 0 60px rgba(230,57,70,0.3)}
}
/* ============================================================
COLOR BADGE
============================================================ */
/* Direction arrow removed — replaced by rotation ring */
/* ============================================================
COLOR BADGE
============================================================ */
#color-badge{
display:flex;align-items:center;gap:8px;
padding:5px 16px;border-radius:20px;
background:rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);
backdrop-filter:blur(10px);font-size:12px;font-weight:700;
letter-spacing:2px;text-transform:uppercase;
position:relative;overflow:hidden;
}
#color-badge::before{
content:'';
position:absolute;
inset:0;
border-radius:inherit;
padding:1px;
background:
conic-gradient(
from var(--marquee-angle,0deg),
white 0deg,
color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 10deg,
color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 22deg,
transparent 34deg,
transparent 146deg,
color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 158deg,
color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 170deg,
white 180deg,
color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 190deg,
color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 202deg,
transparent 214deg,
transparent 326deg,
color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 338deg,
color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 350deg,
white 360deg
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite:xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite:exclude;
opacity:0.92;
pointer-events:none;
}
#color-badge::after{
content:'';
position:absolute;
inset:-3px;
border-radius:inherit;
padding:4px;
background:
conic-gradient(
from var(--marquee-angle,0deg),
color-mix(in srgb, var(--marquee-color, #fff) 86%, white 14%) 0deg,
color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 10deg,
color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 24deg,
transparent 38deg,
transparent 142deg,
color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 156deg,
color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 170deg,
color-mix(in srgb, var(--marquee-color, #fff) 86%, white 14%) 180deg,
color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 190deg,
color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 204deg,
transparent 218deg,
transparent 322deg,
color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 336deg,
color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 350deg,
color-mix(in srgb, var(--marquee-color, #fff) 86%, white 14%) 360deg
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite:xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite:exclude;
opacity:0.55;
filter:blur(7px);
pointer-events:none;
}
#color-badge > *{
position:relative;
z-index:1;
}
#color-badge .color-dot{
width:12px;height:12px;border-radius:50%;
box-shadow:0 0 8px currentColor;
}
/* Ripple animation on color change */
#color-badge.color-ripple::after{
content:'';position:absolute;inset:0;
background:radial-gradient(circle,rgba(255,255,255,0.7) 0%,transparent 70%);
animation:colorRipple 0.6s ease-out forwards;
}
@keyframes colorRipple{
0%{transform:scale(0);opacity:1}
100%{transform:scale(3);opacity:0}
}
@keyframes badgeMarquee{
from{transform:rotate(0deg)}
to{transform:rotate(360deg)}
}
/* Inline color selector for wild cards — appears next to staged card */
#color-selector{
position:fixed;bottom:200px;
display:none;flex-direction:row;align-items:center;gap:8px;
z-index:130;
background:rgba(10,10,30,0.85);
border:1px solid rgba(255,255,255,0.12);
border-radius:16px;
padding:10px 14px;
backdrop-filter:blur(12px);
box-shadow:0 8px 32px rgba(0,0,0,0.5);
animation:selectorPop 0.3s cubic-bezier(0.34,1.4,0.64,1) forwards;
}
#color-selector.show{display:flex}
@keyframes selectorPop{
0%{opacity:0;transform:translateX(-10px) scale(0.9)}
100%{opacity:1;transform:translateX(0) scale(1)}
}
#color-selector .sel-label{
font-size:10px;letter-spacing:2px;color:var(--text-muted);
text-transform:uppercase;margin-right:4px;white-space:nowrap;
}
#color-selector .sel-opt{
width:38px;height:38px;border-radius:50%;cursor:pointer;
transition:transform 0.2s cubic-bezier(0.34,1.4,0.64,1),box-shadow 0.2s;
border:2px solid transparent;
flex-shrink:0;
}
#color-selector .sel-opt:hover{
transform:scale(1.25);
box-shadow:0 0 16px currentColor;
border-color:rgba(255,255,255,0.4);
}
#color-selector .sel-opt.red {background:var(--color-red)}
#color-selector .sel-opt.blue {background:var(--color-blue)}
#color-selector .sel-opt.green{background:var(--color-green)}
#color-selector .sel-opt.yellow{background:var(--color-yellow)}
/* Card staging area for pending confirmation (wild cards) */
#card-stage{
position:fixed;bottom:200px;left:50%;transform:translateX(-50%);
width:88px;height:132px;
z-index:120;pointer-events:none;
display:none;
}
#card-stage.show{display:block}
#card-stage .staged-card{
width:88px;height:132px;
position:fixed;left:50%;bottom:200px;
margin-left:-44px;
transform:translateX(-50%);
filter:drop-shadow(0 8px 24px rgba(0,0,0,0.6));
}
/* Full-screen color wash on color change — center is set by JS via --wash-cx/--wash-cy */
#color-wash{
position:fixed;z-index:50;pointer-events:none;
left:var(--wash-cx,50%);
top:var(--wash-cy,50%);
width:180vmax;height:180vmax;
margin-left:-90vmax;
margin-top:-90vmax;
border-radius:50%;
opacity:0;
background:none;
will-change:transform,opacity,filter;
}
#color-wash.wash-ripple{
opacity:1;
background:
radial-gradient(circle at 50% 50%,
rgba(255,255,255,0.12) 0%,
rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.18) 14%,
rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.11) 28%,
rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.055) 44%,
rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.018) 60%,
rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.005) 74%,
transparent 86%);
animation:colorWashRipple 0.62s ease-out forwards;
}
@keyframes colorWashRipple{
0%{
opacity:0;
transform:scale(0.18);
filter:blur(8px) saturate(1.02);
}
24%{
opacity:0.62;
}
100%{
opacity:0;
transform:scale(1.85);
filter:blur(22px) saturate(0.94);
}
}
/* ============================================================
TURN TOAST
============================================================ */
#turn-toast{
position:fixed;top:42%;left:50%;transform:translate(-50%,-50%);
padding:12px 32px;border-radius:14px;
background:rgba(0,0,0,0.75);backdrop-filter:blur(16px);
border:1px solid rgba(255,255,255,0.1);
font-size:15px;font-weight:700;letter-spacing:2px;
z-index:200;pointer-events:none;
opacity:0;transition:opacity 0.25s;
white-space:nowrap;
}
#turn-toast.show{opacity:1}
#turn-toast.player-turn{color:#2ecc71}
#turn-toast.ai-turn{color:var(--text-muted)}
/* ============================================================
THOUGHT BUBBLE
============================================================ */
.thought-bubble{
background:#fff;color:#222;padding:3px 10px;border-radius:10px;
font-size:11px;font-weight:800;white-space:nowrap;
animation:bubbleUp 0.35s cubic-bezier(0.34,1.56,0.64,1);
box-shadow:0 2px 8px rgba(0,0,0,0.2);
}
@keyframes bubbleUp{
0%{opacity:0;transform:translateY(8px) scale(0.8)}
100%{opacity:1;transform:translateY(0) scale(1)}
}
/* ============================================================
COLOR MODAL
============================================================ */
#color-modal{
position:fixed;inset:0;z-index:300;
background:rgba(0,0,0,0.65);backdrop-filter:blur(12px);
display:none;align-items:center;justify-content:center;
}
#color-modal.show{display:flex}
.color-modal-content{
background:#12122a;border:1px solid rgba(255,255,255,0.1);
border-radius:28px;padding:36px;text-align:center;
animation:modalPop 0.35s cubic-bezier(0.34,1.56,0.64,1);
box-shadow:0 32px 80px rgba(0,0,0,0.6);
}
@keyframes modalPop{
0%{opacity:0;transform:scale(0.7) translateY(40px)}
100%{opacity:1;transform:scale(1) translateY(0)}
}
.color-modal-content h2{font-size:20px;font-weight:800;margin-bottom:24px;letter-spacing:1px}
.color-options{display:flex;gap:14px}
.color-opt{
width:66px;height:66px;border-radius:50%;cursor:pointer;
transition:transform 0.25s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.25s;
border:3px solid transparent;
}
.color-opt:hover{transform:scale(1.15);border-color:rgba(255,255,255,0.5)}
.color-opt.red{background:var(--color-red);box-shadow:0 0 20px rgba(230,57,70,0.4)}
.color-opt.blue{background:var(--color-blue);box-shadow:0 0 20px rgba(74,156,212,0.4)}
.color-opt.green{background:var(--color-green);box-shadow:0 0 20px rgba(46,204,113,0.4)}
.color-opt.yellow{background:var(--color-yellow);box-shadow:0 0 20px rgba(243,156,18,0.4)}
/* ============================================================
WIN SCREEN
============================================================ */
#win-screen{
position:fixed;inset:0;z-index:400;
background:rgba(0,0,0,0.88);backdrop-filter:blur(24px);
display:none;align-items:center;justify-content:center;
}
#win-screen.show{display:flex}
.win-content{text-align:center;animation:modalPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
.win-content h1{
font-size:80px;font-weight:900;
background:linear-gradient(135deg,#e63946,#f39c12,#2ecc71,#4a9cd4);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
background-clip:text;margin-bottom:8px;
}
.win-content .winner-name{font-size:28px;font-weight:700;margin-bottom:6px}
.win-scores{margin:20px 0;display:flex;flex-direction:column;gap:6px;align-items:center}
.win-score-item{font-size:15px;color:var(--text-muted)}
.win-score-item.winner-highlight{color:#2ecc71;font-weight:700;font-size:17px}
.confetti-container{position:fixed;inset:0;pointer-events:none;z-index:401;overflow:hidden}
.confetti-piece{
position:absolute;border-radius:2px;top:-16px;
animation:confettiFall linear forwards;
}
@keyframes confettiFall{
0%{transform:translateY(0) rotateZ(0);opacity:1}
100%{transform:translateY(110vh) rotateZ(800deg);opacity:0}
}
/* ============================================================
PARTICLES
============================================================ */
.particle{
position:fixed;border-radius:50%;pointer-events:none;z-index:500;
animation:particleBurst 0.75s ease-out forwards;
}
@keyframes particleBurst{
0%{transform:translate(0,0) scale(1);opacity:1}
100%{transform:translate(var(--px),var(--py)) scale(0);opacity:0}
}
/* ============================================================
RESPONSIVE
============================================================ */
@media(max-width:768px){
:root{--card-w:58px;--card-h:87px;--card-r:8px;--ai-hand-zone:134px}
.uno-logo{font-size:60px}
.setup-panel{padding:24px 28px;min-width:auto}
#top-row{min-height:96px;padding:10px 10px 2px}
#ai-row{
width:100%;
flex-wrap:nowrap;
justify-content:flex-start;
align-items:flex-end;
gap:10px;
overflow-x:auto;
overflow-y:hidden;
padding:0 6px 6px;
scrollbar-width:none;
}
#ai-row::-webkit-scrollbar{display:none}
.ai-opponent{min-width:88px;flex:0 0 auto;padding:4px 6px;gap:4px}
.ai-label-row{font-size:10px;letter-spacing:1px}
.ai-card-count{font-size:9px;padding:1px 6px;white-space:nowrap}
#player-hand .card-slot{margin-left:-24px}
#player-hand .card-slot:first-child{margin-left:0}
.ai-hand-row{height:60px;padding:0 8px 2px}
.ai-hand-row .card{transform:scale(0.42)}
}
@media(max-width:480px){
:root{--card-w:46px;--card-h:69px;--card-r:6px;--ai-hand-zone:108px}
.uno-logo{font-size:48px}
#top-row{min-height:82px;padding:8px 8px 0}
#rotation-ring{top:6px;right:10px;font-size:10px}
#ai-row{gap:8px;padding:0 2px 4px}
.ai-opponent{min-width:72px;padding:3px 4px;gap:3px}
.ai-label-row{font-size:9px;letter-spacing:0.5px}
.ai-card-count{font-size:8px;padding:1px 5px}
#player-hand .card-slot{margin-left:-20px}
#player-hand .card-slot:first-child{margin-left:0}
.ai-hand-row{height:48px;padding:0 6px 2px}
.ai-hand-row .card{transform:scale(0.36)}
}
/* ============================================================
UTILITY
============================================================ */
.hidden{display:none!important}
</style>
</head>
<body>
<div class="bg-aurora"></div>
<!-- START SCREEN -->
<div id="start-screen">
<div class="uno-logo">UNO</div>
<p class="uno-tagline">Elegant Card Game</p>
<div class="setup-panel">
<label>Difficulty</label>
<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>
<label>Players</label>
<div class="btn-group player-btns">
<button data-count="2">2 Players</button>
<button data-count="3" class="active">3 Players</button>
<button data-count="4">4 Players</button>
</div>
<button class="glow-btn" id="start-btn">START GAME</button>
</div>
</div>
<!-- GAME BOARD -->
<div id="game-board">
<!-- TOP: All AI opponents in a horizontal row -->
<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">
<!-- Bot opponents rendered here dynamically -->
</div>
</div>
<!-- CENTER -->
<div id="center-area">
<div class="pile-zone">
<div id="draw-pile">
<div class="draw-stack" id="draw-stack"></div>
</div>
<!-- Scattered discard pile -->
<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>
<!-- BOTTOM: Player area -->
<div id="player-area">
<div id="turn-badge">Your turn</div>
<div id="score-row"></div>
<div id="player-hand"></div>
<button id="uno-call-btn">UNO!</button>
</div>
<div id="turn-toast"></div>
<!-- Full-screen color wash on color change -->
<div id="color-wash"></div>
<!-- Card staging area for pending wild card -->
<div id="card-stage"></div>
<!-- Inline color selector next to staged card -->
<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>
<!-- COLOR MODAL -->
<div id="color-modal">
<div class="color-modal-content">
<h2>Choose a Color</h2>
<div class="color-options">
<div class="color-opt red" data-color="red"></div>
<div class="color-opt blue" data-color="blue"></div>
<div class="color-opt green" data-color="green"></div>
<div class="color-opt yellow" data-color="yellow"></div>
</div>
</div>
</div>
<!-- WIN SCREEN -->
<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>
/* ============================================================
CONSTANTS
============================================================ */
const COLORS = ['red','blue','green','yellow'];
const COLOR_NAMES = {red:'RED',blue:'BLUE',green:'GREEN',yellow:'YELLOW'};
const COLOR_HEX = {red:'#e63946',blue:'#4a9cd4',green:'#2ecc71',yellow:'#f39c12'};
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 CARD_POINTS = {
wild_draw4:50,wild:50,draw2:20,skip:20,reverse:20,
'0':0,'1':1,'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9
};
const AI_DELAYS = {easy:600,medium:1000,hard:1400};
/* ============================================================
GAME STATE
============================================================ */
let state = null;
let playerLocked = false;
let pendingWildCard = null;
let unoTimer = null;
let _aiHandCounts = {};
let _playerHandRested = false;
let _playerNeedsRestoreAnim = false;
let _playerAutoDrawTimer = null;
let _colorWashSourceIdx = null;
let _orbitAngle = null;
let _orbitAnimFrame = 0;
let _orbitTargetIdx = null;
let _orbitTargetAngle = null;
let _orbitAngularVelocity = 0;
let _orbitLastFrame = 0;
let _playerHandAnimFrame = 0;
let _playerHandLastFrame = 0;
let _playerHandSpawnMode = 'deal';
let _playerHandRenderKey = '';
const _playerHandBodies = new Map();
const _playerHandDrawSpawnIds = new Set();
let _playerHandSpawnCounter = 0;
let _badgeMarqueeAngle = 0;
let _badgeMarqueeSpeed = 24;
let _badgeMarqueeTargetSpeed = 24;
let _badgeMarqueeFrame = 0;
let _badgeMarqueeLastFrame = 0;
let _touchSelectedCardId = null;
const TAU = Math.PI * 2;
const DRAW_STACK_MAX_VISIBLE = 8;
let _cardUidSeed = 1;
const PLAYER_HAND_FORCES = {
springX:272,
springY:310,
springAngle:224,
springScale:58,
dragX:36,
dragY:40,
dragAngle:30,
dragScale:24,
hoverLift:32,
hoverScale:1.16,
restBrightness:0.62,
activeBrightness:1
};
function createDeck(){
const makeCard=(color,value)=>({id:`card_${_cardUidSeed++}`,color,value});
const deck = [];
COLORS.forEach(color => {
deck.push(makeCard(color,'0'));
for(let v=1;v<=9;v++){
deck.push(makeCard(color,String(v)));
deck.push(makeCard(color,String(v)));
}
['skip','reverse','draw2'].forEach(a => {
deck.push(makeCard(color,a));
deck.push(makeCard(color,a));
});
});
for(let i=0;i<4;i++){
deck.push(makeCard('wild','wild'));
deck.push(makeCard('wild','wild_draw4'));
}
return deck;
}
function shuffle(arr){
for(let i=arr.length-1;i>0;i--){
const j=Math.floor(Math.random()*(i+1));
[arr[i],arr[j]]=[arr[j],arr[i]];
}
return arr;
}
function clamp(value,min,max){
return Math.min(max,Math.max(min,value));
}
function isTouchPrimaryInput(){
return !!(
(typeof window!=='undefined'&&window.matchMedia&&window.matchMedia('(pointer: coarse)').matches)
|| (typeof navigator!=='undefined'&&navigator.maxTouchPoints>0)
|| (typeof window!=='undefined'&&'ontouchstart' in window)
);
}
function stepBadgeMarquee(now){
const badge=document.getElementById('color-badge');
if(!badge){
_badgeMarqueeFrame=0;
_badgeMarqueeLastFrame=0;
return;
}
if(!_badgeMarqueeLastFrame) _badgeMarqueeLastFrame=now;
const dt=Math.min(0.032,Math.max(0.008,(now-_badgeMarqueeLastFrame)/1000));
_badgeMarqueeLastFrame=now;
const response=1.5;
_badgeMarqueeSpeed+=(_badgeMarqueeTargetSpeed-_badgeMarqueeSpeed)*Math.min(1,dt*response);
const direction=state?.direction===-1?-1:1;
_badgeMarqueeAngle=(_badgeMarqueeAngle+direction*_badgeMarqueeSpeed*dt)%360;
badge.style.setProperty('--marquee-angle',`${_badgeMarqueeAngle.toFixed(2)}deg`);
_badgeMarqueeFrame=requestAnimationFrame(stepBadgeMarquee);
}
function ensureBadgeMarqueeLoop(){
if(_badgeMarqueeFrame) return;
_badgeMarqueeFrame=requestAnimationFrame(stepBadgeMarquee);
}
function setBadgeMarqueeTargetSpeed(speed){
_badgeMarqueeTargetSpeed=speed;
ensureBadgeMarqueeLoop();
}
function cardEq(a,b){return a.color===b.color&&a.value===b.value}
function isWild(card){return card.color==='wild'}
function isNum(card){return !isNaN(card.value)}
function isAction(value){return['skip','reverse','draw2','wild','wild_draw4'].includes(value)}
function removeCardOnce(hand,card){
const sameRefIdx=hand.indexOf(card);
if(sameRefIdx!==-1){
hand.splice(sameRefIdx,1);
return true;
}
const sameValueIdx=hand.findIndex(c=>cardEq(c,card));
if(sameValueIdx!==-1){
hand.splice(sameValueIdx,1);
return true;
}
return false;
}
function syncUnoFlags(){
state.players.forEach(player=>{
if(player.hand.length!==1) player.saidUno=false;
});
}
function getPlacementMeta(rank){
const ordinal=(n)=>{
if(n%100>=11&&n%100<=13) return `${n}th`;
if(n%10===1) return `${n}st`;
if(n%10===2) return `${n}nd`;
if(n%10===3) return `${n}rd`;
return `${n}th`;
};
if(rank===1) return {label:'♕ Gold Crown',cls:'gold'};
if(rank===2) return {label:'♕ Silver Crown',cls:'silver'};
if(rank===3) return {label:'♕ Bronze Crown',cls:'bronze'};
return {label:ordinal(rank),cls:'place'};
}
function getPlayerAnchor(playerIdx){
const el=playerIdx===0
? document.getElementById('player-area')
: document.getElementById(`ai-opp-${playerIdx}`);
if(!el) return {x:window.innerWidth/2,y:window.innerHeight/2};
const r=el.getBoundingClientRect();
return {x:r.left+r.width/2,y:r.top+r.height/2};
}
function announceUno(playerIdx){
const player=state.players[playerIdx];
if(!player||player.saidUno||player.hand.length!==1) return;
player.saidUno=true;
const {x,y}=getPlayerAnchor(playerIdx);
const target=playerIdx===0
? document.getElementById('player-area')
: document.getElementById(`ai-label-${playerIdx}`);
if(target){
target.classList.remove('uno-shout');
void target.offsetWidth;
target.classList.add('uno-shout');
setTimeout(()=>target.classList.remove('uno-shout'),760);
}
spawnParticleBurst(x,y,playerIdx===0?'#f39c12':'#ffe08a');
if(playerIdx===0){
showToast('UNO!','player-turn');
} else {
showThought('UNO!');
}
}
function autoCallUnoForEligiblePlayers(){
if(!state) return;
state.players.forEach((player,idx)=>{
if(player.hand.length===1&&!player.saidUno) announceUno(idx);
});
}
function onPlayerFinish(playerIdx){
const player=state.players[playerIdx];
if(!player||player.finished) return;
player.finished=true;
player.rank=state.finishOrder.length+1;
state.finishOrder.push(playerIdx);
const remaining=state.players
.map((p,idx)=>({p,idx}))
.filter(({p})=>!p.finished);
if(remaining.length===1){
const last=remaining[0];
last.p.finished=true;
last.p.rank=state.finishOrder.length+1;
state.finishOrder.push(last.idx);
state.gamePhase='ended';
renderAll();
setTimeout(()=>showWinScreen(state.finishOrder[0]),900);
}
}
/* ============================================================
RULE VALIDATION
============================================================ */
function isValidPlay(card,topCard,curColor,pendingDraw){
// Draw stacking: +2 accepts +2 or +4; +4 accepts only +4.
if(pendingDraw>0){
return (topCard.value==='draw2'&&['draw2','wild_draw4'].includes(card.value))
|| (topCard.value==='wild_draw4'&&card.value==='wild_draw4');
}
if(isWild(card)) return true;
if(card.color===curColor) return true;
if(card.value===topCard.value) return true;
return false;
}
function getValidCards(hand,topCard,curColor,pendingDraw){
return hand.filter(c => isValidPlay(c,topCard,curColor,pendingDraw));
}
/* ============================================================
AI LOGIC
============================================================ */
function pickAIColor(hand){
const freq={};
hand.forEach(c => { if(c.color!=='wild') freq[c.color]=(freq[c.color]||0)+1; });
let best='red',bestN=0;
Object.entries(freq).forEach(([k,v]) => { if(v>bestN){bestN=v;best=k;} });
return best;
}
function getAIMove(player,topCard,curColor,pendingDraw,diff){
const valid=getValidCards(player.hand,topCard,curColor,pendingDraw);
if(!valid.length) return {action:'draw'};
const hand=player.hand;
const hasCurColor=hand.some(c=>c.color!=='wild'&&c.color===curColor);
if(diff==='easy'){
return {action:'play',card:valid[Math.floor(Math.random()*valid.length)]};
}
if(diff==='medium'){
const a=valid.find(c=>['skip','reverse','draw2'].includes(c.value)&&c.color===curColor);
if(a) return {action:'play',card:a};
const n=valid.find(c=>c.color===curColor&&isNum(c));
if(n) return {action:'play',card:n};
return {action:'play',card:valid[0]};
}
// Hard
if(!hasCurColor){
const wd4=valid.find(c=>c.value==='wild_draw4');
if(wd4) return {action:'play',card:wd4};
}
const a=valid.find(c=>['skip','reverse'].includes(c.value)&&c.color===curColor);
if(a) return {action:'play',card:a};
const d2=valid.find(c=>c.value==='draw2'&&c.color===curColor);
if(d2) return {action:'play',card:d2};
const nums=valid.filter(c=>c.color===curColor&&isNum(c));
if(nums.length){ nums.sort((a,b)=>parseInt(b.value)-parseInt(a.value)); return {action:'play',card:nums[0]}; }
const any=valid.find(c=>!isWild(c));
if(any) return {action:'play',card:any};
const w=valid.find(c=>isWild(c));
return {action:'play',card:w||valid[0]};
}
/* ============================================================
GAME INIT
============================================================ */
function initGame(difficulty,playerCount){
const deck=shuffle(createDeck());
_aiHandCounts={};
_playerHandRested=false;
_playerNeedsRestoreAnim=false;
clearTimeout(_playerAutoDrawTimer);
_playerAutoDrawTimer=null;
_colorWashSourceIdx=null;
_orbitAngle=null;
_orbitTargetIdx=null;
_orbitTargetAngle=null;
_orbitAngularVelocity=0;
_orbitLastFrame=0;
cancelAnimationFrame(_orbitAnimFrame);
cancelAnimationFrame(_playerHandAnimFrame);
_playerHandAnimFrame=0;
_playerHandLastFrame=0;
_playerHandBodies.clear();
_playerHandDrawSpawnIds.clear();
_playerHandSpawnMode='deal';
_playerHandSpawnCounter=0;
_playerHandRenderKey='';
cancelAnimationFrame(_badgeMarqueeFrame);
_badgeMarqueeFrame=0;
_badgeMarqueeLastFrame=0;
_badgeMarqueeAngle=0;
_badgeMarqueeSpeed=24;
_badgeMarqueeTargetSpeed=24;
const players=[{name:'You',hand:[],isAI:false,saidUno:false,finished:false,rank:null}];
for(let i=1;i<playerCount;i++) players.push({name:`Bot ${i}`,hand:[],isAI:true,saidUno:false,finished:false,rank:null});
for(let d=0;d<7;d++) for(let p=0;p<players.length;p++) players[p].hand.push(deck.pop());
let startCard=null;
while(!startCard&&deck.length){
const c=deck.pop();
if(isWild(c)){ deck.unshift(c); shuffle(deck); }
else startCard=c;
}
if(!startCard){ initGame(difficulty,playerCount); return; }
state={
deck,discardPile:[startCard],players,
currentPlayerIdx:0,direction:1,
currentColor:startCard.color,
gamePhase:'playing',difficulty,
scores:players.map(()=>0),
jumpInEnabled:true,pendingDraw:0,
finishOrder:[],
};
renderAll();
renderScores();
animateDeal();
// Fire initial color wash with start card color
setTimeout(()=>triggerColorWash(state.currentColor,null),300);
showToast("Your turn — play a card",'player-turn');
if(!isPlayerTurn()) triggerAITurn();
}
function isPlayerTurn(){ return state.players[state.currentPlayerIdx].name==='You'; }
function getTopCard(){ return state.discardPile[state.discardPile.length-1]; }
function nextPlayerIdx(){
if(!state) return 0;
let idx=state.currentPlayerIdx;
for(let i=0;i<state.players.length;i++){
idx=(idx+state.direction+state.players.length)%state.players.length;
if(!state.players[idx].finished) return idx;
}
return state.currentPlayerIdx;
}
/* ============================================================
RENDERING — CARD BUILDERS
============================================================ */
function buildCardFront(card){
const sym=CARD_SYMBOLS[card.value]||card.value;
const html=`
<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>`;
return html;
}
function buildCardEl(card){
const el=document.createElement('div');
el.className=`card ${card.color}`;
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 ensureDeckBatchReady({render=false}={}){
if(!state||state.deck.length>0) return false;
state.deck=shuffle(createDeck());
if(render) renderDrawPile();
return true;
}
function takeDrawBatch(count){
const drawn=[];
for(let i=0;i<count;i++){
ensureDeckBatchReady();
if(state.deck.length===0) break;
drawn.push(state.deck.pop());
}
return drawn;
}
function getDrawPileDisplayCount(){
if(!state) return 0;
return Math.min(state.deck.length,DRAW_STACK_MAX_VISIBLE);
}
function layoutDrawPileCards(stack,cards,targetCount){
cards.forEach((card,idx)=>{
card.style.left=(idx*3)+'px';
card.style.top=(-idx*2.5)+'px';
card.style.opacity=String(Math.max(0.45,1-idx*0.12));
card.style.zIndex=String(targetCount-idx);
});
stack.dataset.label=`剩${targetCount}`;
}
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 layoutDrawPileCards(stack,cards,targetCount){
cards.forEach((card,idx)=>{
card.style.left=(idx*3)+'px';
card.style.top=(-idx*2.5)+'px';
card.style.opacity=String(Math.max(0.45,1-idx*0.12));
card.style.zIndex=String(targetCount-idx);
});
stack.dataset.label=state?`剩${state.deck.length}`:'';
}
function getAIHandScale(){
if(window.innerWidth<480) return 0.36;
if(window.innerWidth<768) return 0.42;
return 0.5;
}
function getAIHandZoneWidth(){
return parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ai-hand-zone'))||220;
}
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=getAIHandZoneWidth();
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 {fanAngle,fanY,baseTransform}=getFanLayout(idx,visible,spread,6);
slot.dataset.baseTransform=baseTransform;
slot.dataset.fanAngle=fanAngle;
slot.dataset.fanY=fanY;
slot.style.transform=baseTransform;
if(idx>0) slot.style.marginLeft=`${overlap}px`;
const el=buildCardEl({color:'blue',value:'0'});
el.classList.add('flipped');
slot.appendChild(el);
handRow.appendChild(slot);
}
}
function animateHandInsertion(container,slotSelector,prevCount,addedCount,{
scale=1,
offsetX=0,
cardScale=1,
spin=300,
pushPerCard=26,
entryMode='center',
stagger=50,
onDone
}={}){
if(addedCount<=0){
if(onDone) onDone();
return;
}
const slots=[...container.querySelectorAll(slotSelector)];
if(!slots.length){
if(onDone) onDone();
return;
}
const existingShift=Math.min(pushPerCard*addedCount, pushPerCard*4);
const existingCenter=Math.max(0,(prevCount-1)/2);
const newCenter=Math.max(0,(addedCount-1)/2);
slots.forEach((slot,idx)=>{
const base=slot.dataset.idleTransform||slot.dataset.baseTransform||'rotateZ(0deg) translateY(0px)';
const cardEl=slot.querySelector('.card');
if(idx<prevCount){
const dir=entryMode==='center'
? (idx<existingCenter?-1:1)
: 1;
const shift=entryMode==='center'
? dir*existingShift*Math.max(0.4,1-Math.abs(idx-existingCenter)/Math.max(prevCount,1))
: existingShift;
slot.style.transition='none';
slot.style.transform=`translateX(${shift}px) ${base}`;
requestAnimationFrame(()=>{
slot.style.transition='transform 0.45s cubic-bezier(0.42,0,0.2,1)';
slot.style.transform=base;
});
return;
}
const newIdx=idx-prevCount;
const angle=parseFloat(slot.dataset.fanAngle)||0;
const fanY=parseFloat(slot.dataset.fanY)||0;
const centerOffset=(newIdx-newCenter)*14;
const entryX=entryMode==='center'
? centerOffset+offsetX
: offsetX+newIdx*14;
const entryAngle=entryMode==='center'
? angle+(newIdx<newCenter?-12:12)
: angle+18;
slot.style.transition='none';
slot.style.opacity='0';
slot.style.transform=`translateX(${entryX}px) rotateZ(${entryAngle}deg) translateY(${fanY+10}px) scale(${0.78*scale})`;
if(cardEl){
cardEl.style.transition='none';
cardEl.style.transform=`scale(${cardScale}) rotate(${spin}deg)`;
}
requestAnimationFrame(()=>{
setTimeout(()=>{
slot.style.transition='transform 0.4s cubic-bezier(0.42,0,0.2,1), opacity 0.25s ease';
slot.style.opacity='1';
slot.style.transform=base;
if(cardEl){
cardEl.style.transition='transform 0.4s cubic-bezier(0.42,0,0.2,1)';
cardEl.style.transform=`scale(${cardScale}) rotate(0deg)`;
}
},newIdx*stagger);
});
});
const totalDelay=Math.max(350,350+(addedCount-1)*stagger);
setTimeout(()=>{ if(onDone) onDone(); },totalDelay);
}
function captureSlotRects(container,slotSelector){
return [...container.querySelectorAll(slotSelector)].map(slot=>{
const r=slot.getBoundingClientRect();
return {left:r.left,top:r.top,width:r.width,height:r.height};
});
}
function animateHandCollapse(container,slotSelector,previousRects,removedIdx,{
duration=400,
startTransformResolver=null,
targetTransformResolver=null,
onDone
}={}){
const slots=[...container.querySelectorAll(slotSelector)];
if(!slots.length||!previousRects.length){
if(onDone) onDone();
return;
}
slots.forEach(slot=>{
const newIdx=parseInt(slot.dataset.idx,10);
const oldIdx=Number.isFinite(removedIdx)
? (newIdx<removedIdx?newIdx:newIdx+1)
: Math.min(newIdx,previousRects.length-1);
const prev=previousRects[oldIdx];
if(!prev) return;
const rect=slot.getBoundingClientRect();
const dx=prev.left-rect.left;
const dy=prev.top-rect.top;
const startTransform=startTransformResolver
? startTransformResolver(slot,newIdx,slots.length)
: (slot.dataset.baseTransform||slot.style.transform||'translateY(0px)');
const targetTransform=targetTransformResolver
? targetTransformResolver(slot,newIdx,slots.length)
: (slot.dataset.baseTransform||slot.style.transform||'translateY(0px)');
if(Math.abs(dx)<0.5&&Math.abs(dy)<0.5&&startTransform===targetTransform) return;
slot.style.transition='none';
slot.style.transform=`translate(${dx}px,${dy}px) ${startTransform}`;
requestAnimationFrame(()=>{
slot.style.transition=`transform ${duration}ms cubic-bezier(0.42,0,0.2,1)`;
slot.style.transform=targetTransform;
const cardEl=slot.querySelector('.card');
if(cardEl){
cardEl.style.transition=`filter ${duration}ms ease`;
if(targetTransform===(slot.dataset.restTransform||'')){
cardEl.style.filter='brightness(0.62) saturate(0.82)';
} else {
cardEl.style.filter='';
}
}
});
});
setTimeout(()=>{ if(onDone) onDone(); },duration);
}
function collapsePlayerHandAfterPlay(container,playedSlot,playedIdx,onDone){
if(!container){
if(onDone) onDone();
return;
}
const previousRects=captureSlotRects(container,'.card-slot');
container.removeEventListener('mousemove', onMouseMove);
onCardUnhover(container);
if(playedSlot&&playedSlot.parentElement===container){
playedSlot.remove();
}
const slots=[...container.querySelectorAll('.card-slot')];
if(!slots.length){
if(onDone) onDone();
return;
}
const total=slots.length;
slots.forEach((slot,idx)=>{
const {baseTransform}=getFanLayout(idx,total,24,10);
const restTransform=getPlayerRestTransform(idx,total);
slot.dataset.idx=idx;
slot.dataset.baseTransform=baseTransform;
slot.dataset.fanAngle=String(total>1?(idx/(total-1))*24-12:0);
slot.dataset.restTransform=restTransform;
slot.dataset.idleTransform=restTransform;
slot.style.transition='none';
slot.style.transform=restTransform;
const cardEl=slot.querySelector('.card');
if(cardEl){
cardEl.classList.remove('playable-card');
cardEl.style.transition='none';
cardEl.style.filter='brightness(0.62) saturate(0.82)';
cardEl.style.pointerEvents='none';
}
});
const finalRects=slots.map(slot=>slot.getBoundingClientRect());
slots.forEach((slot,newIdx)=>{
const oldIdx=newIdx<playedIdx?newIdx:newIdx+1;
const prev=previousRects[oldIdx];
const next=finalRects[newIdx];
if(!prev||!next) return;
const dx=prev.left-next.left;
const dy=prev.top-next.top;
slot.style.transform=`translate(${dx}px,${dy}px) ${slot.dataset.restTransform}`;
requestAnimationFrame(()=>{
slot.style.transition='transform 450ms cubic-bezier(0.42,0,0.2,1)';
slot.style.transform=slot.dataset.restTransform;
const cardEl=slot.querySelector('.card');
if(cardEl){
cardEl.style.transition='filter 450ms ease';
}
});
});
setTimeout(()=>{ if(onDone) onDone(); },450);
}
function getPlayerRestTransform(idx,total){
const {fanAngle,fanY}=getFanLayout(idx,total,24,10);
const center=(total-1)/2;
const gatherStrength=total<=3?18:13;
const towardCenter=(center-idx)*gatherStrength;
const compactAngle=fanAngle*0.26;
const compactY=fanY+16;
return `translateX(${towardCenter}px) rotateZ(${compactAngle}deg) translateY(${compactY}px) scale(0.78)`;
}
function getPlayerRestPose(idx,total){
const {fanAngle,fanY}=getFanLayout(idx,total,24,10);
const center=(total-1)/2;
const gatherStrength=total<=3?18:13;
return {
x:(center-idx)*gatherStrength,
y:fanY+16,
angle:fanAngle*0.26,
scale:0.78
};
}
function getPlayerActivePose(idx,total){
const {fanAngle,fanY}=getFanLayout(idx,total,24,10);
return {x:0,y:fanY,angle:fanAngle,scale:1};
}
function getPlayerHandCenterPoint(container){
const rect=container.getBoundingClientRect();
return {
x:rect.left+rect.width/2,
y:rect.top+rect.height/2
};
}
function getPlayerHandSpawnState(mode,slot,container,targetPose){
const slotRect=slot.getBoundingClientRect();
const slotCx=slotRect.left+slotRect.width/2;
const slotCy=slotRect.top+slotRect.height/2;
if(mode==='draw'){
const center=getPlayerHandCenterPoint(container);
return {
x:center.x-slotCx,
y:center.y-slotCy,
angle:targetPose.angle*0.45,
scale:0.84,
brightness:PLAYER_HAND_FORCES.restBrightness,
opacity:0
};
}
return {
x:0,
y:-180,
angle:targetPose.angle*0.5,
scale:0.82,
brightness:PLAYER_HAND_FORCES.restBrightness,
opacity:0
};
}
function hashCardId(cardId){
let hash=0;
for(let i=0;i<cardId.length;i++) hash=(hash*31+cardId.charCodeAt(i))%9973;
return hash;
}
function ensurePlayerHandBody(cardId,slot,cardEl,container,targetPose){
let body=_playerHandBodies.get(cardId);
if(body){
body.slot=slot;
body.cardEl=cardEl;
body.container=container;
return body;
}
const seed=hashCardId(cardId);
const spawnMode=_playerHandDrawSpawnIds.has(cardId)
? 'draw'
: _playerHandSpawnMode;
const spawn=getPlayerHandSpawnState(spawnMode,slot,container,targetPose);
body={
cardId,
slot,
cardEl,
container,
x:spawn.x,
y:spawn.y,
angle:spawn.angle,
scale:spawn.scale,
brightness:spawn.brightness,
opacity:spawn.opacity ?? 1,
vx:0,
vy:0,
va:0,
vs:0,
mass:1.02+(seed%3)*0.04,
brightnessVelocity:0,
spawnDelayMs:spawnMode==='deal'?_playerHandSpawnCounter*86:0,
pushRelaxMs:0
};
if(spawnMode==='deal') _playerHandSpawnCounter++;
_playerHandDrawSpawnIds.delete(cardId);
_playerHandBodies.set(cardId,body);
return body;
}
function getPlayerHandTargetPose(body,interactive){
const total=body.total||1;
const idx=body.idx||0;
const pose=interactive?getPlayerActivePose(idx,total):getPlayerRestPose(idx,total);
let z=100+idx;
let brightness=interactive?PLAYER_HAND_FORCES.activeBrightness:PLAYER_HAND_FORCES.restBrightness;
let opacity=1;
const touchSelectedIdx=interactive&&_touchSelectedCardId&&state?.players?.[0]
? state.players[0].hand.findIndex(card=>(card.id||`${card.color}_${card.value}`)===_touchSelectedCardId)
: -1;
if(interactive&&_hoverIdx!==-1&&body.cardEl){
const influenceRadius=220;
const maxPush=78;
const maxRot=18;
const maxLift=18;
const hoverLift=34;
const ambientScaleBoost=0.08;
const hoverScale=1.18;
const rect=body.cardEl.getBoundingClientRect();
const centerX=rect.left+rect.width/2;
const centerY=rect.top+rect.height/2;
const dx=lastMouseX-centerX;
const dy=lastMouseY-centerY;
const dist=Math.hypot(dx,dy);
const influence=Math.max(0,1-dist/influenceRadius);
const distanceRatio=dist/influenceRadius;
const falloff=2/(1+Math.pow(distanceRatio,1)*3);
if(idx===_hoverIdx){
const attractX=dx*0.18;
const attractRot=pose.angle+(dx/Math.max(rect.width,1))*4;
pose.x=attractX;
pose.angle=attractRot;
pose.y=pose.y-hoverLift;
pose.scale=hoverScale;
z=200;
} else {
const dir=centerX<lastMouseX?-1:1;
pose.x=dir*distanceRatio*maxPush*falloff;
pose.angle=pose.angle+dir*(distanceRatio*maxRot*falloff+influence*2.5);
pose.y=pose.y-(distanceRatio*maxLift*falloff);
pose.scale=1+distanceRatio*ambientScaleBoost*falloff;
}
}
if(interactive&&isTouchPrimaryInput()&&touchSelectedIdx!==-1){
if(idx===touchSelectedIdx){
pose.y-=26;
pose.scale=1.12;
z=230;
brightness=1.04;
} else if(Math.abs(idx-touchSelectedIdx)<=2){
const pushDir=idx<touchSelectedIdx?-1:1;
const pushStrength=Math.max(0,1-Math.abs(idx-touchSelectedIdx)/3);
pose.x+=pushDir*16*pushStrength;
pose.y-=6*pushStrength;
}
}
return {...pose,z,brightness,opacity};
}
function applyPlayerHandBody(body,target,dt){
if(body.spawnDelayMs>0){
body.spawnDelayMs=Math.max(0,body.spawnDelayMs-dt*1000);
body.cardEl.style.opacity='0';
return;
}
if(body.pushRelaxMs>0){
body.pushRelaxMs=Math.max(0,body.pushRelaxMs-dt*1000);
}
const relaxRatio=body.pushRelaxMs>0 ? body.pushRelaxMs/220 : 0;
const springFactor=1-relaxRatio*0.45;
const dragFactor=1+relaxRatio*0.32;
const fx=(target.x-body.x)*(PLAYER_HAND_FORCES.springX*springFactor)-body.vx*(PLAYER_HAND_FORCES.dragX*dragFactor);
const fy=(target.y-body.y)*(PLAYER_HAND_FORCES.springY*springFactor)-body.vy*(PLAYER_HAND_FORCES.dragY*dragFactor);
const fa=(target.angle-body.angle)*(PLAYER_HAND_FORCES.springAngle*springFactor)-body.va*(PLAYER_HAND_FORCES.dragAngle*dragFactor);
body.vx+=(fx/body.mass)*dt;
body.vy+=(fy/body.mass)*dt;
body.va+=(fa/body.mass)*dt;
body.x+=body.vx*dt;
body.y+=body.vy*dt;
body.angle+=body.va*dt;
body.scale=target.scale;
const brightnessForce=(target.brightness-body.brightness)*18-body.brightnessVelocity*10;
body.brightnessVelocity+=(brightnessForce/body.mass)*dt;
body.brightness+=body.brightnessVelocity*dt;
body.opacity+=(target.opacity-body.opacity)*Math.min(1,dt*12);
body.slot.style.transform=`translateX(${body.x.toFixed(2)}px) rotateZ(${body.angle.toFixed(2)}deg) translateY(${body.y.toFixed(2)}px) scale(${body.scale.toFixed(4)})`;
body.slot.style.zIndex=String(target.z);
body.cardEl.style.filter=`brightness(${body.brightness.toFixed(3)}) saturate(${(0.78+body.brightness*0.22).toFixed(3)})`;
body.cardEl.style.opacity=body.opacity.toFixed(3);
}
function stepPlayerHandPhysics(now){
if(!_playerHandBodies.size){
_playerHandAnimFrame=0;
_playerHandLastFrame=0;
return;
}
if(!_playerHandLastFrame) _playerHandLastFrame=now;
const dt=Math.min(0.032,Math.max(0.008,(now-_playerHandLastFrame)/1000));
_playerHandLastFrame=now;
let activeBodies=0;
_playerHandBodies.forEach(body=>{
if(!body.slot||!body.slot.isConnected||!body.cardEl||!body.cardEl.isConnected) return;
activeBodies++;
const target=getPlayerHandTargetPose(body,body.interactive);
applyPlayerHandBody(body,target,dt);
});
if(!activeBodies){
_playerHandAnimFrame=0;
_playerHandLastFrame=0;
return;
}
_playerHandAnimFrame=requestAnimationFrame(stepPlayerHandPhysics);
}
function ensurePlayerHandPhysicsLoop(){
if(_playerHandAnimFrame) return;
_playerHandAnimFrame=requestAnimationFrame(stepPlayerHandPhysics);
}
function getPlayerHandRenderKey(){
if(!state) return '';
return state.players[0].hand.map(card=>card.id||`${card.color}_${card.value}`).join('|');
}
function updatePlayerHandSlot(slot,card,idx,total,interactiveTurn,validIdSet,container){
const el=slot.querySelector('.card');
const cardId=card.id||`${card.color}_${card.value}_${idx}`;
const activePose=getPlayerActivePose(idx,total);
const restPose=getPlayerRestPose(idx,total);
slot.dataset.idx=idx;
slot.dataset.cardId=cardId;
slot.dataset.baseTransform=`translateX(${activePose.x}px) rotateZ(${activePose.angle}deg) translateY(${activePose.y}px) scale(${activePose.scale})`;
slot.dataset.restTransform=`translateX(${restPose.x}px) rotateZ(${restPose.angle}deg) translateY(${restPose.y}px) scale(${restPose.scale})`;
slot.dataset.idleTransform=interactiveTurn?slot.dataset.baseTransform:slot.dataset.restTransform;
if(el){
el.classList.toggle('playable-card',validIdSet.has(cardId));
el.classList.toggle('touch-selected',_touchSelectedCardId===cardId&&interactiveTurn&&isTouchPrimaryInput());
el.onclick=interactiveTurn?()=>onPlayerCardActivate(idx):null;
el.style.pointerEvents=interactiveTurn?'':'none';
}
const body=ensurePlayerHandBody(cardId,slot,el,container,interactiveTurn?activePose:restPose);
body.idx=idx;
body.total=total;
body.interactive=interactiveTurn;
body.valid=validIdSet.has(cardId);
slot.style.transform=`translateX(${body.x.toFixed(2)}px) rotateZ(${body.angle.toFixed(2)}deg) translateY(${body.y.toFixed(2)}px) scale(${body.scale.toFixed(4)})`;
slot.style.zIndex=String(100+idx);
if(el){
el.style.filter=`brightness(${body.brightness.toFixed(3)}) saturate(${(0.78+body.brightness*0.22).toFixed(3)})`;
el.style.opacity=body.opacity.toFixed(3);
}
}
function animatePlayerWaitTransition(cardValue,onDone){
const container=document.getElementById('player-hand');
const slots=container?[...container.querySelectorAll('.card-slot')]:[];
if(!slots.length){
if(onDone) onDone();
return;
}
const duration=cardValue==='reverse'?450:400;
slots.forEach((slot,idx)=>{
slot.style.transition=`transform ${duration}ms cubic-bezier(0.42,0,0.2,1)`;
slot.style.transform=slot.dataset.restTransform||getPlayerRestTransform(idx,slots.length);
const cardEl=slot.querySelector('.card');
if(cardEl){
cardEl.style.transition=`filter ${duration}ms ease, opacity ${duration}ms ease`;
cardEl.style.filter='brightness(0.62) saturate(0.82)';
}
});
setTimeout(()=>{ if(onDone) onDone(); },duration);
}
function endPlayerTurnWithGather(card){
_playerHandRested=true;
_playerNeedsRestoreAnim=true;
finishTurn(card);
}
function getCardVisualSize(){
const root=getComputedStyle(document.documentElement);
const width=(parseFloat(root.getPropertyValue('--card-w'))||76)*1.16;
const height=(parseFloat(root.getPropertyValue('--card-h'))||114)*1.16;
return {width,height};
}
function toFlightPosition(anchor,size=getCardVisualSize()){
if(anchor && typeof anchor.width==='number' && typeof anchor.height==='number'){
return {
left:anchor.left+anchor.width/2-size.width/2,
top:anchor.top+anchor.height/2-size.height/2
};
}
return {left:anchor.left,top:anchor.top};
}
function getDiscardCenterAnchor(offsetX=0,offsetY=0){
const dz=document.getElementById('discard-zone');
const size=getCardVisualSize();
if(!dz) return {left:window.innerWidth/2-size.width/2+offsetX,top:window.innerHeight/2-size.height/2+offsetY};
const r=dz.getBoundingClientRect();
return {
left:r.left+r.width/2-size.width/2+offsetX,
top:r.top+r.height/2-size.height/2+offsetY
};
}
function getHandInsertAnchor(container,scale=1,{
rightInset=0.72,
bottomBias=0.94
}={}){
const size=getCardVisualSize();
const r=container.getBoundingClientRect();
return {
left:r.right-size.width*scale*rightInset,
top:r.bottom-size.height*scale*bottomBias
};
}
function getHandCenterAnchor(container){
const size=getCardVisualSize();
const r=container.getBoundingClientRect();
return {
left:r.left+r.width/2-size.width/2,
top:r.top+r.height/2-size.height/2
};
}
function getPlayerStageAnchor(){
const size=getCardVisualSize();
const hand=document.getElementById('player-hand');
const area=document.getElementById('player-area');
const rect=(hand||area)?.getBoundingClientRect();
if(!rect){
return getDiscardCenterAnchor(-54,0);
}
return {
left:rect.left+rect.width/2-size.width/2,
top:rect.top-size.height-18
};
}
function createFlyingCard(card,{faceUp=true,zIndex=500,mountTo=null}={}){
const flying=buildCardEl(card||{color:'blue',value:'0'});
flying.classList.add('flying-card');
if(!faceUp) flying.classList.add('flipped');
const size=getCardVisualSize();
flying.style.cssText=`
position:fixed;
left:0;top:0;
width:${size.width}px;height:${size.height}px;
z-index:${zIndex};
pointer-events:none;
transform-origin:center center;
`;
(mountTo||document.body).appendChild(flying);
return flying;
}
function animateCardFlight({
card,
from,
to,
faceUp=true,
duration=440,
delay=0,
scaleTo=0.84,
rotateTo=8,
opacityTo=0.82,
fadeOutMs=100,
fadeScaleTo=null,
zIndex=500,
mountTo=null,
persist=false,
approachLead=180,
onApproach,
onDone
}){
const flying=createFlyingCard(card,{faceUp,zIndex,mountTo});
const size=getCardVisualSize();
const start=toFlightPosition(from,size);
const end=toFlightPosition(to,size);
flying.style.left=`${start.left}px`;
flying.style.top=`${start.top}px`;
setTimeout(()=>{
flying.style.transition=`left ${duration}ms cubic-bezier(0.2,0.8,0.3,1), top ${duration}ms cubic-bezier(0.2,0.8,0.3,1), transform ${duration}ms cubic-bezier(0.2,0.8,0.3,1), opacity ${duration}ms ease`;
requestAnimationFrame(()=>{
flying.style.left=`${end.left}px`;
flying.style.top=`${end.top}px`;
flying.style.transform=`rotate(${rotateTo}deg) scale(${scaleTo})`;
flying.style.opacity=String(opacityTo);
});
},delay);
if(onApproach){
setTimeout(()=>onApproach(flying),delay+Math.max(40,duration-approachLead));
}
if(!persist){
const fadeMs=Math.max(40,fadeOutMs);
const fadeScale=fadeScaleTo===null?Math.max(0.66,scaleTo-0.12):fadeScaleTo;
setTimeout(()=>{
if(typeof flying.animate==='function'){
flying.animate([
{
opacity:getComputedStyle(flying).opacity,
transform:getComputedStyle(flying).transform
},
{
opacity:'0',
transform:`rotate(${rotateTo}deg) scale(${fadeScale})`
}
],{
duration:fadeMs,
easing:'ease-out',
fill:'forwards'
});
} else {
flying.style.transition=`opacity ${fadeMs}ms linear, transform ${fadeMs}ms ease-out`;
flying.style.opacity='0';
flying.style.transform=`rotate(${rotateTo}deg) scale(${fadeScale})`;
}
},delay+Math.max(0,duration-fadeMs));
}
setTimeout(()=>{
if(!persist) flying.remove();
if(onDone) onDone(flying);
},delay+duration);
}
function peekUpcomingDrawCards(count){
if(!state) return [];
const cards=[];
let snapshot=state.deck.slice();
for(let i=0;i<count;i++){
if(snapshot.length===0){
snapshot=shuffle(createDeck());
}
if(snapshot.length===0) break;
cards.push(snapshot[snapshot.length-1]);
snapshot.pop();
}
return cards;
}
function animateDrawPileConsumption(count,startDelay=0,interval=50){
const stack=document.getElementById('draw-stack');
if(!stack) return;
for(let i=0;i<count;i++){
setTimeout(()=>{
const cards=[...stack.querySelectorAll('.card-back-mini:not(.stack-fly-out)')];
if(!cards.length) return;
const topCard=cards.sort((a,b)=>(parseInt(b.style.zIndex,10)||0)-(parseInt(a.style.zIndex,10)||0))[0];
if(!topCard) return;
topCard.style.zIndex=String(cards.length+2);
topCard.classList.add('stack-fly-out');
const remaining=[...stack.querySelectorAll('.card-back-mini:not(.stack-fly-out)')];
layoutDrawPileCards(stack,remaining,remaining.length);
setTimeout(()=>{
if(topCard.parentElement) topCard.remove();
},240);
},startDelay+i*interval);
}
}
function animateDrawFromPileToTarget(target,count,{
revealCards=false,
previewCards=[],
onEachApproach,
onApproach,
onDone
}={}){
const drawPile=document.getElementById('draw-pile');
const board=document.getElementById('game-board');
if(!drawPile){
if(onDone) onDone();
return;
}
const pileRect=drawPile.getBoundingClientRect();
animateDrawPileConsumption(count);
for(let i=0;i<count;i++){
const preview=previewCards[i]||previewCards[previewCards.length-1]||{color:'blue',value:'0'};
animateCardFlight({
card:preview,
from:{
left:pileRect.left+(i%3)*2,
top:pileRect.top+(i%3)*2,
width:pileRect.width,
height:pileRect.height
},
to:target,
faceUp:revealCards,
duration:400+i*40,
delay:i*50,
scaleTo:0.74,
rotateTo:(Math.random()*10-5),
opacityTo:0.96,
zIndex:12,
mountTo:board||undefined,
persist:false,
approachLead:100,
onApproach:()=>{
if(onEachApproach) onEachApproach(i);
if(i===count-1&&onApproach) onApproach();
},
onDone:i===count-1?()=>{ if(onDone) onDone(); }:undefined
});
}
}
function animateDrawToPlayerSequential(target,previewCards,{
revealCards=true,
onEachApproach,
onDone
}={}){
const drawPile=document.getElementById('draw-pile');
const board=document.getElementById('game-board');
if(!drawPile){
if(onDone) onDone();
return;
}
const pileRect=drawPile.getBoundingClientRect();
const cards=previewCards.slice();
cards.forEach((card,idx)=>{
const delay=idx*116;
animateDrawPileConsumption(1,delay,0);
animateCardFlight({
card:card||{color:'blue',value:'0'},
from:{
left:pileRect.left+(idx%3)*2,
top:pileRect.top+(idx%3)*2,
width:pileRect.width,
height:pileRect.height
},
to:target,
faceUp:revealCards,
duration:380,
delay,
scaleTo:0.74,
rotateTo:(Math.random()*10-5),
opacityTo:0.96,
zIndex:12,
mountTo:board||undefined,
persist:false,
approachLead:95,
onApproach:()=>{
if(onEachApproach) onEachApproach(idx);
},
onDone:idx===cards.length-1?()=>{ if(onDone) onDone(); }:undefined
});
});
}
/* ============================================================
RENDER — AI ROW
============================================================ */
function renderAI(){
const aiRow=document.getElementById('ai-row');
aiRow.innerHTML='';
for(let i=1;i<state.players.length;i++){
const p=state.players[i];
const opp=document.createElement('div');
opp.className='ai-opponent'+(state.currentPlayerIdx===i?' active-ai':'');
opp.id=`ai-opp-${i}`;
const labelRow=document.createElement('div');
labelRow.className='ai-label-row'+(state.currentPlayerIdx===i?' active':'');
labelRow.id=`ai-label-${i}`;
labelRow.innerHTML=`<span class="active-dot"></span><span class="ai-name">${p.name}</span>`;
const count=document.createElement('div');
count.className='ai-card-count';
count.id=`ai-count-${i}`;
count.textContent=p.finished&&p.rank?getPlacementMeta(p.rank).label:`${p.hand.length} cards`;
const handRow=document.createElement('div');
handRow.className='ai-hand-row';
handRow.id=`ai-hand-${i}`;
renderAIHandRow(handRow,p.hand.length);
if(p.rank){
const badge=document.createElement('div');
const meta=getPlacementMeta(p.rank);
badge.className=`placement-badge ${meta.cls}`;
badge.textContent=meta.label;
handRow.appendChild(badge);
}
_aiHandCounts[i]=p.hand.length;
opp.appendChild(labelRow);
opp.appendChild(count);
opp.appendChild(handRow);
aiRow.appendChild(opp);
}
updateActiveAI();
}
function updateActiveAI(){
for(let i=1;i<state.players.length;i++){
const opp=document.getElementById(`ai-opp-${i}`);
const label=document.getElementById(`ai-label-${i}`);
const count=document.getElementById(`ai-count-${i}`);
if(!label||!count) continue;
const active=state.currentPlayerIdx===i;
if(opp) opp.className='ai-opponent'+(active?' active-ai':'');
label.className='ai-label-row'+(active?' active':'');
const p=state.players[i];
count.textContent=p.finished&&p.rank?getPlacementMeta(p.rank).label:`${p.hand.length} cards`;
}
}
/* ============================================================
DEAL ANIMATION
============================================================ */
function animateDeal(){
document.querySelectorAll('.ai-hand-row').forEach((row,ri)=>{
const slots=row.querySelectorAll('.ai-card-slot');
slots.forEach((slot,i)=>{
const base=slot.dataset.baseTransform||'rotateZ(0deg) 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+ri*150+i*50);
});
});
}
/* ============================================================
RENDER — DRAW PILE
============================================================ */
function renderDrawPile(){
const stack=document.getElementById('draw-stack');
const show=getDrawPileDisplayCount();
const cards=[...stack.querySelectorAll('.card-back-mini:not(.stack-fly-out)')];
if(cards.length<show){
for(let i=cards.length;i<show;i++){
const card=buildCardBackMini();
card.classList.add('stack-enter');
stack.appendChild(card);
requestAnimationFrame(()=>{
card.classList.remove('stack-enter');
});
}
} else if(cards.length>show){
cards.slice(show).forEach(card=>card.remove());
}
layoutDrawPileCards(stack,[...stack.querySelectorAll('.card-back-mini:not(.stack-fly-out)')],show);
// Pending badge: show remaining penalty count
const zone=document.getElementById('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.style.opacity='0';
}
}
/* ============================================================
RENDER — DISCARD PILE (scattered, persistent elements)
============================================================ */
function renderDiscard(){
const scatter=document.getElementById('discard-scatter');
const total=state.discardPile.length;
if(total===0){ scatter.innerHTML=''; return; }
const prevTotal=parseInt(scatter.dataset.total)||0;
if(prevTotal===total){
return; // nothing changed — skip to prevent flicker
}
scatter.dataset.total=total;
const isNewCard=total>prevTotal;
// Map existing elements by their stable card identity: value_color_idx
const existing=new Map();
scatter.querySelectorAll('.card').forEach(el=>{
if(el.dataset.id) existing.set(el.dataset.id,el);
});
function seededRand(seed){
const x=Math.sin(seed*9301+49297)*233280;
return x-Math.floor(x);
}
// Remove cards that are no longer in the pile
existing.forEach((el,id)=>{
if(!state.discardPile.some((c,i)=>`${c.value}_${c.color}_${i}`===id)){
el.remove();
}
});
state.discardPile.forEach((card,idx)=>{
const id=`${card.value}_${card.color}_${idx}`;
let el=existing.get(id);
const age=total-1-idx;
const bound=Math.min(70,30+age*5);
const ox=(seededRand(idx*3+1)-0.5)*bound*2;
const oy=(seededRand(idx*3+2)-0.5)*bound*2;
const rot=(seededRand(idx*3+3)-0.5)*40;
const brightness=Math.max(0.3,1-age*0.07);
const faceKey=`${card.color}_${card.value}`;
if(!el){
// Brand new card
el=buildCardEl(card);
el.dataset.id=id;
el.dataset.color=card.color;
el.dataset.value=card.value;
el.style.position='absolute';el.style.left='50%';el.style.top='50%';
el.style.transition='none';
el.style.opacity='0';
} else {
// Existing — update its CSS for smooth slide to new position
el.dataset.color=card.color;
el.dataset.value=card.value;
// Only update position transition if this is NOT the flying card landing
if(!el.classList.contains('flying-card')){
el.style.transition='transform 0.45s cubic-bezier(0.34,1.2,0.64,1), filter 0.3s, opacity 0.2s';
}
}
el.style.setProperty('--discard-x',`${ox}px`);
el.style.setProperty('--discard-y',`${oy}px`);
el.style.setProperty('--discard-rot',`${rot}deg`);
el.style.transform=`translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px)) rotate(${rot}deg)`;
el.style.filter=`brightness(${brightness})`;
el.style.pointerEvents='none';
if(idx===total-1){
// Top card — always show face, add bounce if genuinely new
el.className=`card ${card.color}`;
if(el.dataset.faceKey!==faceKey){
el.innerHTML=`<div class="card-inner"><div class="card-front">${buildCardFront(card)}</div><div class="card-back"></div></div>`;
el.dataset.faceKey=faceKey;
}
el.style.zIndex=99;
if(isNewCard){
el.classList.remove('discard-land-anim');
void el.offsetWidth;
el.classList.add('discard-land-anim');
el.style.transition='';
el.style.opacity='1';
} else {
el.style.opacity='1';
}
} else {
el.style.zIndex=idx;
el.style.opacity='1';
}
if(!scatter.contains(el)) scatter.appendChild(el);
});
}
/* ============================================================
RENDER — PLAYER HAND
============================================================ */
function renderPlayerHand(){
const container=document.getElementById('player-hand');
const playerArea=document.getElementById('player-area');
const previousAnchors=new Map();
_playerHandBodies.forEach((body,cardId)=>{
if(body.slot&&body.slot.isConnected){
const rect=body.slot.getBoundingClientRect();
previousAnchors.set(cardId,{
x:rect.left+rect.width/2,
y:rect.top+rect.height/2
});
}
});
playerArea.querySelector('.player-placement')?.remove();
container.removeEventListener('mousemove', onMouseMove);
const top=getTopCard();
const valid=getValidCards(state.players[0].hand,top,state.currentColor,state.pendingDraw);
const validIdSet=new Set(valid.map(card=>card.id||`${card.color}_${card.value}`));
const isMyTurn=isPlayerTurn();
const interactiveTurn=isMyTurn&&!playerLocked&&!pendingWildCard&&!state.players[0].finished;
const total=state.players[0].hand.length;
const handKey=getPlayerHandRenderKey();
if(!interactiveTurn||!state.players[0].hand.some(card=>(card.id||`${card.color}_${card.value}`)===_touchSelectedCardId)){
_touchSelectedCardId=null;
}
const activeIds=new Set();
state.players[0].hand.forEach(card=>{
activeIds.add(card.id||`${card.color}_${card.value}`);
});
container.style.cssText='';
[...container.querySelectorAll('.card-slot')].forEach(slot=>{
if(slot.dataset.cardId&&!activeIds.has(slot.dataset.cardId)){
slot.remove();
}
});
const slotMap=new Map(
[...container.querySelectorAll('.card-slot')].map(slot=>[slot.dataset.cardId,slot])
);
state.players[0].hand.forEach((card,idx)=>{
const cardId=card.id||`${card.color}_${card.value}_${idx}`;
let slot=slotMap.get(cardId);
if(!slot){
slot=document.createElement('div');
slot.className='card-slot';
slot.appendChild(buildCardEl(card));
const nextSibling=container.children[idx]||null;
container.insertBefore(slot,nextSibling);
}
updatePlayerHandSlot(slot,card,idx,total,interactiveTurn,validIdSet,container);
const body=_playerHandBodies.get(cardId);
const previousAnchor=previousAnchors.get(cardId);
if(body&&previousAnchor&&handKey!==_playerHandRenderKey){
const nextRect=slot.getBoundingClientRect();
body.x=previousAnchor.x-(nextRect.left+nextRect.width/2);
body.y=previousAnchor.y-(nextRect.top+nextRect.height/2);
slot.style.transform=`translateX(${body.x.toFixed(2)}px) rotateZ(${body.angle.toFixed(2)}deg) translateY(${body.y.toFixed(2)}px) scale(${body.scale.toFixed(4)})`;
}
});
[..._playerHandBodies.keys()].forEach(cardId=>{
if(!activeIds.has(cardId)) _playerHandBodies.delete(cardId);
});
_playerHandRenderKey=handKey;
if(interactiveTurn&&!isTouchPrimaryInput()){
container.addEventListener('mousemove', onMouseMove);
}
container.onmouseenter=(e)=>{
if(!state||!interactiveTurn||isTouchPrimaryInput()) return;
container.addEventListener('mousemove', onMouseMove);
onMouseMove(e);
};
container.onmouseleave=()=>{
container.removeEventListener('mousemove', onMouseMove);
onCardUnhover(container);
};
if(!interactiveTurn){
onCardUnhover(container);
}
ensurePlayerHandPhysicsLoop();
_playerNeedsRestoreAnim=false;
if(state.players[0].rank){
const meta=getPlacementMeta(state.players[0].rank);
const badge=document.createElement('div');
badge.className=`placement-badge player-placement ${meta.cls}`;
badge.textContent=meta.label;
playerArea.appendChild(badge);
}
if(_playerHandSpawnMode!=='steady'){
requestAnimationFrame(()=>{ _playerHandSpawnMode='steady'; });
}
}
// Track which card index the mouse is over and last mouse position
let _hoverIdx=-1;
let lastMouseX=0;
let lastMouseY=0;
function onMouseMove(e){
const container=document.getElementById('player-hand');
if(!state||!isPlayerTurn()||playerLocked||pendingWildCard){
return;
}
lastMouseX=e.clientX;
lastMouseY=e.clientY;
const slots=[...container.querySelectorAll('.card-slot')];
if(slots.length===0) return;
// Only count cards the mouse is truly over, then pick the one whose center is closest.
let hitIdx=-1;
let bestDist=Infinity;
slots.forEach((slot,i)=>{
const cardEl=slot.querySelector('.card');
if(!cardEl) return;
const r=cardEl.getBoundingClientRect();
if(e.clientX>=r.left&&e.clientX<=r.right&&e.clientY>=r.top&&e.clientY<=r.bottom){
const cx=r.left+r.width/2;
const cy=r.top+r.height/2;
const dist=Math.hypot(e.clientX-cx,e.clientY-cy);
if(dist<bestDist){
bestDist=dist;
hitIdx=i;
}
}
});
_hoverIdx=hitIdx;
}
function onCardUnhover(container){
_hoverIdx=-1;
if(container){
container.removeEventListener('mousemove', onMouseMove);
}
}
function renderScores(){
const row=document.getElementById('score-row');
row.innerHTML='';
}
let _prevColor=undefined;
let _skipWashNext = false; // skip wash when wild is played (modal handles it)
function renderColorBadge(){
const changed=_prevColor!==undefined&&_prevColor!==state.currentColor;
_prevColor=state.currentColor;
const dot=document.getElementById('color-dot');
const txt=document.getElementById('color-text');
const badge=document.getElementById('color-badge');
// Ripple animation on color change
if(changed){
badge.classList.remove('color-ripple');
void badge.offsetWidth;
badge.classList.add('color-ripple');
setTimeout(()=>badge.classList.remove('color-ripple'),700);
}
// Full-screen color wash ripple (skip for wild → modal will trigger it)
if(changed && !_skipWashNext){
triggerColorWash(state.currentColor,_colorWashSourceIdx);
}
_skipWashNext=false;
// Apply current color to badge dot and text
const hex=COLOR_HEX[state.currentColor]||'#ffffff';
dot.style.background=hex;
dot.style.color=hex;
txt.textContent=COLOR_NAMES[state.currentColor]||state.currentColor.toUpperCase();
badge.style.borderColor=hex+'55';
badge.style.setProperty('--marquee-color',hex);
ensureBadgeMarqueeLoop();
}
function triggerColorWash(color,playerIdx){
const wash=document.getElementById('color-wash');
if(!wash) return;
const hex=COLOR_HEX[color]||'#ffffff';
const r=parseInt(hex.slice(1,3),16);
const g=parseInt(hex.slice(3,5),16);
const b=parseInt(hex.slice(5,7),16);
// Center wash at player's area; no owner defaults to screen center.
let cx='50%',cy='50%';
if(playerIdx===0){
// Player: center on their hand area
cy='85%'; cx='50%';
} else if(playerIdx>0){
// AI: center on their opponent card
const opp=document.getElementById(`ai-hand-${playerIdx}`)||document.getElementById(`ai-opp-${playerIdx}`);
if(opp){
const r2=opp.getBoundingClientRect();
cx=((r2.left+r2.width/2)/window.innerWidth*100).toFixed(1)+'%';
cy=((r2.top+r2.height/2)/window.innerHeight*100).toFixed(1)+'%';
}
}
wash.style.setProperty('--wash-r',r);
wash.style.setProperty('--wash-g',g);
wash.style.setProperty('--wash-b',b);
wash.style.setProperty('--wash-cx',cx);
wash.style.setProperty('--wash-cy',cy);
wash.classList.remove('wash-ripple');
void wash.offsetWidth;
wash.classList.add('wash-ripple');
setTimeout(()=>wash.classList.remove('wash-ripple'),640);
}
function getOrbitGeometry(){
const track=document.getElementById('orbit-track');
if(!track) return null;
const rect=track.getBoundingClientRect();
const cx=rect.left+rect.width/2;
const cy=rect.top+rect.height/2;
const topRow=document.getElementById('top-row');
const playerArea=document.getElementById('player-area');
const topLimit=topRow?topRow.getBoundingClientRect().bottom+18:32;
const bottomLimit=playerArea?playerArea.getBoundingClientRect().top-18:window.innerHeight-40;
const maxVertical=Math.min(rect.height/2,cy-topLimit,bottomLimit-cy);
return {
cx,
cy,
a:rect.width/2,
b:Math.max(0,maxVertical)
};
}
function getOrbitTargetElement(playerIdx){
return playerIdx===0
? document.getElementById('player-area')
: document.getElementById(`ai-opp-${playerIdx}`);
}
function getOrbitTargetPoint(playerIdx){
const targetEl=getOrbitTargetElement(playerIdx);
if(!targetEl) return null;
const rect=targetEl.getBoundingClientRect();
if(playerIdx===0){
const handRect=document.getElementById('player-hand')?.getBoundingClientRect();
return {
x:rect.left+rect.width/2,
y:(handRect?handRect.top:rect.top)-20
};
}
return {
x:rect.left+rect.width/2,
y:rect.top+Math.min(rect.height*0.46,rect.height-18)
};
}
function wrapPlayerIdx(idx){
return (idx+state.players.length)%state.players.length;
}
function getOrbitAngleForPlayer(playerIdx){
const geom=getOrbitGeometry();
const target=getOrbitTargetPoint(playerIdx);
if(!geom||!target) return null;
return Math.atan2(target.y-geom.cy,target.x-geom.cx);
}
function normalizeAngle(angle){
let out=angle%TAU;
if(out<=-Math.PI) out+=TAU;
if(out>Math.PI) out-=TAU;
return out;
}
function getDirectedOrbitDelta(from,to,direction){
let delta=normalizeAngle(to-from);
if(direction>=0&&delta<0) delta+=TAU;
if(direction<0&&delta>0) delta-=TAU;
return delta;
}
function setOrbitPointer(angle){
const pointer=document.getElementById('orbit-pointer');
const geom=getOrbitGeometry();
if(!pointer||!state||angle===null||!geom){
if(pointer) pointer.style.opacity='0';
return;
}
const px=geom.cx+geom.a*Math.cos(angle);
const py=geom.cy+geom.b*Math.sin(angle);
const deg=angle*180/Math.PI+90;
const hex=COLOR_HEX[state.currentColor]||'#ffffff';
pointer.style.opacity='1';
pointer.style.left=`${px-10}px`;
pointer.style.top=`${py-11}px`;
pointer.style.transform=`rotate(${deg}deg)`;
pointer.style.borderBottomColor=hex;
pointer.style.filter=`drop-shadow(0 0 10px ${hex}aa)`;
}
function stepOrbitPhysics(now){
if(_orbitAngle===null||_orbitTargetAngle===null){
setBadgeMarqueeTargetSpeed(24);
_orbitAnimFrame=0;
_orbitLastFrame=0;
return;
}
if(!_orbitLastFrame) _orbitLastFrame=now;
const dt=Math.min(0.032,Math.max(0.008,(now-_orbitLastFrame)/1000));
_orbitLastFrame=now;
const spring=50;
const damping=10;
const delta=_orbitTargetAngle-_orbitAngle;
const accel=delta*spring-_orbitAngularVelocity*damping;
_orbitAngularVelocity+=accel*dt;
_orbitAngle+=_orbitAngularVelocity*dt;
setBadgeMarqueeTargetSpeed(24+Math.min(360,Math.abs(_orbitAngularVelocity)*112));
setOrbitPointer(_orbitAngle);
if(Math.abs(delta)<0.0015&&Math.abs(_orbitAngularVelocity)<0.02){
_orbitAngle=_orbitTargetAngle;
_orbitAngularVelocity=0;
setBadgeMarqueeTargetSpeed(24);
setOrbitPointer(_orbitAngle);
_orbitAnimFrame=0;
_orbitLastFrame=0;
return;
}
_orbitAnimFrame=requestAnimationFrame(stepOrbitPhysics);
}
function ensureOrbitPhysicsLoop(){
if(_orbitAnimFrame) return;
_orbitAnimFrame=requestAnimationFrame(stepOrbitPhysics);
}
function queueOrbitMotion(fromIdx,toIdx,{direction=state?.direction||1}={}){
const fromAngle=getOrbitAngleForPlayer(fromIdx);
const toAngle=getOrbitAngleForPlayer(toIdx);
if(fromAngle===null||toAngle===null) return;
const base=_orbitAngle===null?fromAngle:_orbitAngle;
_orbitTargetIdx=toIdx;
_orbitTargetAngle=base+getDirectedOrbitDelta(base,toAngle,direction);
ensureOrbitPhysicsLoop();
}
function renderTurnOrbit(){
const pointer=document.getElementById('orbit-pointer');
if(!pointer||!state) return;
const targetAngle=getOrbitAngleForPlayer(state.currentPlayerIdx);
if(targetAngle===null){
pointer.style.opacity='0';
return;
}
if(_orbitAngle===null){
_orbitAngle=targetAngle;
_orbitTargetAngle=targetAngle;
_orbitTargetIdx=state.currentPlayerIdx;
setOrbitPointer(_orbitAngle);
return;
}
if(_orbitTargetIdx===state.currentPlayerIdx&&Math.abs(normalizeAngle(targetAngle-_orbitAngle))<0.01){
_orbitAngle=targetAngle;
_orbitTargetAngle=targetAngle;
setOrbitPointer(_orbitAngle);
return;
}
queueOrbitMotion(_orbitTargetIdx??state.currentPlayerIdx,state.currentPlayerIdx,{direction:state.direction});
}
function renderDirection(){
// Show the next player as "up next"
const ringArrow=document.getElementById('ring-arrow');
const ringText=document.getElementById('ring-text');
const badge=document.getElementById('color-badge');
const nextIdx=nextPlayerIdx();
const nextPlayer=state.players[nextIdx];
if(ringArrow){
ringArrow.className=`ring-arrow ${state.direction===-1?'reversed':'clockwise'}`;
}
if(badge){
badge.dataset.direction=String(state.direction);
}
if(ringText){
ringText.textContent=nextPlayer?`Next: ${nextPlayer.name}`:'';
}
ensureBadgeMarqueeLoop();
renderTurnOrbit();
}
function renderTurnBadge(){
const badge=document.getElementById('turn-badge');
if(isPlayerTurn()){
badge.textContent='Your turn — play a card';
badge.className='your-turn';
} else {
badge.textContent=`${state.players[state.currentPlayerIdx].name} is playing…`;
badge.className='';
}
}
function renderAll(){
renderAI();
renderPlayerHand();
renderDrawPile();
renderDiscard();
renderColorBadge();
renderDirection();
renderTurnBadge();
renderScores();
schedulePlayerAutoDraw();
}
function schedulePlayerAutoDraw(){
clearTimeout(_playerAutoDrawTimer);
_playerAutoDrawTimer=null;
if(!state||state.gamePhase==='ended'||!isPlayerTurn()||playerLocked||pendingWildCard) return;
const top=getTopCard();
const hasPlayable=getValidCards(state.players[0].hand,top,state.currentColor,state.pendingDraw).length>0;
if(hasPlayable) return;
_playerAutoDrawTimer=setTimeout(()=>{
_playerAutoDrawTimer=null;
if(state&&isPlayerTurn()&&!playerLocked&&!pendingWildCard){
const curTop=getTopCard();
if(!getValidCards(state.players[0].hand,curTop,state.currentColor,state.pendingDraw).length){
onPlayerDraw();
}
}
},420);
}
/* ============================================================
PLAYER ACTIONS
============================================================ */
function onPlayerClickCard(cardIdx){
if(!isPlayerTurn()||playerLocked) return;
clearTimeout(_playerAutoDrawTimer);
_playerAutoDrawTimer=null;
const card=state.players[0].hand[cardIdx];
if(!card) return;
const top=getTopCard();
const slot=document.querySelector(`#player-hand .card-slot[data-idx="${cardIdx}"]`);
if(!isValidPlay(card,top,state.currentColor,state.pendingDraw)){
const el=slot?.querySelector('.card');
if(el){
el.classList.remove('shake-anim');
void el.offsetWidth;
el.classList.add('shake-anim');
setTimeout(()=>el.classList.remove('shake-anim'),400);
}
return;
}
playerLocked=true;
doPlayerPlay(cardIdx);
}
function onPlayerCardActivate(cardIdx){
if(!isTouchPrimaryInput()){
onPlayerClickCard(cardIdx);
return;
}
const card=state?.players?.[0]?.hand?.[cardIdx];
const top=getTopCard();
if(!card) return;
const cardId=card.id||`${card.color}_${card.value}`;
if(!isValidPlay(card,top,state.currentColor,state.pendingDraw)){
_touchSelectedCardId=null;
onPlayerClickCard(cardIdx);
return;
}
if(_touchSelectedCardId===cardId){
_touchSelectedCardId=null;
onPlayerClickCard(cardIdx);
return;
}
_touchSelectedCardId=cardId;
renderPlayerHand();
}
function doPlayerPlay(cardIdx){
const handContainer=document.getElementById('player-hand');
_playerHandRested=false;
const card=state.players[0].hand[cardIdx];
const slot=document.querySelector(`#player-hand .card-slot[data-idx="${cardIdx}"]`);
if(!slot){ playerLocked=false; return; }
const cardRect=slot.getBoundingClientRect();
onCardUnhover(handContainer);
const isWildCard=card.value==='wild'||card.value==='wild_draw4';
if(isWildCard){
const didWin=executePlay(0,card);
renderPlayerHand();
const stageTarget=getPlayerStageAnchor();
animateCardFlight({
card,
from:cardRect,
to:stageTarget,
faceUp:true,
duration:440,
scaleTo:1,
rotateTo:-6,
opacityTo:1,
persist:true,
onDone:(flying)=>{
flying.classList.add('staged-card');
flying.dataset.cardValue=card.value;
_playerHandRested=true;
if(didWin){
renderDiscard();
setTimeout(()=>flying.remove(),180);
} else {
document.getElementById('card-stage').classList.add('show');
pendingWildCard=flying;
showColorModal(true);
}
}
});
return;
}
const didWin=executePlay(0,card);
renderPlayerHand();
animateCardFlight({
card,
from:cardRect,
to:getDiscardCenterAnchor(),
faceUp:true,
duration:430,
scaleTo:0.84,
rotateTo:(Math.random()*20-10),
opacityTo:0.82,
onApproach:()=>renderDiscard(),
onDone:()=>{
_playerHandRested=true;
if(!didWin) endPlayerTurnWithGather(card);
}
});
}
function onPlayerDraw(){
if(!isPlayerTurn()||playerLocked) return;
clearTimeout(_playerAutoDrawTimer);
_playerAutoDrawTimer=null;
playerLocked=true;
const handContainer=document.getElementById('player-hand');
const penaltyDraw=state.pendingDraw>0;
const count=penaltyDraw?state.pendingDraw:1;
const refreshedAtStart=ensureDeckBatchReady({render:true});
const drawnCards=takeDrawBatch(count);
if(!drawnCards.length){ playerLocked=false; return; }
if(refreshedAtStart) renderDrawPile();
const targetRect=getHandCenterAnchor(handContainer);
document.getElementById('draw-pile').classList.add('pile-shake-anim');
const previewCards=drawnCards;
let insertedCount=0;
let finished=false;
const commitDrawCard=(idx)=>{
if(idx!==insertedCount||idx>=drawnCards.length) return;
_playerHandBodies.forEach(body=>{
body.pushRelaxMs=Math.max(body.pushRelaxMs||0,220);
});
state.players[0].hand.push(drawnCards[idx]);
if(idx===drawnCards.length-1){
state.pendingDraw=0;
}
syncUnoFlags();
autoCallUnoForEligiblePlayers();
renderDrawPile();
_playerHandSpawnMode='draw';
renderPlayerHand();
insertedCount++;
};
const finishPlayerDraw=()=>{
if(finished) return;
finished=true;
while(insertedCount<drawnCards.length){
_playerHandDrawSpawnIds.add(drawnCards[insertedCount].id||`${drawnCards[insertedCount].color}_${drawnCards[insertedCount].value}`);
commitDrawCard(insertedCount);
}
updateUnoTimer();
showToast(drawnCards.length>1?`Drew ${drawnCards.length} cards`:'Drew 1 card','player-turn');
_playerHandRested=true;
_playerNeedsRestoreAnim=true;
setTimeout(()=>finishTurn(null),220);
};
animateDrawPileShake(()=>{
animateDrawToPlayerSequential(targetRect,previewCards,{
revealCards:true,
onEachApproach:(idx)=>{
_playerHandDrawSpawnIds.add(drawnCards[idx].id||`${drawnCards[idx].color}_${drawnCards[idx].value}`);
commitDrawCard(idx);
},
onDone:finishPlayerDraw
});
});
}
document.getElementById('draw-pile').addEventListener('click',()=>{
if(isPlayerTurn()&&!playerLocked) onPlayerDraw();
});
/* ============================================================
CARD EXECUTION
============================================================ */
function executePlay(playerIdx,card){
const player=state.players[playerIdx];
if(!removeCardOnce(player.hand,card)) return false;
state.discardPile.push(card);
if(card.color!=='wild'){
state.currentColor=card.color;
_colorWashSourceIdx=playerIdx;
}
switch(card.value){
case 'reverse':
if(state.players.length>2) state.direction*=-1;
break;
case 'draw2': state.pendingDraw+=2; break;
case 'wild_draw4': state.pendingDraw+=4; state.currentColor='wild'; break;
}
syncUnoFlags();
autoCallUnoForEligiblePlayers();
if(player.hand.length===0){
onPlayerFinish(playerIdx);
return state.gamePhase==='ended';
}
return false;
// Don't call renderAll here — let the caller handle animation timing
}
function drawCard(playerIdx){
const card=takeDrawBatch(1)[0];
if(!card) return;
state.players[playerIdx].hand.push(card);
syncUnoFlags();
autoCallUnoForEligiblePlayers();
renderAll();
}
function reshuffleDeck(){
ensureDeckBatchReady();
}
function advanceTurn(){
state.currentPlayerIdx=nextPlayerIdx();
}
function finishTurn(card){
if(state.gamePhase==='ended'){
playerLocked=false;
pendingWildCard=null;
updateUnoTimer();
return;
}
playerLocked=false;
pendingWildCard=null;
const fromIdx=state.currentPlayerIdx;
switch(card?.value){
case 'skip':
advanceTurn();
advanceTurn();
break;
case 'reverse':
advanceTurn();
if(state.players.length===2) advanceTurn();
break;
default:
advanceTurn();
}
queueOrbitMotion(fromIdx,state.currentPlayerIdx,{
direction:state.direction
});
renderAll();
renderScores();
updateUnoTimer();
if(!isPlayerTurn()){
triggerAITurn();
}
}
/* ============================================================
AI TURN
============================================================ */
function triggerAITurn(){
if(state.gamePhase==='ended') return;
const delay=AI_DELAYS[state.difficulty];
setTimeout(()=>executeAITurn(),delay);
}
function executeAITurn(){
const idx=state.currentPlayerIdx;
const player=state.players[idx];
const top=getTopCard();
const move=getAIMove(player,top,state.currentColor,state.pendingDraw,state.difficulty);
if(move.action==='draw'){
const count=state.pendingDraw>0?state.pendingDraw:1;
ensureDeckBatchReady({render:true});
const drawnCards=takeDrawBatch(count);
if(!drawnCards.length){
finishTurn(null);
return;
}
let inserted=false;
let entryDone=false;
let flightDone=false;
const completeAIDraw=()=>{
if(!entryDone||!flightDone) return;
showThought(drawnCards.length>1?`${player.name} drew ${drawnCards.length}`:`${player.name} draws`);
finishTurn(null);
};
const finishAIDraw=()=>{
if(inserted) return;
inserted=true;
const previousCount=player.hand.length;
player.hand.push(...drawnCards);
state.pendingDraw=0;
syncUnoFlags();
autoCallUnoForEligiblePlayers();
renderAI();
animateHandInsertion(document.getElementById(`ai-hand-${idx}`),'.ai-card-slot',previousCount,drawnCards.length,{
scale:1,
offsetX:0,
cardScale:getAIHandScale(),
spin:300,
pushPerCard:18,
entryMode:'center',
stagger:50,
onDone:()=>{
entryDone=true;
completeAIDraw();
}
});
};
animateDrawPileShake(()=>{
animateDrawToAI(idx,drawnCards.length,{
onApproach:finishAIDraw,
onDone:()=>{
finishAIDraw();
flightDone=true;
completeAIDraw();
}
});
});
return;
}
const card=move.card;
const handRow=document.getElementById(`ai-hand-${idx}`);
const opp=document.getElementById(`ai-opp-${idx}`);
const removedIdx=player.hand.findIndex(c=>cardEq(c,card));
const collapseIdx=removedIdx>=0?removedIdx:undefined;
const previousRects=handRow?captureSlotRects(handRow,'.ai-card-slot'):[];
const sourceRect=(handRow?.querySelector(`.ai-card-slot[data-idx="${removedIdx}"]`)||handRow||opp)?.getBoundingClientRect();
const didWin=executePlay(idx,card);
// Pick wild color immediately so the discard preview uses the final state.
if(card.value==='wild'||card.value==='wild_draw4'){
state.currentColor=pickAIColor(player.hand);
}
renderAI();
animateHandCollapse(document.getElementById(`ai-hand-${idx}`),'.ai-card-slot',previousRects,collapseIdx,{
duration:280
});
// Animate AI card flying from opponent area to discard
animateAIPlay(sourceRect,card,()=>{
if(card.value==='wild'||card.value==='wild_draw4'){
state.currentColor=pickAIColor(player.hand);
_colorWashSourceIdx=idx;
_skipWashNext=true; // renderColorBadge will skip its internal wash
triggerColorWash(state.currentColor,idx); // wash centered on AI player
renderColorBadge();
setOrbitPointer(_orbitAngle);
}
if(player.hand.length!==1){
const msgs={skip:'Skip!',reverse:'Reverse!',draw2:'+2!',wild:`Wild → ${(COLOR_NAMES[state.currentColor]||'').slice(0,3)}!`,wild_draw4:`+4 → ${(COLOR_NAMES[state.currentColor]||'').slice(0,3)}!`};
const aMsg=isAction(card.value)?msgs[card.value]:card.value;
if(aMsg) showThought(`${player.name}: ${aMsg}`);
}
if(didWin) return;
finishTurn(card);
});
}
function animateAIPlay(sourceRect,card,onDone){
if(!sourceRect){
onDone(); return;
}
animateCardFlight({
card,
from:sourceRect,
to:getDiscardCenterAnchor(),
faceUp:true,
duration:440,
scaleTo:0.84,
rotateTo:15,
opacityTo:0.82,
onApproach:()=>renderDiscard(),
onDone
});
}
function animateDrawToAI(aiIdx,count,{onApproach,onDone}={}){
const opp=document.getElementById(`ai-hand-${aiIdx}`)||document.getElementById(`ai-opp-${aiIdx}`);
if(!opp){
onDone(); return;
}
animateDrawFromPileToTarget(getHandCenterAnchor(opp),count,{
revealCards:false,
onApproach,
onDone
});
}
function animateDrawPileShake(onDone){
const pile=document.getElementById('draw-pile');
pile.classList.add('pile-shake-anim');
setTimeout(()=>{ pile.classList.remove('pile-shake-anim'); onDone(); },450);
}
/* ============================================================
UNO TIMER
============================================================ */
function updateUnoTimer(){
clearTimeout(unoTimer);
return;
}
/* ============================================================
COLOR SELECTOR (inline, next to staged card)
============================================================ */
function showColorModal(isPlayer){
if(!isPlayer) return; // AI: no selector needed
// Player wild card: show inline selector next to staged card
const sel=document.getElementById('color-selector');
const stage=document.getElementById('card-stage');
const staged=pendingWildCard;
// Position selector to the right of the staged card
if(staged){
const sRect=staged.getBoundingClientRect();
sel.style.left=(sRect.right+8)+'px';
sel.style.top=(sRect.top-10)+'px';
sel.style.bottom='auto';
sel.style.transform='none';
} else {
const dz=document.getElementById('discard-zone');
if(dz){
const r=dz.getBoundingClientRect();
sel.style.left=(r.left+r.width/2-100)+'px';
sel.style.top=(r.top+r.height/2-25)+'px';
sel.style.bottom='auto';
}
}
sel.classList.add('show');
const opts=sel.querySelectorAll('.sel-opt');
opts.forEach(opt=>{
opt.onclick=()=>{
const color=opt.dataset.color;
sel.classList.remove('show');
state.currentColor=color;
_colorWashSourceIdx=0;
_skipWashNext=true;
renderColorBadge();
triggerColorWash(color,0);
setOrbitPointer(_orbitAngle);
if(staged){
const target=getDiscardCenterAnchor();
staged.style.transition='left 0.4s cubic-bezier(0.2,0.8,0.3,1), top 0.4s cubic-bezier(0.2,0.8,0.3,1), transform 0.4s cubic-bezier(0.2,0.8,0.3,1), opacity 0.2s ease';
requestAnimationFrame(()=>{
staged.style.left=`${target.left}px`;
staged.style.top=`${target.top}px`;
staged.style.transform='rotate(8deg) scale(0.84)';
staged.style.opacity='0.85';
});
setTimeout(()=>renderDiscard(),260);
setTimeout(()=>{
staged.remove();
stage.classList.remove('show');
pendingWildCard=null;
endPlayerTurnWithGather({value:staged.dataset.cardValue||'wild'});
},420);
} else {
pendingWildCard=null;
endPlayerTurnWithGather({value:'wild'});
}
};
});
}
/* ============================================================
TOAST / THOUGHT
============================================================ */
function showToast(msg,cls){
const t=document.getElementById('turn-toast');
t.textContent=msg;
t.className='show '+cls;
clearTimeout(t._tid);
t._tid=setTimeout(()=>t.classList.remove('show'),1800);
}
function showThought(msg){
const label=document.getElementById(`ai-label-${state.currentPlayerIdx}`);
if(!label) return;
const existing=label.querySelector('.thought-bubble');
if(existing) existing.remove();
const b=document.createElement('div');
b.className='thought-bubble';
b.textContent=msg;
label.appendChild(b);
setTimeout(()=>b.remove(),1400);
}
/* ============================================================
PARTICLES
============================================================ */
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);
}
}
/* ============================================================
WIN
============================================================ */
function onWin(playerIdx){
state.gamePhase='ended';
showWinScreen(playerIdx);
}
function showWinScreen(winnerIdx){
const winner=state.players[winnerIdx]||state.players[state.finishOrder[0]];
document.getElementById('winner-name').textContent=`${winner.name} Takes 1st!`;
const scoresEl=document.getElementById('win-scores');
scoresEl.innerHTML='';
const ordered=state.finishOrder.length
? state.finishOrder.map(idx=>state.players[idx])
: state.players;
ordered.forEach((p,i)=>{
const d=document.createElement('div');
const rank=i+1;
const meta=getPlacementMeta(rank);
d.className='win-score-item'+(rank===1?' winner-highlight':'');
d.textContent=`${meta.label}${p.name}`;
scoresEl.appendChild(d);
});
document.getElementById('win-screen').classList.add('show');
spawnConfetti();
}
function spawnConfetti(){
const c=document.getElementById('confetti-container');
c.innerHTML='';
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);
}
}
/* ============================================================
UI EVENTS
============================================================ */
document.querySelectorAll('.difficulty-btns button').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.difficulty-btns button').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
});
});
document.querySelectorAll('.player-btns button').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.player-btns button').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
});
});
document.getElementById('start-btn').addEventListener('click',()=>{
const diff=[...document.querySelectorAll('.difficulty-btns button')].find(b=>b.classList.contains('active'))?.dataset.diff||'hard';
const count=parseInt([...document.querySelectorAll('.player-btns button')].find(b=>b.classList.contains('active'))?.dataset.count||3);
const ss=document.getElementById('start-screen');
ss.style.transition='opacity 0.5s';
ss.style.opacity='0';
setTimeout(()=>{
ss.style.display='none';
document.getElementById('game-board').classList.add('active');
initGame(diff,count);
},300);
});
document.getElementById('play-again-btn').addEventListener('click',()=>{
document.getElementById('win-screen').classList.remove('show');
clearTimeout(unoTimer);
clearTimeout(_playerAutoDrawTimer);
_playerAutoDrawTimer=null;
playerLocked=false;pendingWildCard=null;
const diff=[...document.querySelectorAll('.difficulty-btns button')].find(b=>b.classList.contains('active'))?.dataset.diff||'hard';
const count=parseInt([...document.querySelectorAll('.player-btns button')].find(b=>b.classList.contains('active'))?.dataset.count||3);
initGame(diff,count);
});
document.getElementById('uno-call-btn').addEventListener('click',()=>{
if(state&&state.players[0].hand.length===1&&!state.players[0].saidUno){
state.players[0].saidUno=true;
document.getElementById('uno-call-btn').classList.remove('visible');
showToast("UNO!",'player-turn');
spawnParticleBurst(window.innerWidth/2,window.innerHeight-160,'#f39c12');
clearTimeout(unoTimer);
}
});
document.addEventListener('keydown',e=>{
if(state&&e.key.toLowerCase()==='u'&&state.players[0].hand.length===1&&!state.players[0].saidUno){
state.players[0].saidUno=true;
document.getElementById('uno-call-btn').classList.remove('visible');
showToast("UNO!",'player-turn');
spawnParticleBurst(window.innerWidth/2,window.innerHeight-160,'#f39c12');
clearTimeout(unoTimer);
}
if(state&&e.key===' '&&isPlayerTurn()&&!playerLocked){
e.preventDefault(); onPlayerDraw();
}
});
document.addEventListener('pointerdown',e=>{
if(!isTouchPrimaryInput()||!_touchSelectedCardId) return;
const target=e.target;
if(target instanceof Element && (target.closest('#player-hand')||target.closest('#color-selector'))){
return;
}
_touchSelectedCardId=null;
if(state) renderPlayerHand();
});
(function adjustFont(){
const vw=window.innerWidth;
document.documentElement.style.fontSize=vw<480?'13px':vw<768?'15px':'16px';
})();
window.addEventListener('resize',()=>{
const vw=window.innerWidth;
document.documentElement.style.fontSize=vw<480?'13px':vw<768?'15px':'16px';
});
</script>
</body>
</html>