| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>UNO</title> |
| <style> |
| /* ============================================================ |
| CSS VARIABLES & THEME |
| ============================================================ */ |
| :root { |
| --bg-deep: #080818; |
| --bg-surface: #10102a; |
| --bg-glass: rgba(255,255,255,0.04); |
| --color-red: #e63946; |
| --color-blue: #4a9cd4; |
| --color-green: #2ecc71; |
| --color-yellow: #f39c12; |
| --card-face: #f8f6ee; |
| --text-primary: #f0f0f8; |
| --text-muted: #6666aa; |
| --card-w: 76px; |
| --card-h: 114px; |
| --card-r: 10px; |
| --ai-hand-zone: 220px; |
| } |
|
|
| /* ============================================================ |
| RESET & BASE |
| ============================================================ */ |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} |
| html,body{height:100%;overflow:hidden} |
| body{ |
| font-family:'Segoe UI',system-ui,-apple-system,sans-serif; |
| background:var(--bg-deep); |
| color:var(--text-primary); |
| } |
|
|
| /* ============================================================ |
| BACKGROUND ATMOSPHERE |
| ============================================================ */ |
| .bg-aurora{ |
| position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none; |
| } |
| .bg-aurora::before{ |
| content:'';position:absolute; |
| width:700px;height:700px;border-radius:50%; |
| background:radial-gradient(circle,rgba(74,156,212,0.1) 0%,transparent 70%); |
| top:-200px;left:-200px;animation:aurora1 14s ease-in-out infinite; |
| } |
| .bg-aurora::after{ |
| content:'';position:absolute; |
| width:600px;height:600px;border-radius:50%; |
| background:radial-gradient(circle,rgba(230,57,70,0.08) 0%,transparent 70%); |
| bottom:-150px;right:-100px;animation:aurora2 18s ease-in-out infinite; |
| } |
| @keyframes aurora1{ |
| 0%,100%{transform:translate(0,0) scale(1)} |
| 50%{transform:translate(100px,80px) scale(1.2)} |
| } |
| @keyframes aurora2{ |
| 0%,100%{transform:translate(0,0) scale(1)} |
| 50%{transform:translate(-80px,-60px) scale(1.15)} |
| } |
|
|
| /* ============================================================ |
| START SCREEN |
| ============================================================ */ |
| #start-screen{ |
| position:fixed;inset:0;z-index:100; |
| display:flex;flex-direction:column;align-items:center;justify-content:center;gap:32px; |
| background:var(--bg-deep); |
| } |
| .uno-logo{ |
| font-size:88px;font-weight:900;letter-spacing:-2px;line-height:1; |
| background:linear-gradient(135deg,#e63946,#f39c12,#2ecc71,#4a9cd4); |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent; |
| background-clip:text; |
| filter:drop-shadow(0 4px 24px rgba(230,57,70,0.5)); |
| animation:logoFloat 3s ease-in-out infinite; |
| } |
| @keyframes logoFloat{ |
| 0%,100%{transform:translateY(0)} |
| 50%{transform:translateY(-10px)} |
| } |
| .uno-tagline{color:var(--text-muted);font-size:14px;letter-spacing:5px;text-transform:uppercase} |
| .setup-panel{ |
| background:rgba(255,255,255,0.04); |
| border:1px solid rgba(255,255,255,0.08); |
| border-radius:24px;padding:32px 40px; |
| backdrop-filter:blur(20px); |
| display:flex;flex-direction:column;gap:18px;min-width:340px; |
| } |
| .setup-panel label{font-size:11px;letter-spacing:3px;text-transform:uppercase;color:var(--text-muted);text-align:left} |
| .btn-group{display:flex;gap:8px} |
| .btn-group button{ |
| flex:1;padding:9px 0;border:none;border-radius:10px;cursor:pointer; |
| font-size:13px;font-weight:600;transition:all 0.2s; |
| background:rgba(255,255,255,0.06);color:var(--text-primary); |
| } |
| .btn-group button:hover{background:rgba(255,255,255,0.12)} |
| .btn-group button.active{ |
| background:linear-gradient(135deg,#4a9cd4,#2ecc71); |
| color:#fff;box-shadow:0 4px 16px rgba(74,156,212,0.3); |
| } |
| .glow-btn{ |
| padding:14px 36px;border:none;border-radius:14px;cursor:pointer; |
| font-size:16px;font-weight:800;letter-spacing:2px; |
| background:linear-gradient(135deg,#e63946,#f39c12); |
| color:#fff;transition:all 0.3s; |
| box-shadow:0 4px 24px rgba(230,57,70,0.4); |
| } |
| .glow-btn:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(230,57,70,0.6)} |
| .glow-btn:active{transform:translateY(0)} |
|
|
| /* ============================================================ |
| GAME BOARD — FULL VIEWPORT |
| ============================================================ */ |
| #game-board{ |
| display:none;position:fixed;inset:0;z-index:10; |
| flex-direction:column; |
| } |
| #game-board.active{display:flex} |
|
|
| /* ============================================================ |
| TOP ROW — ALL AI OPPONENTS (horizontal) |
| ============================================================ */ |
| #top-row{ |
| display:flex;flex-direction:column;align-items:center; |
| padding:12px 16px 6px; |
| background:linear-gradient(to bottom,rgba(0,0,0,0.3),transparent); |
| min-height:130px; |
| position:relative; |
| } |
|
|
| /* Rotation order ring */ |
| #rotation-ring{ |
| position:absolute;top:8px;right:20px; |
| display:flex;align-items:center;gap:8px; |
| font-size:11px;color:var(--text-muted);letter-spacing:1px; |
| } |
| #rotation-ring .ring-arrow{ |
| font-size:18px;transition:transform 0.4s; |
| } |
| #rotation-ring .ring-arrow.reversed{transform:scaleX(-1) rotate(-45deg)} |
| #rotation-ring .ring-arrow.clockwise{transform:rotate(45deg)} |
|
|
| #ai-row{ |
| display:flex;gap:14px;align-items:flex-end;justify-content:center; |
| flex-wrap:wrap; |
| } |
| .ai-opponent{ |
| display:flex;flex-direction:column;align-items:center;gap:5px; |
| min-width:120px;padding:6px 10px;border-radius:12px; |
| transition:all 0.3s; |
| } |
| .ai-label-row{ |
| display:flex;align-items:center;gap:6px; |
| font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase; |
| color:var(--text-muted); |
| } |
| .ai-name{white-space:nowrap} |
| .ai-label-row.active{color:#2ecc71} |
| .ai-label-row .active-dot{ |
| width:7px;height:7px;border-radius:50%;background:#e63946; |
| animation:pulse 1s ease-in-out infinite;display:none; |
| } |
| .ai-label-row.active .active-dot{display:block} |
| @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}} |
| .ai-card-count{ |
| font-size:10px;color:var(--text-muted);letter-spacing:1px; |
| background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:8px; |
| } |
| .placement-badge{ |
| position:absolute; |
| left:50%;top:50%; |
| transform:translate(-50%,-50%); |
| padding:8px 14px; |
| border-radius:999px; |
| font-size:12px;font-weight:900;letter-spacing:1.5px; |
| text-transform:uppercase; |
| border:1px solid rgba(255,255,255,0.22); |
| backdrop-filter:blur(10px); |
| box-shadow:0 10px 28px rgba(0,0,0,0.32); |
| z-index:4; |
| white-space:nowrap; |
| } |
| .placement-badge.gold{ |
| color:#fff8d5; |
| background:linear-gradient(135deg,rgba(243,156,18,0.86),rgba(255,214,102,0.72)); |
| } |
| .placement-badge.silver{ |
| color:#f5f8ff; |
| background:linear-gradient(135deg,rgba(148,163,184,0.9),rgba(226,232,240,0.72)); |
| } |
| .placement-badge.bronze{ |
| color:#fff1e8; |
| background:linear-gradient(135deg,rgba(180,83,9,0.9),rgba(251,146,60,0.72)); |
| } |
| .placement-badge.place{ |
| color:#fff; |
| background:rgba(15,23,42,0.82); |
| } |
| .player-placement{ |
| top:32%; |
| } |
| .uno-shout{ |
| animation:unoShout 0.75s cubic-bezier(0.22,0.9,0.3,1); |
| } |
| @keyframes unoShout{ |
| 0%{transform:scale(0.88);filter:brightness(0.9)} |
| 35%{transform:scale(1.16);filter:brightness(1.22)} |
| 100%{transform:scale(1);filter:brightness(1)} |
| } |
| .ai-hand-row{ |
| display:flex;align-items:flex-end;justify-content:center; |
| height:74px;position:relative; |
| width:var(--ai-hand-zone); |
| padding:0 10px 2px; |
| overflow:visible; |
| z-index:20; |
| } |
| .ai-hand-row .ai-card-slot{ |
| display:flex;align-items:flex-end;justify-content:center; |
| flex-shrink:0; |
| transition:transform 0.24s cubic-bezier(0.34,1.2,0.64,1), opacity 0.22s ease; |
| position:relative; |
| z-index:1; |
| } |
| .ai-hand-row .ai-card-slot:first-child{margin-left:0} |
| .ai-hand-row .ai-card-slot::before{ |
| content:''; |
| position:absolute; |
| left:50%;bottom:0; |
| width:var(--card-w);height:var(--card-h); |
| border-radius:var(--card-r); |
| border:2px solid rgba(255,255,255,0.15); |
| background: |
| repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(255,255,255,0.03) 8px,rgba(255,255,255,0.03) 16px), |
| radial-gradient(circle at 50% 50%,rgba(255,255,255,0.1) 0%,transparent 60%), |
| linear-gradient(135deg,#1a3a6a,#2a5a9a); |
| transform:translateX(-50%) scale(var(--ai-card-scale,0.5)); |
| transform-origin:bottom center; |
| box-shadow:0 6px 18px rgba(0,0,0,0.22); |
| z-index:0; |
| } |
| .ai-hand-row .card{ |
| pointer-events:none; |
| transform:scale(0.5); |
| transform-origin:bottom center; |
| filter:brightness(1) saturate(1); |
| opacity:1; |
| position:relative; |
| z-index:1; |
| } |
|
|
| #turn-orbit{ |
| position:fixed;inset:0;z-index:35;pointer-events:none; |
| } |
| #turn-orbit .orbit-track{ |
| position:absolute; |
| left:50%;top:49%; |
| width:min(84vw,1000px); |
| height:min(60vh,500px); |
| transform:translate(-50%,-50%); |
| border:1px dashed rgba(255,255,255,0.1); |
| border-radius:50%; |
| opacity:0.5; |
| } |
| #turn-orbit .orbit-pointer{ |
| position:absolute; |
| width:0;height:0; |
| border-left:10px solid transparent; |
| border-right:10px solid transparent; |
| border-bottom:22px solid #2ecc71; |
| filter:drop-shadow(0 0 10px rgba(46,204,113,0.55)); |
| transform-origin:50% 70%; |
| transition:opacity 0.25s ease, border-bottom-color 0.2s ease, filter 0.2s ease; |
| } |
|
|
| /* ============================================================ |
| CENTER AREA |
| ============================================================ */ |
| #center-area{ |
| flex:1;display:flex;flex-direction:column; |
| align-items:center;justify-content:center;gap:12px; |
| position:relative; |
| } |
| #direction-arrow{ |
| font-size:22px;color:var(--text-muted);opacity:0.5; |
| transition:transform 0.4s cubic-bezier(0.34,1.2,0.64,1); |
| user-select:none; |
| } |
| #direction-arrow.reversed{transform:scaleX(-1) rotate(-45deg)} |
| #direction-arrow.clockwise{transform:rotate(45deg)} |
|
|
| .pile-zone{ |
| display:flex;gap:28px;align-items:center;justify-content:center; |
| } |
|
|
| #draw-pile{ |
| cursor:pointer;position:relative; |
| transition:transform 0.2s; |
| } |
| #draw-pile:hover{transform:scale(1.06)} |
| .pending-badge{ |
| position:absolute;top:-10px;right:-10px; |
| background:#e63946;color:#fff; |
| font-size:12px;font-weight:900;line-height:1; |
| padding:4px 7px;border-radius:10px; |
| box-shadow:0 2px 8px rgba(230,57,70,0.6); |
| z-index:10;pointer-events:none; |
| animation:pendingPulse 0.8s ease-in-out infinite alternate; |
| min-width:24px;text-align:center; |
| } |
| @keyframes pendingPulse{ |
| from{transform:scale(1)} |
| to{transform:scale(1.15)} |
| } |
| .draw-stack{ |
| width:var(--card-w);height:var(--card-h); |
| position:relative; |
| } |
| .draw-stack .card-back-mini{ |
| position:absolute;top:0;left:0; |
| width:var(--card-w);height:var(--card-h); |
| border:2px solid rgba(255,255,255,0.12); |
| border-radius:var(--card-r); |
| background: |
| repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(255,255,255,0.03) 8px,rgba(255,255,255,0.03) 16px), |
| radial-gradient(circle at 50% 50%,rgba(255,255,255,0.1) 0%,transparent 60%), |
| linear-gradient(135deg,#1a3a6a,#2a5a9a); |
| box-shadow:0 6px 18px rgba(0,0,0,0.22); |
| transform-origin:center center; |
| transition:left 0.26s cubic-bezier(0.22,0.9,0.3,1), top 0.26s cubic-bezier(0.22,0.9,0.3,1), opacity 0.24s ease, transform 0.24s cubic-bezier(0.22,0.9,0.3,1), filter 0.24s ease; |
| } |
| .draw-stack .card-back-mini.stack-enter{ |
| opacity:0; |
| transform:translateY(22px) scale(0.82); |
| } |
| .draw-stack .card-back-mini.stack-fly-out{ |
| animation:drawPileFlyOut 0.24s cubic-bezier(0.22,0.9,0.3,1) forwards; |
| } |
| @keyframes drawPileFlyOut{ |
| 0%{ |
| opacity:1; |
| transform:translate(0,0) scale(1) rotate(0deg); |
| filter:brightness(1); |
| } |
| 55%{ |
| opacity:0.88; |
| transform:translate(5px,-6px) scale(0.98) rotate(2deg); |
| filter:brightness(1.05); |
| } |
| 100%{ |
| opacity:0; |
| transform:translate(10px,-12px) scale(0.94) rotate(4deg); |
| filter:brightness(1.08); |
| } |
| } |
| .draw-stack::after{ |
| content:'抽牌';position:absolute;bottom:-18px;left:50%;transform:translateX(-50%); |
| font-size:10px;letter-spacing:2px;color:var(--text-muted);white-space:nowrap; |
| } |
|
|
| .draw-stack[data-label]::after{content:attr(data-label)} |
| #discard-zone{ |
| width:calc(var(--card-w) + 240px); |
| height:calc(var(--card-h) + 240px); |
| position:relative; |
| } |
| #discard-scatter{ |
| position:absolute;inset:0; |
| } |
|
|
| /* ============================================================ |
| CARD DESIGN |
| ============================================================ */ |
| .card{ |
| width:var(--card-w);height:var(--card-h); |
| perspective:1000px;cursor:pointer;flex-shrink:0; |
| transform-origin:bottom center;position:relative; |
| } |
| .card.disabled{pointer-events:none;opacity:0.4} |
|
|
| .card-inner{ |
| position:relative;width:100%;height:100%; |
| transform-style:preserve-3d; |
| transition:transform 0.45s cubic-bezier(0.4,0.2,0.2,1); |
| } |
| .card.flipped .card-inner{transform:rotateY(180deg)} |
|
|
| .card-front,.card-back{ |
| position:absolute;inset:0;border-radius:var(--card-r); |
| backface-visibility:hidden;overflow:hidden; |
| display:flex;align-items:center;justify-content:center; |
| } |
|
|
| .card-back{ |
| background:linear-gradient(135deg,#1a3a6a,#2a5a9a); |
| border:2px solid rgba(255,255,255,0.15); |
| transform:rotateY(180deg); |
| background-image: |
| repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(255,255,255,0.03) 8px,rgba(255,255,255,0.03) 16px), |
| radial-gradient(circle at 50% 50%,rgba(255,255,255,0.1) 0%,transparent 60%); |
| } |
| .card-back::after{ |
| content:'UNO';font-family:Arial Black,sans-serif; |
| font-size:16px;color:rgba(255,255,255,0.5);letter-spacing:2px; |
| } |
|
|
| .card-front{ |
| background:var(--card-face); |
| border:2px solid currentColor; |
| flex-direction:column; |
| box-shadow:0 6px 24px rgba(0,0,0,0.5); |
| } |
| .card.red{color:var(--color-red)} |
| .card.blue{color:var(--color-blue)} |
| .card.green{color:var(--color-green)} |
| .card.yellow{color:var(--color-yellow)} |
|
|
| .card.wild .card-front{ |
| background:linear-gradient(135deg,var(--color-red),var(--color-blue),var(--color-green),var(--color-yellow)); |
| border-color:rgba(255,255,255,0.4); |
| color:#fff; |
| } |
|
|
| .corner-pip{ |
| position:absolute;font-size:11px;font-weight:800;line-height:1; |
| color:currentColor; |
| } |
| .corner-pip.tl{top:4px;left:5px} |
| .corner-pip.br{bottom:4px;right:5px;transform:rotate(180deg)} |
|
|
| .card-center{ |
| display:flex;flex-direction:column;align-items:center;justify-content:center; |
| gap:1px;z-index:1;position:relative; |
| } |
| .card-center .big-symbol{ |
| font-size:36px;font-weight:900;line-height:1; |
| color:currentColor;text-shadow:0 2px 4px rgba(0,0,0,0.15); |
| } |
| .card.wild .card-front::before{ |
| content:'';position:absolute;inset:0; |
| background:rgba(0,0,0,0.12);border-radius:var(--card-r);z-index:0; |
| } |
| .card.wild .corner-pip,.card.wild .card-center{color:#fff;text-shadow:0 1px 4px rgba(0,0,0,0.4);z-index:2} |
|
|
| /* ============================================================ |
| CARD ANIMATIONS |
| ============================================================ */ |
| @keyframes dealIn{ |
| 0%{opacity:0;transform:translateY(-200px) scale(0.6) rotateZ(-10deg)} |
| 60%{opacity:1;transform:translateY(10px) scale(1.03) rotateZ(3deg)} |
| 100%{opacity:1;transform:translateY(0) scale(1) rotateZ(0)} |
| } |
| .card.deal-anim{animation:dealIn 0.35s cubic-bezier(0.34,1.56,0.64,1) forwards} |
|
|
| /* Card playing arc — goes from hand to discard */ |
| @keyframes cardPlayArc{ |
| 0%{transform:translate(0,0) scale(1) rotateZ(0)} |
| 20%{transform:translate(0,-40px) scale(1.05) rotateZ(-4deg)} |
| 60%{transform:translate(var(--arc-x,0),-180px) scale(0.88) rotateZ(var(--arc-r,0deg))} |
| 85%{transform:translate(var(--arc-x,0),-220px) scale(0.82)} |
| 100%{transform:translate(var(--arc-x,0),-220px) scale(0.72);opacity:0} |
| } |
| .card.card-play-anim{ |
| animation:cardPlayArc 0.42s cubic-bezier(0.2,0.7,0.3,1) forwards; |
| pointer-events:none; |
| } |
|
|
| /* Discard card lands on pile */ |
| @keyframes discardLand{ |
| 0%{ |
| transform:translate(calc(-50% + var(--discard-x,0px)), calc(-50% - 56px)) rotate(var(--discard-rot,0deg)) scale(0.74); |
| opacity:0.78; |
| } |
| 55%{ |
| transform:translate(calc(-50% + var(--discard-x,0px)), calc(-50% + 10px + var(--discard-y,0px))) rotate(var(--discard-rot,0deg)) scale(1.04); |
| } |
| 100%{ |
| transform:translate(calc(-50% + var(--discard-x,0px)), calc(-50% + var(--discard-y,0px))) rotate(var(--discard-rot,0deg)) scale(1); |
| opacity:1; |
| } |
| } |
| .card.discard-land-anim{animation:discardLand 0.38s cubic-bezier(0.34,1.2,0.64,1) forwards} |
|
|
| /* Card flies from draw pile to player hand */ |
| @keyframes cardFlyToHand{ |
| 0%{transform:translate(var(--fd-x,0),var(--fd-y,0)) scale(0.8);opacity:0} |
| 30%{transform:translate(calc(var(--fd-x,0)*0.3),calc(var(--fd-y,0)*0.3-20px)) scale(0.9);opacity:1} |
| 100%{transform:translate(0,0) scale(1);opacity:1} |
| } |
| .card.fly-in-anim{ |
| animation:cardFlyToHand 0.3s cubic-bezier(0.34,1.3,0.64,1) forwards; |
| } |
|
|
| @keyframes pileShake{ |
| 0%,100%{transform:translateX(0) rotate(0)} |
| 20%{transform:translateX(-6px) rotate(-3deg)} |
| 40%{transform:translateX(6px) rotate(3deg)} |
| 60%{transform:translateX(-4px) rotate(-2deg)} |
| 80%{transform:translateX(4px) rotate(2deg)} |
| } |
| .pile-shake-anim{animation:pileShake 0.45s ease-in-out} |
|
|
| @keyframes cardShake{ |
| 0%,100%{transform:translateX(0) rotate(0)} |
| 25%{transform:translateX(-5px) rotate(-3deg)} |
| 75%{transform:translateX(5px) rotate(3deg)} |
| } |
| .card.shake-anim{animation:cardShake 0.35s ease-in-out} |
|
|
| @keyframes cardSlideIn{ |
| 0%{transform:translateY(20px) scale(0.9);opacity:0} |
| 100%{transform:translateY(0) scale(1);opacity:1} |
| } |
| .card.slide-in-anim{animation:cardSlideIn 0.25s cubic-bezier(0.34,1.3,0.64,1) forwards} |
|
|
| /* ============================================================ |
| PLAYER HAND — COMPACT OVERLAPPING |
| ============================================================ */ |
| #player-area{ |
| display:flex;flex-direction:column;align-items:center; |
| padding:0 16px 12px;gap:8px; |
| min-height:180px; |
| position:relative; |
| } |
| #turn-badge{ |
| display:none; |
| font-size:11px;letter-spacing:3px;text-transform:uppercase; |
| color:var(--text-muted);font-weight:700; |
| } |
| #turn-badge.your-turn{color:#2ecc71} |
| #score-row{ |
| display:none;gap:20px;font-size:12px;color:var(--text-muted); |
| } |
| #score-row b{color:var(--text-primary)} |
|
|
| /* Player hand — slot-based: slot handles fan (stable), JS handles ripple on hover */ |
| #player-hand{ |
| display:flex;align-items:flex-end;justify-content:center; |
| position:relative;min-height:130px;padding:0 60px; |
| width:min(100%,960px); |
| z-index:20; |
| } |
|
|
| /* Each card in a slot — slot handles fan position */ |
| #player-hand .card-slot{ |
| display:flex;align-items:flex-end;justify-content:center; |
| flex-shrink:0; |
| margin-left:-34px; |
| will-change:transform; |
| } |
| #player-hand .card-slot:first-child{margin-left:0} |
|
|
| /* Playable card highlight */ |
| #player-hand .card-slot .card.playable-card{ |
| box-shadow:0 0 0 2.5px rgba(255,255,255,0.55),0 6px 20px rgba(255,255,255,0.18); |
| } |
|
|
| /* Playable card highlight */ |
| #player-hand .card.playable-card{ |
| box-shadow:0 0 0 2.5px rgba(255,255,255,0.55),0 6px 20px rgba(255,255,255,0.18); |
| } |
| #player-hand .card.touch-selected{ |
| box-shadow:0 0 0 3px rgba(255,255,255,0.68),0 10px 28px rgba(255,255,255,0.22); |
| } |
|
|
| #player-hand .card{ |
| will-change:filter; |
| } |
|
|
| #uno-call-btn{ |
| display:none!important;padding:9px 28px;border:none;border-radius:12px;cursor:pointer; |
| font-size:13px;font-weight:900;letter-spacing:3px; |
| background:linear-gradient(135deg,#e63946,#f39c12); |
| color:#fff;box-shadow:0 4px 20px rgba(230,57,70,0.5); |
| animation:unoGlow 0.7s ease-in-out infinite alternate; |
| } |
| #uno-call-btn.visible{display:block} |
| @keyframes unoGlow{ |
| from{box-shadow:0 4px 16px rgba(230,57,70,0.5)} |
| to{box-shadow:0 4px 32px rgba(230,57,70,0.9),0 0 60px rgba(230,57,70,0.3)} |
| } |
|
|
| /* ============================================================ |
| COLOR BADGE |
| ============================================================ */ |
| /* Direction arrow removed — replaced by rotation ring */ |
|
|
| /* ============================================================ |
| COLOR BADGE |
| ============================================================ */ |
| #color-badge{ |
| display:flex;align-items:center;gap:8px; |
| padding:5px 16px;border-radius:20px; |
| background:rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1); |
| backdrop-filter:blur(10px);font-size:12px;font-weight:700; |
| letter-spacing:2px;text-transform:uppercase; |
| position:relative;overflow:hidden; |
| } |
| #color-badge::before{ |
| content:''; |
| position:absolute; |
| inset:0; |
| border-radius:inherit; |
| padding:1px; |
| background: |
| conic-gradient( |
| from var(--marquee-angle,0deg), |
| white 0deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 10deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 22deg, |
| transparent 34deg, |
| transparent 146deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 158deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 170deg, |
| white 180deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 190deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 202deg, |
| transparent 214deg, |
| transparent 326deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 48%, rgba(255,255,255,0.12) 52%) 338deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 76%, white 24%) 350deg, |
| white 360deg |
| ); |
| -webkit-mask: |
| linear-gradient(#000 0 0) content-box, |
| linear-gradient(#000 0 0); |
| -webkit-mask-composite:xor; |
| mask: |
| linear-gradient(#000 0 0) content-box, |
| linear-gradient(#000 0 0); |
| mask-composite:exclude; |
| opacity:0.92; |
| pointer-events:none; |
| } |
| #color-badge::after{ |
| content:''; |
| position:absolute; |
| inset:-3px; |
| border-radius:inherit; |
| padding:4px; |
| background: |
| conic-gradient( |
| from var(--marquee-angle,0deg), |
| color-mix(in srgb, var(--marquee-color, #fff) 86%, white 14%) 0deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 10deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 24deg, |
| transparent 38deg, |
| transparent 142deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 156deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 170deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 86%, white 14%) 180deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 190deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 204deg, |
| transparent 218deg, |
| transparent 322deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 26%, rgba(255,255,255,0.04) 74%) 336deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 54%, rgba(255,255,255,0.18) 46%) 350deg, |
| color-mix(in srgb, var(--marquee-color, #fff) 86%, white 14%) 360deg |
| ); |
| -webkit-mask: |
| linear-gradient(#000 0 0) content-box, |
| linear-gradient(#000 0 0); |
| -webkit-mask-composite:xor; |
| mask: |
| linear-gradient(#000 0 0) content-box, |
| linear-gradient(#000 0 0); |
| mask-composite:exclude; |
| opacity:0.55; |
| filter:blur(7px); |
| pointer-events:none; |
| } |
| #color-badge > *{ |
| position:relative; |
| z-index:1; |
| } |
| #color-badge .color-dot{ |
| width:12px;height:12px;border-radius:50%; |
| box-shadow:0 0 8px currentColor; |
| } |
| /* Ripple animation on color change */ |
| #color-badge.color-ripple::after{ |
| content:'';position:absolute;inset:0; |
| background:radial-gradient(circle,rgba(255,255,255,0.7) 0%,transparent 70%); |
| animation:colorRipple 0.6s ease-out forwards; |
| } |
| @keyframes colorRipple{ |
| 0%{transform:scale(0);opacity:1} |
| 100%{transform:scale(3);opacity:0} |
| } |
| @keyframes badgeMarquee{ |
| from{transform:rotate(0deg)} |
| to{transform:rotate(360deg)} |
| } |
|
|
| /* Inline color selector for wild cards — appears next to staged card */ |
| #color-selector{ |
| position:fixed;bottom:200px; |
| display:none;flex-direction:row;align-items:center;gap:8px; |
| z-index:130; |
| background:rgba(10,10,30,0.85); |
| border:1px solid rgba(255,255,255,0.12); |
| border-radius:16px; |
| padding:10px 14px; |
| backdrop-filter:blur(12px); |
| box-shadow:0 8px 32px rgba(0,0,0,0.5); |
| animation:selectorPop 0.3s cubic-bezier(0.34,1.4,0.64,1) forwards; |
| } |
| #color-selector.show{display:flex} |
| @keyframes selectorPop{ |
| 0%{opacity:0;transform:translateX(-10px) scale(0.9)} |
| 100%{opacity:1;transform:translateX(0) scale(1)} |
| } |
| #color-selector .sel-label{ |
| font-size:10px;letter-spacing:2px;color:var(--text-muted); |
| text-transform:uppercase;margin-right:4px;white-space:nowrap; |
| } |
| #color-selector .sel-opt{ |
| width:38px;height:38px;border-radius:50%;cursor:pointer; |
| transition:transform 0.2s cubic-bezier(0.34,1.4,0.64,1),box-shadow 0.2s; |
| border:2px solid transparent; |
| flex-shrink:0; |
| } |
| #color-selector .sel-opt:hover{ |
| transform:scale(1.25); |
| box-shadow:0 0 16px currentColor; |
| border-color:rgba(255,255,255,0.4); |
| } |
| #color-selector .sel-opt.red {background:var(--color-red)} |
| #color-selector .sel-opt.blue {background:var(--color-blue)} |
| #color-selector .sel-opt.green{background:var(--color-green)} |
| #color-selector .sel-opt.yellow{background:var(--color-yellow)} |
|
|
| /* Card staging area for pending confirmation (wild cards) */ |
| #card-stage{ |
| position:fixed;bottom:200px;left:50%;transform:translateX(-50%); |
| width:88px;height:132px; |
| z-index:120;pointer-events:none; |
| display:none; |
| } |
| #card-stage.show{display:block} |
| #card-stage .staged-card{ |
| width:88px;height:132px; |
| position:fixed;left:50%;bottom:200px; |
| margin-left:-44px; |
| transform:translateX(-50%); |
| filter:drop-shadow(0 8px 24px rgba(0,0,0,0.6)); |
| } |
|
|
| /* Full-screen color wash on color change — center is set by JS via --wash-cx/--wash-cy */ |
| #color-wash{ |
| position:fixed;z-index:50;pointer-events:none; |
| left:var(--wash-cx,50%); |
| top:var(--wash-cy,50%); |
| width:180vmax;height:180vmax; |
| margin-left:-90vmax; |
| margin-top:-90vmax; |
| border-radius:50%; |
| opacity:0; |
| background:none; |
| will-change:transform,opacity,filter; |
| } |
| #color-wash.wash-ripple{ |
| opacity:1; |
| background: |
| radial-gradient(circle at 50% 50%, |
| rgba(255,255,255,0.12) 0%, |
| rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.18) 14%, |
| rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.11) 28%, |
| rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.055) 44%, |
| rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.018) 60%, |
| rgba(var(--wash-r,255),var(--wash-g,255),var(--wash-b,255),0.005) 74%, |
| transparent 86%); |
| animation:colorWashRipple 0.62s ease-out forwards; |
| } |
| @keyframes colorWashRipple{ |
| 0%{ |
| opacity:0; |
| transform:scale(0.18); |
| filter:blur(8px) saturate(1.02); |
| } |
| 24%{ |
| opacity:0.62; |
| } |
| 100%{ |
| opacity:0; |
| transform:scale(1.85); |
| filter:blur(22px) saturate(0.94); |
| } |
| } |
|
|
| /* ============================================================ |
| TURN TOAST |
| ============================================================ */ |
| #turn-toast{ |
| position:fixed;top:42%;left:50%;transform:translate(-50%,-50%); |
| padding:12px 32px;border-radius:14px; |
| background:rgba(0,0,0,0.75);backdrop-filter:blur(16px); |
| border:1px solid rgba(255,255,255,0.1); |
| font-size:15px;font-weight:700;letter-spacing:2px; |
| z-index:200;pointer-events:none; |
| opacity:0;transition:opacity 0.25s; |
| white-space:nowrap; |
| } |
| #turn-toast.show{opacity:1} |
| #turn-toast.player-turn{color:#2ecc71} |
| #turn-toast.ai-turn{color:var(--text-muted)} |
|
|
| /* ============================================================ |
| THOUGHT BUBBLE |
| ============================================================ */ |
| .thought-bubble{ |
| background:#fff;color:#222;padding:3px 10px;border-radius:10px; |
| font-size:11px;font-weight:800;white-space:nowrap; |
| animation:bubbleUp 0.35s cubic-bezier(0.34,1.56,0.64,1); |
| box-shadow:0 2px 8px rgba(0,0,0,0.2); |
| } |
| @keyframes bubbleUp{ |
| 0%{opacity:0;transform:translateY(8px) scale(0.8)} |
| 100%{opacity:1;transform:translateY(0) scale(1)} |
| } |
|
|
| /* ============================================================ |
| COLOR MODAL |
| ============================================================ */ |
| #color-modal{ |
| position:fixed;inset:0;z-index:300; |
| background:rgba(0,0,0,0.65);backdrop-filter:blur(12px); |
| display:none;align-items:center;justify-content:center; |
| } |
| #color-modal.show{display:flex} |
| .color-modal-content{ |
| background:#12122a;border:1px solid rgba(255,255,255,0.1); |
| border-radius:28px;padding:36px;text-align:center; |
| animation:modalPop 0.35s cubic-bezier(0.34,1.56,0.64,1); |
| box-shadow:0 32px 80px rgba(0,0,0,0.6); |
| } |
| @keyframes modalPop{ |
| 0%{opacity:0;transform:scale(0.7) translateY(40px)} |
| 100%{opacity:1;transform:scale(1) translateY(0)} |
| } |
| .color-modal-content h2{font-size:20px;font-weight:800;margin-bottom:24px;letter-spacing:1px} |
| .color-options{display:flex;gap:14px} |
| .color-opt{ |
| width:66px;height:66px;border-radius:50%;cursor:pointer; |
| transition:transform 0.25s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.25s; |
| border:3px solid transparent; |
| } |
| .color-opt:hover{transform:scale(1.15);border-color:rgba(255,255,255,0.5)} |
| .color-opt.red{background:var(--color-red);box-shadow:0 0 20px rgba(230,57,70,0.4)} |
| .color-opt.blue{background:var(--color-blue);box-shadow:0 0 20px rgba(74,156,212,0.4)} |
| .color-opt.green{background:var(--color-green);box-shadow:0 0 20px rgba(46,204,113,0.4)} |
| .color-opt.yellow{background:var(--color-yellow);box-shadow:0 0 20px rgba(243,156,18,0.4)} |
|
|
| /* ============================================================ |
| WIN SCREEN |
| ============================================================ */ |
| #win-screen{ |
| position:fixed;inset:0;z-index:400; |
| background:rgba(0,0,0,0.88);backdrop-filter:blur(24px); |
| display:none;align-items:center;justify-content:center; |
| } |
| #win-screen.show{display:flex} |
| .win-content{text-align:center;animation:modalPop 0.5s cubic-bezier(0.34,1.56,0.64,1)} |
| .win-content h1{ |
| font-size:80px;font-weight:900; |
| background:linear-gradient(135deg,#e63946,#f39c12,#2ecc71,#4a9cd4); |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent; |
| background-clip:text;margin-bottom:8px; |
| } |
| .win-content .winner-name{font-size:28px;font-weight:700;margin-bottom:6px} |
| .win-scores{margin:20px 0;display:flex;flex-direction:column;gap:6px;align-items:center} |
| .win-score-item{font-size:15px;color:var(--text-muted)} |
| .win-score-item.winner-highlight{color:#2ecc71;font-weight:700;font-size:17px} |
| .confetti-container{position:fixed;inset:0;pointer-events:none;z-index:401;overflow:hidden} |
| .confetti-piece{ |
| position:absolute;border-radius:2px;top:-16px; |
| animation:confettiFall linear forwards; |
| } |
| @keyframes confettiFall{ |
| 0%{transform:translateY(0) rotateZ(0);opacity:1} |
| 100%{transform:translateY(110vh) rotateZ(800deg);opacity:0} |
| } |
|
|
| /* ============================================================ |
| PARTICLES |
| ============================================================ */ |
| .particle{ |
| position:fixed;border-radius:50%;pointer-events:none;z-index:500; |
| animation:particleBurst 0.75s ease-out forwards; |
| } |
| @keyframes particleBurst{ |
| 0%{transform:translate(0,0) scale(1);opacity:1} |
| 100%{transform:translate(var(--px),var(--py)) scale(0);opacity:0} |
| } |
|
|
| /* ============================================================ |
| RESPONSIVE |
| ============================================================ */ |
| @media(max-width:768px){ |
| :root{--card-w:58px;--card-h:87px;--card-r:8px;--ai-hand-zone:134px} |
| .uno-logo{font-size:60px} |
| .setup-panel{padding:24px 28px;min-width:auto} |
| #top-row{min-height:96px;padding:10px 10px 2px} |
| #ai-row{ |
| width:100%; |
| flex-wrap:nowrap; |
| justify-content:flex-start; |
| align-items:flex-end; |
| gap:10px; |
| overflow-x:auto; |
| overflow-y:hidden; |
| padding:0 6px 6px; |
| scrollbar-width:none; |
| } |
| #ai-row::-webkit-scrollbar{display:none} |
| .ai-opponent{min-width:88px;flex:0 0 auto;padding:4px 6px;gap:4px} |
| .ai-label-row{font-size:10px;letter-spacing:1px} |
| .ai-card-count{font-size:9px;padding:1px 6px;white-space:nowrap} |
| #player-hand .card-slot{margin-left:-24px} |
| #player-hand .card-slot:first-child{margin-left:0} |
| .ai-hand-row{height:60px;padding:0 8px 2px} |
| .ai-hand-row .card{transform:scale(0.42)} |
| } |
| @media(max-width:480px){ |
| :root{--card-w:46px;--card-h:69px;--card-r:6px;--ai-hand-zone:108px} |
| .uno-logo{font-size:48px} |
| #top-row{min-height:82px;padding:8px 8px 0} |
| #rotation-ring{top:6px;right:10px;font-size:10px} |
| #ai-row{gap:8px;padding:0 2px 4px} |
| .ai-opponent{min-width:72px;padding:3px 4px;gap:3px} |
| .ai-label-row{font-size:9px;letter-spacing:0.5px} |
| .ai-card-count{font-size:8px;padding:1px 5px} |
| #player-hand .card-slot{margin-left:-20px} |
| #player-hand .card-slot:first-child{margin-left:0} |
| .ai-hand-row{height:48px;padding:0 6px 2px} |
| .ai-hand-row .card{transform:scale(0.36)} |
| } |
|
|
| /* ============================================================ |
| UTILITY |
| ============================================================ */ |
| .hidden{display:none!important} |
| </style> |
| </head> |
| <body> |
|
|
| <div class="bg-aurora"></div> |
|
|
| |
| <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> |
|
|
| |
| <div id="game-board"> |
| |
| <div id="top-row"> |
| <div id="rotation-ring"> |
| <span id="ring-arrow" class="ring-arrow clockwise">↻</span> |
| <span id="ring-text">Clockwise</span> |
| </div> |
| <div id="turn-orbit"> |
| <div class="orbit-track" id="orbit-track"></div> |
| <div class="orbit-pointer" id="orbit-pointer"></div> |
| </div> |
| <div id="ai-row"> |
| |
| </div> |
| </div> |
|
|
| |
| <div id="center-area"> |
| <div class="pile-zone"> |
| <div id="draw-pile"> |
| <div class="draw-stack" id="draw-stack"></div> |
| </div> |
| |
| <div id="discard-zone"> |
| <div id="discard-scatter"></div> |
| </div> |
| </div> |
| <div id="color-badge"> |
| <span class="color-dot" id="color-dot"></span> |
| <span id="color-text">RED</span> |
| </div> |
| </div> |
|
|
| |
| <div id="player-area"> |
| <div id="turn-badge">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> |
|
|
| |
| <div id="color-wash"></div> |
|
|
| |
| <div id="card-stage"></div> |
|
|
| |
| <div id="color-selector"> |
| <span class="sel-label">Color</span> |
| <div class="sel-opt red" data-color="red"></div> |
| <div class="sel-opt blue" data-color="blue"></div> |
| <div class="sel-opt green" data-color="green"></div> |
| <div class="sel-opt yellow" data-color="yellow"></div> |
| </div> |
| </div> |
|
|
| |
| <div id="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> |
|
|
| |
| <div id="win-screen"> |
| <div class="confetti-container" id="confetti-container"></div> |
| <div class="win-content"> |
| <h1>UNO!</h1> |
| <div class="winner-name" id="winner-name"></div> |
| <div class="win-scores" id="win-scores"></div> |
| <button class="glow-btn" id="play-again-btn">Play Again</button> |
| </div> |
| </div> |
|
|
| <script> |
| |
| |
| |
| const COLORS = ['red','blue','green','yellow']; |
| const COLOR_NAMES = {red:'RED',blue:'BLUE',green:'GREEN',yellow:'YELLOW'}; |
| 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}; |
| |
| |
| |
| |
| 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); |
| } |
| } |
| |
| |
| |
| |
| function isValidPlay(card,topCard,curColor,pendingDraw){ |
| |
| 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)); |
| } |
| |
| |
| |
| |
| 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]}; |
| } |
| |
| 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]}; |
| } |
| |
| |
| |
| |
| 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(); |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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 |
| }); |
| }); |
| } |
| |
| |
| |
| |
| 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`; |
| } |
| } |
| |
| |
| |
| |
| 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); |
| }); |
| }); |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| |
| |
| 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; |
| } |
| scatter.dataset.total=total; |
| const isNewCard=total>prevTotal; |
| |
| |
| 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); |
| } |
| |
| |
| 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){ |
| |
| 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 { |
| |
| el.dataset.color=card.color; |
| el.dataset.value=card.value; |
| |
| 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){ |
| |
| 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); |
| }); |
| } |
| |
| |
| |
| |
| 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'; }); |
| } |
| } |
| |
| |
| 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; |
| |
| |
| 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; |
| 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'); |
| |
| |
| if(changed){ |
| badge.classList.remove('color-ripple'); |
| void badge.offsetWidth; |
| badge.classList.add('color-ripple'); |
| setTimeout(()=>badge.classList.remove('color-ripple'),700); |
| } |
| |
| |
| if(changed && !_skipWashNext){ |
| triggerColorWash(state.currentColor,_colorWashSourceIdx); |
| } |
| _skipWashNext=false; |
| |
| |
| 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); |
| |
| |
| let cx='50%',cy='50%'; |
| if(playerIdx===0){ |
| |
| cy='85%'; cx='50%'; |
| } else if(playerIdx>0){ |
| |
| 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(){ |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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(); |
| }); |
| |
| |
| |
| |
| 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; |
| |
| } |
| |
| 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(); |
| } |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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 |
| }); |
| |
| |
| animateAIPlay(sourceRect,card,()=>{ |
| if(card.value==='wild'||card.value==='wild_draw4'){ |
| state.currentColor=pickAIColor(player.hand); |
| _colorWashSourceIdx=idx; |
| _skipWashNext=true; |
| triggerColorWash(state.currentColor,idx); |
| 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); |
| } |
| |
| |
| |
| |
| function updateUnoTimer(){ |
| clearTimeout(unoTimer); |
| return; |
| } |
| |
| |
| |
| |
| function showColorModal(isPlayer){ |
| if(!isPlayer) return; |
| |
| |
| const sel=document.getElementById('color-selector'); |
| const stage=document.getElementById('card-stage'); |
| const staged=pendingWildCard; |
| |
| |
| 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'}); |
| } |
| }; |
| }); |
| } |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| function spawnParticleBurst(x,y,color){ |
| for(let i=0;i<16;i++){ |
| const p=document.createElement('div'); |
| p.className='particle'; |
| const angle=(i/16)*Math.PI*2; |
| const dist=50+Math.random()*70; |
| const size=4+Math.random()*6; |
| p.style.cssText=`left:${x}px;top:${y}px;width:${size}px;height:${size}px;background:${color};--px:${Math.cos(angle)*dist}px;--py:${Math.sin(angle)*dist}px;`; |
| document.body.appendChild(p); |
| setTimeout(()=>p.remove(),800); |
| } |
| } |
| |
| |
| |
| |
| function 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); |
| } |
| } |
| |
| |
| |
| |
| 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> |
|
|