Download uno.html from cacodex/uno: direct link, hf CLI and curl.
- Browser
- Download file 118 kB
-
https://huggingface.co/spaces/cacodex/uno/resolve/main/uno.html
- Command line
-
hf download hf://spaces/cacodex/uno/uno.html
-
curl -L -o uno.html https://huggingface.co/spaces/cacodex/uno/resolve/main/uno.html
118 kB
| <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> | |