Created
June 9, 2026 01:18
-
-
Save basperheim/f47a862ced9a2d0160e34ae726f49dc9 to your computer and use it in GitHub Desktop.
A complete, dependency-free Yahtzee game built as a single standalone HTML file. Features a responsive metallic-blue neon interface, animated and clickable dice, keyboard controls, sound effects generated with the Web Audio API, full Yahtzee scoring, upper-section bonus tracking, local high-score persistence, and optional educational strategy hi…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta name="color-scheme" content="dark" /> | |
| <title>NEON YAHTZEE</title> | |
| <style> | |
| :root { | |
| --bg-0: #020712; | |
| --bg-1: #071426; | |
| --bg-2: #0c2440; | |
| --panel: rgba(8, 26, 49, 0.82); | |
| --panel-strong: rgba(7, 20, 39, 0.95); | |
| --line: rgba(124, 201, 255, 0.26); | |
| --line-bright: rgba(150, 220, 255, 0.65); | |
| --text: #eaf7ff; | |
| --muted: #87a8bf; | |
| --cyan: #55d9ff; | |
| --blue: #2c84ff; | |
| --electric: #8ee8ff; | |
| --gold: #ffc857; | |
| --danger: #ff5b7f; | |
| --success: #65f5b4; | |
| --die-size: clamp(64px, 8vw, 96px); | |
| --radius: 22px; | |
| --shadow: | |
| 0 24px 80px rgba(0, 0, 0, 0.45), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.06); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| min-height: 100%; | |
| margin: 0; | |
| } | |
| body { | |
| overflow-x: hidden; | |
| color: var(--text); | |
| font-family: | |
| Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, | |
| "Segoe UI", sans-serif; | |
| background: | |
| radial-gradient(circle at 18% 10%, rgba(40, 132, 255, 0.22), transparent 34%), | |
| radial-gradient(circle at 82% 18%, rgba(65, 223, 255, 0.12), transparent 30%), | |
| linear-gradient(160deg, var(--bg-0), var(--bg-1) 45%, #06101d 100%); | |
| } | |
| body::before { | |
| content: ""; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| opacity: 0.22; | |
| background-image: | |
| linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px); | |
| background-size: 42px 42px; | |
| mask-image: radial-gradient(circle at center, black, transparent 86%); | |
| } | |
| button, input { | |
| font: inherit; | |
| } | |
| button { | |
| color: inherit; | |
| } | |
| .app-shell { | |
| width: min(1520px, 100%); | |
| margin: 0 auto; | |
| padding: 24px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .topbar { | |
| display: flex; | |
| gap: 18px; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 22px; | |
| padding: 18px 20px; | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: | |
| linear-gradient(180deg, rgba(23, 65, 102, 0.72), rgba(5, 19, 38, 0.9)); | |
| box-shadow: var(--shadow); | |
| backdrop-filter: blur(18px); | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| min-width: 0; | |
| } | |
| .brand-mark { | |
| width: 48px; | |
| height: 48px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 14px; | |
| background: | |
| linear-gradient(145deg, #b9eeff, #3c90d6 42%, #0f355d 72%, #17334b); | |
| border: 1px solid rgba(255,255,255,0.4); | |
| color: #03101d; | |
| font-weight: 1000; | |
| font-size: 21px; | |
| box-shadow: | |
| 0 0 28px rgba(85,217,255,0.35), | |
| inset 2px 2px 4px rgba(255,255,255,0.65), | |
| inset -4px -4px 8px rgba(0,0,0,0.38); | |
| transform: rotate(-7deg); | |
| } | |
| h1 { | |
| margin: 0; | |
| font-size: clamp(1.2rem, 2vw, 2rem); | |
| line-height: 1; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .subtitle { | |
| margin-top: 6px; | |
| color: var(--muted); | |
| font-size: 0.86rem; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .topbar-actions { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| justify-content: flex-end; | |
| } | |
| .game-grid { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); | |
| gap: 22px; | |
| align-items: start; | |
| } | |
| .panel { | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: | |
| linear-gradient(180deg, rgba(13, 38, 70, 0.78), rgba(4, 16, 31, 0.93)); | |
| box-shadow: var(--shadow); | |
| backdrop-filter: blur(18px); | |
| } | |
| .arena { | |
| min-height: 690px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .arena::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| background: | |
| radial-gradient(circle at 50% 44%, rgba(54, 155, 255, 0.2), transparent 34%), | |
| linear-gradient(135deg, rgba(255,255,255,0.025), transparent 45%); | |
| } | |
| .arena-inner { | |
| position: relative; | |
| min-height: 690px; | |
| padding: clamp(22px, 4vw, 50px); | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .status-row { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 16px; | |
| align-items: flex-start; | |
| margin-bottom: 26px; | |
| } | |
| .round-label { | |
| color: var(--cyan); | |
| font-size: 0.8rem; | |
| font-weight: 800; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| } | |
| .status-main { | |
| margin-top: 8px; | |
| font-size: clamp(1.25rem, 2vw, 2rem); | |
| font-weight: 900; | |
| letter-spacing: -0.02em; | |
| } | |
| .roll-counter { | |
| text-align: right; | |
| color: var(--muted); | |
| white-space: nowrap; | |
| } | |
| .roll-counter strong { | |
| display: block; | |
| color: var(--text); | |
| font-size: 2rem; | |
| line-height: 1; | |
| } | |
| .dice-zone { | |
| flex: 1; | |
| display: grid; | |
| place-items: center; | |
| min-height: 320px; | |
| perspective: 1100px; | |
| } | |
| .dice-row { | |
| width: 100%; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: clamp(12px, 2vw, 24px); | |
| flex-wrap: wrap; | |
| } | |
| .die-wrap { | |
| position: relative; | |
| width: var(--die-size); | |
| height: calc(var(--die-size) + 34px); | |
| display: grid; | |
| justify-items: center; | |
| align-content: start; | |
| gap: 9px; | |
| user-select: none; | |
| } | |
| .die { | |
| width: var(--die-size); | |
| height: var(--die-size); | |
| position: relative; | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| grid-template-rows: repeat(3, 1fr); | |
| padding: 13%; | |
| border: 1px solid rgba(255,255,255,0.72); | |
| border-radius: 19%; | |
| cursor: pointer; | |
| background: | |
| radial-gradient(circle at 28% 20%, rgba(255,255,255,0.96), transparent 23%), | |
| linear-gradient(145deg, #e7f6ff 0%, #8bbbdc 36%, #3f779e 72%, #173854 100%); | |
| box-shadow: | |
| 0 18px 32px rgba(0,0,0,0.48), | |
| 0 0 22px rgba(71, 188, 255, 0.18), | |
| inset 5px 5px 12px rgba(255,255,255,0.72), | |
| inset -8px -8px 16px rgba(0,20,42,0.44); | |
| transition: | |
| transform 180ms ease, | |
| box-shadow 180ms ease, | |
| filter 180ms ease; | |
| transform-style: preserve-3d; | |
| } | |
| .die:hover { | |
| transform: translateY(-5px) rotateX(6deg) rotateY(-5deg); | |
| box-shadow: | |
| 0 25px 38px rgba(0,0,0,0.55), | |
| 0 0 30px rgba(85,217,255,0.32), | |
| inset 5px 5px 12px rgba(255,255,255,0.72), | |
| inset -8px -8px 16px rgba(0,20,42,0.44); | |
| } | |
| .die.held { | |
| transform: translateY(-10px); | |
| filter: saturate(1.15) brightness(1.08); | |
| box-shadow: | |
| 0 0 0 3px rgba(85,217,255,0.85), | |
| 0 0 34px rgba(85,217,255,0.72), | |
| 0 24px 42px rgba(0,0,0,0.56), | |
| inset 5px 5px 12px rgba(255,255,255,0.75), | |
| inset -8px -8px 16px rgba(0,20,42,0.42); | |
| } | |
| .die.hold-disabled { | |
| cursor: default; | |
| } | |
| .die.rolling { | |
| animation: tumble 650ms cubic-bezier(.2,.85,.25,1); | |
| pointer-events: none; | |
| } | |
| .die-question { | |
| position: absolute; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| color: #06213a; | |
| font-size: calc(var(--die-size) * 0.5); | |
| font-weight: 1000; | |
| line-height: 1; | |
| text-shadow: | |
| 0 1px 0 rgba(255, 255, 255, 0.5), | |
| 0 0 10px rgba(85, 217, 255, 0.24); | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| @keyframes tumble { | |
| 0% { transform: translateY(0) rotateX(0) rotateY(0) rotateZ(0) scale(1); } | |
| 20% { transform: translateY(-45px) rotateX(160deg) rotateY(110deg) rotateZ(35deg) scale(1.08); } | |
| 52% { transform: translateY(10px) rotateX(380deg) rotateY(260deg) rotateZ(120deg) scale(0.94); } | |
| 78% { transform: translateY(-12px) rotateX(510deg) rotateY(420deg) rotateZ(170deg) scale(1.02); } | |
| 100% { transform: translateY(0) rotateX(720deg) rotateY(540deg) rotateZ(180deg) scale(1); } | |
| } | |
| .pip { | |
| width: 46%; | |
| aspect-ratio: 1; | |
| place-self: center; | |
| border-radius: 50%; | |
| opacity: 0; | |
| background: | |
| radial-gradient(circle at 35% 30%, #d6f5ff 0 10%, #0f385a 20%, #020b14 78%); | |
| box-shadow: | |
| inset 1px 1px 2px rgba(255,255,255,0.18), | |
| 0 1px 1px rgba(255,255,255,0.25); | |
| } | |
| .pip.visible { opacity: 1; } | |
| .hold-label { | |
| min-height: 22px; | |
| color: transparent; | |
| font-size: 0.72rem; | |
| font-weight: 900; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| transition: color 150ms ease; | |
| } | |
| .die-wrap[data-held="true"] .hold-label { | |
| color: var(--cyan); | |
| text-shadow: 0 0 12px rgba(85,217,255,0.8); | |
| } | |
| .controls { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) auto; | |
| gap: 14px; | |
| align-items: stretch; | |
| margin-top: 24px; | |
| } | |
| .hint { | |
| display: flex; | |
| align-items: center; | |
| min-height: 62px; | |
| padding: 14px 16px; | |
| border: 1px solid rgba(115, 190, 235, 0.18); | |
| border-radius: 16px; | |
| color: var(--muted); | |
| background: rgba(0, 8, 18, 0.28); | |
| } | |
| .primary-btn, .secondary-btn, .icon-btn { | |
| border: 0; | |
| cursor: pointer; | |
| border-radius: 14px; | |
| font-weight: 900; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| transition: | |
| transform 150ms ease, | |
| filter 150ms ease, | |
| box-shadow 150ms ease, | |
| opacity 150ms ease; | |
| } | |
| .primary-btn { | |
| min-width: 190px; | |
| padding: 0 24px; | |
| background: | |
| linear-gradient(180deg, #b5efff 0%, #4fbdf5 34%, #1976d7 64%, #0a3f85 100%); | |
| color: #03101d; | |
| border: 1px solid rgba(255,255,255,0.56); | |
| box-shadow: | |
| 0 12px 30px rgba(31, 128, 235, 0.33), | |
| inset 0 2px 0 rgba(255,255,255,0.68), | |
| inset 0 -3px 8px rgba(0,0,0,0.28); | |
| text-shadow: 0 1px rgba(255,255,255,0.46); | |
| } | |
| .primary-btn:hover:not(:disabled), | |
| .secondary-btn:hover:not(:disabled), | |
| .icon-btn:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| filter: brightness(1.08); | |
| } | |
| .primary-btn:active:not(:disabled), | |
| .secondary-btn:active:not(:disabled), | |
| .icon-btn:active:not(:disabled) { | |
| transform: translateY(1px); | |
| } | |
| button:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.44; | |
| filter: grayscale(0.3); | |
| } | |
| .secondary-btn, .icon-btn { | |
| min-height: 42px; | |
| padding: 0 15px; | |
| border: 1px solid var(--line); | |
| background: linear-gradient(180deg, rgba(28, 71, 111, 0.82), rgba(8, 28, 52, 0.88)); | |
| color: var(--text); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.09); | |
| } | |
| .icon-btn { | |
| min-width: 46px; | |
| padding: 0 12px; | |
| font-size: 1.05rem; | |
| } | |
| .score-panel { | |
| overflow: hidden; | |
| } | |
| .score-header { | |
| padding: 20px; | |
| border-bottom: 1px solid var(--line); | |
| background: | |
| linear-gradient(180deg, rgba(30, 79, 120, 0.4), rgba(0,0,0,0.08)); | |
| } | |
| .score-title-row, | |
| .score-summary { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| gap: 14px; | |
| } | |
| .score-title { | |
| font-size: 1rem; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| color: var(--cyan); | |
| font-weight: 900; | |
| } | |
| .score-summary { | |
| margin-top: 12px; | |
| } | |
| .total-value { | |
| font-size: 2.15rem; | |
| line-height: 1; | |
| font-weight: 1000; | |
| text-shadow: 0 0 20px rgba(85,217,255,0.28); | |
| } | |
| .score-note { | |
| text-align: right; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| line-height: 1.4; | |
| } | |
| .score-list { | |
| max-height: 610px; | |
| overflow-y: auto; | |
| scrollbar-width: thin; | |
| scrollbar-color: rgba(85,217,255,0.45) rgba(0,0,0,0.18); | |
| } | |
| .section-label { | |
| padding: 12px 18px 8px; | |
| color: var(--muted); | |
| background: rgba(0, 8, 18, 0.32); | |
| font-size: 0.68rem; | |
| font-weight: 900; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| } | |
| .score-row { | |
| min-height: 48px; | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 14px; | |
| align-items: center; | |
| padding: 10px 16px; | |
| border-top: 1px solid rgba(120, 195, 240, 0.11); | |
| cursor: pointer; | |
| transition: background 120ms ease, transform 120ms ease; | |
| } | |
| .score-row:hover:not(.used):not(.locked) { | |
| background: rgba(65, 174, 255, 0.12); | |
| transform: translateX(-2px); | |
| } | |
| .score-row.used { | |
| cursor: default; | |
| background: rgba(0,0,0,0.14); | |
| } | |
| .score-row.locked { | |
| cursor: default; | |
| opacity: 0.58; | |
| } | |
| .score-row { | |
| position: relative; | |
| z-index: 0; | |
| } | |
| .score-row:hover, | |
| .score-row:focus, | |
| .score-row:focus-within { | |
| z-index: 100; | |
| } | |
| .category-name { | |
| font-weight: 780; | |
| } | |
| .category-help { | |
| margin-top: 2px; | |
| color: var(--muted); | |
| font-size: 0.72rem; | |
| } | |
| .score-value { | |
| min-width: 56px; | |
| padding: 6px 10px; | |
| text-align: center; | |
| border-radius: 10px; | |
| font-weight: 1000; | |
| background: rgba(1, 10, 20, 0.4); | |
| border: 1px solid rgba(130, 205, 250, 0.18); | |
| } | |
| .score-row:not(.used):not(.locked) .score-value.preview { | |
| color: var(--cyan); | |
| border-color: rgba(85,217,255,0.55); | |
| box-shadow: inset 0 0 14px rgba(85,217,255,0.08); | |
| } | |
| .score-row.used .score-value { | |
| color: var(--success); | |
| } | |
| .score-row.recommended { | |
| position: relative; | |
| z-index: 3; | |
| background: | |
| linear-gradient(90deg, rgba(42, 145, 255, 0.24), rgba(85, 217, 255, 0.08)); | |
| box-shadow: | |
| inset 3px 0 0 var(--cyan), | |
| inset 0 0 24px rgba(85, 217, 255, 0.12), | |
| 0 0 22px rgba(85, 217, 255, 0.12); | |
| } | |
| .score-row.recommended .score-value { | |
| color: #dff9ff; | |
| border-color: rgba(142, 232, 255, 0.85); | |
| box-shadow: | |
| 0 0 18px rgba(85, 217, 255, 0.28), | |
| inset 0 0 15px rgba(85, 217, 255, 0.12); | |
| } | |
| .recommendation-badge { | |
| display: inline-block; | |
| margin-left: 8px; | |
| padding: 2px 6px; | |
| border: 1px solid rgba(142, 232, 255, 0.58); | |
| border-radius: 999px; | |
| color: var(--cyan); | |
| background: rgba(8, 42, 67, 0.72); | |
| font-size: 0.57rem; | |
| font-weight: 950; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| vertical-align: 2px; | |
| text-shadow: 0 0 8px rgba(85, 217, 255, 0.55); | |
| } | |
| .strategy-tooltip { | |
| position: absolute; | |
| right: 14px; | |
| top: calc(100% - 4px); | |
| z-index: 101; | |
| width: min(330px, calc(100vw - 42px)); | |
| padding: 13px 14px; | |
| border: 1px solid rgba(142, 232, 255, 0.48); | |
| border-radius: 13px; | |
| color: var(--text); | |
| background: | |
| linear-gradient(180deg, rgba(11, 39, 68, 0.98), rgba(3, 15, 29, 0.99)); | |
| box-shadow: | |
| 0 18px 44px rgba(0, 0, 0, 0.58), | |
| 0 0 24px rgba(85, 217, 255, 0.12); | |
| opacity: 0; | |
| visibility: hidden; | |
| pointer-events: none; | |
| transform: translateY(-4px) scale(0.98); | |
| transform-origin: top right; | |
| transition: | |
| opacity 130ms ease, | |
| transform 130ms ease, | |
| visibility 130ms ease; | |
| } | |
| .score-row:hover .strategy-tooltip, | |
| .score-row:focus-within .strategy-tooltip, | |
| .score-row:focus .strategy-tooltip { | |
| opacity: 1; | |
| visibility: visible; | |
| transform: translateY(0) scale(1); | |
| } | |
| .tooltip-title { | |
| margin-bottom: 8px; | |
| color: var(--cyan); | |
| font-weight: 900; | |
| } | |
| .tooltip-line { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 16px; | |
| margin-top: 5px; | |
| color: var(--muted); | |
| font-size: 0.75rem; | |
| } | |
| .tooltip-line strong { | |
| color: var(--text); | |
| text-align: right; | |
| } | |
| .tooltip-advice { | |
| margin-top: 10px; | |
| padding-top: 9px; | |
| border-top: 1px solid rgba(130, 205, 250, 0.16); | |
| color: #b9d8ea; | |
| font-size: 0.76rem; | |
| line-height: 1.45; | |
| } | |
| .tooltip-advice.good { | |
| color: var(--success); | |
| } | |
| .strategy-off .strategy-tooltip, | |
| .strategy-off .recommendation-badge { | |
| display: none; | |
| } | |
| .strategy-off .score-row.recommended { | |
| background: transparent; | |
| box-shadow: none; | |
| } | |
| .bonus-meter { | |
| margin-top: 14px; | |
| height: 8px; | |
| overflow: hidden; | |
| border-radius: 999px; | |
| background: rgba(0, 0, 0, 0.32); | |
| border: 1px solid rgba(255,255,255,0.06); | |
| } | |
| .bonus-fill { | |
| height: 100%; | |
| width: 0%; | |
| background: linear-gradient(90deg, var(--blue), var(--cyan), var(--electric)); | |
| box-shadow: 0 0 16px rgba(85,217,255,0.45); | |
| transition: width 400ms ease; | |
| } | |
| .modal-backdrop { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 20; | |
| display: grid; | |
| place-items: center; | |
| padding: 22px; | |
| background: rgba(0, 5, 12, 0.78); | |
| backdrop-filter: blur(14px); | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 180ms ease; | |
| } | |
| .modal-backdrop.open { | |
| opacity: 1; | |
| pointer-events: auto; | |
| } | |
| .modal { | |
| width: min(520px, 100%); | |
| padding: 28px; | |
| border: 1px solid var(--line-bright); | |
| border-radius: 24px; | |
| background: | |
| linear-gradient(180deg, rgba(18, 52, 88, 0.98), rgba(4, 15, 30, 0.99)); | |
| box-shadow: | |
| 0 30px 100px rgba(0,0,0,0.7), | |
| 0 0 60px rgba(45, 148, 255, 0.18), | |
| inset 0 1px 0 rgba(255,255,255,0.08); | |
| transform: translateY(10px) scale(0.98); | |
| transition: transform 180ms ease; | |
| } | |
| .modal-backdrop.open .modal { | |
| transform: translateY(0) scale(1); | |
| } | |
| .modal h2 { | |
| margin: 0 0 10px; | |
| font-size: 2rem; | |
| } | |
| .modal p { | |
| color: var(--muted); | |
| line-height: 1.6; | |
| } | |
| .modal-score { | |
| margin: 18px 0; | |
| font-size: 3.4rem; | |
| font-weight: 1000; | |
| color: var(--cyan); | |
| text-shadow: 0 0 32px rgba(85,217,255,0.38); | |
| } | |
| .modal-actions { | |
| display: flex; | |
| gap: 12px; | |
| justify-content: flex-end; | |
| margin-top: 22px; | |
| } | |
| .toast-stack { | |
| position: fixed; | |
| right: 22px; | |
| bottom: 22px; | |
| z-index: 30; | |
| display: grid; | |
| gap: 10px; | |
| pointer-events: none; | |
| } | |
| .toast { | |
| min-width: 250px; | |
| max-width: 360px; | |
| padding: 13px 15px; | |
| border-radius: 14px; | |
| color: var(--text); | |
| background: rgba(4, 18, 34, 0.94); | |
| border: 1px solid var(--line-bright); | |
| box-shadow: 0 18px 40px rgba(0,0,0,0.45); | |
| animation: toastIn 260ms ease, toastOut 280ms ease 2.5s forwards; | |
| } | |
| @keyframes toastIn { | |
| from { opacity: 0; transform: translateY(16px) scale(0.96); } | |
| to { opacity: 1; transform: translateY(0) scale(1); } | |
| } | |
| @keyframes toastOut { | |
| to { opacity: 0; transform: translateY(10px) scale(0.98); } | |
| } | |
| .particle { | |
| position: fixed; | |
| z-index: 25; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| pointer-events: none; | |
| background: var(--cyan); | |
| box-shadow: 0 0 12px currentColor; | |
| animation: burst 900ms ease-out forwards; | |
| } | |
| @keyframes burst { | |
| to { | |
| opacity: 0; | |
| transform: translate(var(--dx), var(--dy)) scale(0.2) rotate(360deg); | |
| } | |
| } | |
| .kbd { | |
| display: inline-block; | |
| min-width: 24px; | |
| padding: 2px 6px; | |
| margin: 0 2px; | |
| text-align: center; | |
| color: #cceeff; | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.14); | |
| border-bottom-width: 2px; | |
| border-radius: 6px; | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| } | |
| @media (max-width: 980px) { | |
| .game-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .score-list { | |
| max-height: none; | |
| } | |
| .arena, | |
| .arena-inner { | |
| min-height: 590px; | |
| } | |
| } | |
| @media (max-width: 680px) { | |
| .app-shell { padding: 12px; } | |
| .topbar { | |
| align-items: flex-start; | |
| padding: 14px; | |
| } | |
| .subtitle { display: none; } | |
| .topbar-actions { | |
| gap: 7px; | |
| } | |
| .secondary-btn { | |
| min-height: 38px; | |
| padding: 0 11px; | |
| font-size: 0.75rem; | |
| } | |
| .arena-inner { | |
| padding: 20px 14px; | |
| } | |
| .controls { | |
| grid-template-columns: 1fr; | |
| } | |
| .primary-btn { | |
| min-height: 58px; | |
| width: 100%; | |
| } | |
| .hint { | |
| order: 2; | |
| } | |
| .status-main { | |
| max-width: 230px; | |
| } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| animation-duration: 1ms !important; | |
| animation-iteration-count: 1 !important; | |
| scroll-behavior: auto !important; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="app-shell"> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <div class="brand-mark" aria-hidden="true">Y!</div> | |
| <div> | |
| <h1>Neon Yahtzee</h1> | |
| <div class="subtitle">Five dice. Thirteen rounds. Zero mercy.</div> | |
| </div> | |
| </div> | |
| <div class="topbar-actions"> | |
| <button class="secondary-btn" id="strategyBtn" type="button" title="Toggle educational strategy hints">Hints: On</button> | |
| <button class="secondary-btn" id="newGameBtn" type="button">New Game</button> | |
| <button class="icon-btn" id="soundBtn" type="button" aria-label="Toggle sound" title="Toggle sound">🔊</button> | |
| </div> | |
| </header> | |
| <section class="game-grid"> | |
| <section class="panel arena" aria-label="Dice arena"> | |
| <div class="arena-inner"> | |
| <div class="status-row"> | |
| <div> | |
| <div class="round-label" id="roundLabel">Round 1 of 13</div> | |
| <div class="status-main" id="statusText">Lock in. Roll the dice.</div> | |
| </div> | |
| <div class="roll-counter"> | |
| Rolls left | |
| <strong id="rollsLeft">3</strong> | |
| </div> | |
| </div> | |
| <div class="dice-zone"> | |
| <div class="dice-row" id="diceRow" aria-label="Dice"></div> | |
| </div> | |
| <div class="controls"> | |
| <div class="hint" id="hintText"> | |
| Roll, then click dice to hold them. Keyboard: <span class="kbd">Space</span> to roll, <span class="kbd">1–5</span> to hold. | |
| </div> | |
| <button class="primary-btn" id="rollBtn" type="button">Roll Dice</button> | |
| </div> | |
| </div> | |
| </section> | |
| <aside class="panel score-panel" aria-label="Scorecard"> | |
| <div class="score-header"> | |
| <div class="score-title-row"> | |
| <div class="score-title">Combat Scorecard</div> | |
| <div id="highScoreLabel">Best: 0</div> | |
| </div> | |
| <div class="score-summary"> | |
| <div> | |
| <div class="total-value" id="grandTotal">0</div> | |
| <div class="score-note">Current score</div> | |
| </div> | |
| <div class="score-note"> | |
| Upper: <strong id="upperTotal">0</strong> / 63<br /> | |
| Bonus: <strong id="bonusValue">0</strong> | |
| </div> | |
| </div> | |
| <div class="bonus-meter" aria-label="Upper section bonus progress"> | |
| <div class="bonus-fill" id="bonusFill"></div> | |
| </div> | |
| </div> | |
| <div class="score-list" id="scoreList"></div> | |
| </aside> | |
| </section> | |
| </main> | |
| <div class="modal-backdrop" id="modalBackdrop" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> | |
| <div class="modal"> | |
| <h2 id="modalTitle">Mission Complete</h2> | |
| <p id="modalText">Your final score:</p> | |
| <div class="modal-score" id="modalScore">0</div> | |
| <div class="modal-actions"> | |
| <button class="primary-btn" id="playAgainBtn" type="button">Play Again</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="toast-stack" id="toastStack" aria-live="polite"></div> | |
| <script> | |
| "use strict"; | |
| const CATEGORY_DEFINITIONS = [ | |
| { id: "ones", label: "Ones", section: "upper", help: "Sum all ones" }, | |
| { id: "twos", label: "Twos", section: "upper", help: "Sum all twos" }, | |
| { id: "threes", label: "Threes", section: "upper", help: "Sum all threes" }, | |
| { id: "fours", label: "Fours", section: "upper", help: "Sum all fours" }, | |
| { id: "fives", label: "Fives", section: "upper", help: "Sum all fives" }, | |
| { id: "sixes", label: "Sixes", section: "upper", help: "Sum all sixes" }, | |
| { id: "threeKind", label: "Three of a Kind", section: "lower", help: "3+ matching — sum all dice" }, | |
| { id: "fourKind", label: "Four of a Kind", section: "lower", help: "4+ matching — sum all dice" }, | |
| { id: "fullHouse", label: "Full House", section: "lower", help: "Three + pair — 25 points" }, | |
| { id: "smallStraight", label: "Small Straight", section: "lower", help: "Four in sequence — 30 points" }, | |
| { id: "largeStraight", label: "Large Straight", section: "lower", help: "Five in sequence — 40 points" }, | |
| { id: "yahtzee", label: "YAHTZEE", section: "lower", help: "Five matching — 50 points" }, | |
| { id: "chance", label: "Chance", section: "lower", help: "Sum all dice" } | |
| ]; | |
| const state = { | |
| dice: [null, null, null, null, null], | |
| held: [false, false, false, false, false], | |
| rollsLeft: 3, | |
| hasRolled: false, | |
| isRolling: false, | |
| scores: Object.fromEntries(CATEGORY_DEFINITIONS.map(({ id }) => [id, null])), | |
| soundEnabled: true, | |
| strategyEnabled: localStorage.getItem("neon-yahtzee-strategy") !== "off" | |
| }; | |
| const diceRow = document.getElementById("diceRow"); | |
| const rollBtn = document.getElementById("rollBtn"); | |
| const strategyBtn = document.getElementById("strategyBtn"); | |
| const newGameBtn = document.getElementById("newGameBtn"); | |
| const soundBtn = document.getElementById("soundBtn"); | |
| const scoreList = document.getElementById("scoreList"); | |
| const roundLabel = document.getElementById("roundLabel"); | |
| const statusText = document.getElementById("statusText"); | |
| const rollsLeftEl = document.getElementById("rollsLeft"); | |
| const grandTotalEl = document.getElementById("grandTotal"); | |
| const upperTotalEl = document.getElementById("upperTotal"); | |
| const bonusValueEl = document.getElementById("bonusValue"); | |
| const bonusFillEl = document.getElementById("bonusFill"); | |
| const highScoreLabel = document.getElementById("highScoreLabel"); | |
| const hintText = document.getElementById("hintText"); | |
| const modalBackdrop = document.getElementById("modalBackdrop"); | |
| const modalScore = document.getElementById("modalScore"); | |
| const modalText = document.getElementById("modalText"); | |
| const playAgainBtn = document.getElementById("playAgainBtn"); | |
| const toastStack = document.getElementById("toastStack"); | |
| let audioContext = null; | |
| function getHighScore() { | |
| return Number(localStorage.getItem("neon-yahtzee-high-score") || 0); | |
| } | |
| function setHighScore(score) { | |
| localStorage.setItem("neon-yahtzee-high-score", String(score)); | |
| } | |
| function sum(values) { | |
| return values.reduce((total, value) => total + value, 0); | |
| } | |
| function getCounts(dice) { | |
| return dice.reduce((counts, die) => { | |
| counts[die] = (counts[die] || 0) + 1; | |
| return counts; | |
| }, {}); | |
| } | |
| function calculateScore(categoryId, dice) { | |
| const counts = getCounts(dice); | |
| const values = Object.values(counts); | |
| const total = sum(dice); | |
| const uniqueSorted = [...new Set(dice)].sort((a, b) => a - b); | |
| const sequence = uniqueSorted.join(""); | |
| const upperMap = { | |
| ones: 1, | |
| twos: 2, | |
| threes: 3, | |
| fours: 4, | |
| fives: 5, | |
| sixes: 6 | |
| }; | |
| if (upperMap[categoryId]) { | |
| const face = upperMap[categoryId]; | |
| return (counts[face] || 0) * face; | |
| } | |
| switch (categoryId) { | |
| case "threeKind": | |
| return values.some(count => count >= 3) ? total : 0; | |
| case "fourKind": | |
| return values.some(count => count >= 4) ? total : 0; | |
| case "fullHouse": | |
| return values.includes(3) && values.includes(2) ? 25 : 0; | |
| case "smallStraight": | |
| return ["1234", "2345", "3456"].some(run => sequence.includes(run)) ? 30 : 0; | |
| case "largeStraight": | |
| return sequence === "12345" || sequence === "23456" ? 40 : 0; | |
| case "yahtzee": | |
| return values.includes(5) ? 50 : 0; | |
| case "chance": | |
| return total; | |
| default: | |
| return 0; | |
| } | |
| } | |
| const UPPER_FACE_BY_CATEGORY = { | |
| ones: 1, | |
| twos: 2, | |
| threes: 3, | |
| fours: 4, | |
| fives: 5, | |
| sixes: 6 | |
| }; | |
| const BASE_ODDS_CACHE = new Map(); | |
| function formatPercent(probability) { | |
| if (probability === null) { | |
| return "—"; | |
| } | |
| const percent = probability * 100; | |
| if (percent > 0 && percent < 0.1) { | |
| return "<0.1%"; | |
| } | |
| return `${percent.toFixed(percent < 10 ? 1 : 0)}%`; | |
| } | |
| function enumerateRolls(count, callback, working = []) { | |
| if (working.length === count) { | |
| callback(working); | |
| return; | |
| } | |
| for (let value = 1; value <= 6; value += 1) { | |
| working.push(value); | |
| enumerateRolls(count, callback, working); | |
| working.pop(); | |
| } | |
| } | |
| function meetsLearningTarget(categoryId, dice) { | |
| const counts = getCounts(dice); | |
| const values = Object.values(counts); | |
| const uniqueSorted = [...new Set(dice)].sort((a, b) => a - b); | |
| const sequence = uniqueSorted.join(""); | |
| if (UPPER_FACE_BY_CATEGORY[categoryId]) { | |
| return (counts[UPPER_FACE_BY_CATEGORY[categoryId]] || 0) >= 3; | |
| } | |
| switch (categoryId) { | |
| case "threeKind": | |
| return values.some(count => count >= 3); | |
| case "fourKind": | |
| return values.some(count => count >= 4); | |
| case "fullHouse": | |
| return values.includes(3) && values.includes(2); | |
| case "smallStraight": | |
| return ["1234", "2345", "3456"].some(run => sequence.includes(run)); | |
| case "largeStraight": | |
| return sequence === "12345" || sequence === "23456"; | |
| case "yahtzee": | |
| return values.includes(5); | |
| default: | |
| return false; | |
| } | |
| } | |
| function getBaseOdds(categoryId) { | |
| if (categoryId === "chance") { | |
| return null; | |
| } | |
| if (BASE_ODDS_CACHE.has(categoryId)) { | |
| return BASE_ODDS_CACHE.get(categoryId); | |
| } | |
| let successes = 0; | |
| let total = 0; | |
| enumerateRolls(5, dice => { | |
| total += 1; | |
| if (meetsLearningTarget(categoryId, dice)) { | |
| successes += 1; | |
| } | |
| }); | |
| const probability = successes / total; | |
| BASE_ODDS_CACHE.set(categoryId, probability); | |
| return probability; | |
| } | |
| function getSuggestedHoldIndexes(categoryId, dice) { | |
| const counts = getCounts(dice); | |
| if (UPPER_FACE_BY_CATEGORY[categoryId]) { | |
| const face = UPPER_FACE_BY_CATEGORY[categoryId]; | |
| return dice.map((value, index) => value === face ? index : -1).filter(index => index >= 0); | |
| } | |
| if (["threeKind", "fourKind", "yahtzee"].includes(categoryId)) { | |
| const bestFace = Object.entries(counts) | |
| .sort((a, b) => Number(b[1]) - Number(a[1]) || Number(b[0]) - Number(a[0]))[0][0]; | |
| return dice.map((value, index) => value === Number(bestFace) ? index : -1).filter(index => index >= 0); | |
| } | |
| if (categoryId === "fullHouse") { | |
| const usefulFaces = Object.entries(counts) | |
| .filter(([, count]) => count >= 2) | |
| .sort((a, b) => Number(b[1]) - Number(a[1])) | |
| .slice(0, 2) | |
| .map(([face]) => Number(face)); | |
| if (usefulFaces.length === 0) { | |
| const bestFace = Number(Object.entries(counts).sort((a, b) => Number(b[1]) - Number(a[1]))[0][0]); | |
| usefulFaces.push(bestFace); | |
| } | |
| return dice.map((value, index) => usefulFaces.includes(value) ? index : -1).filter(index => index >= 0); | |
| } | |
| if (categoryId === "smallStraight" || categoryId === "largeStraight") { | |
| const runs = categoryId === "smallStraight" | |
| ? [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]] | |
| : [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]; | |
| const bestRun = runs | |
| .map(run => ({ | |
| run, | |
| matches: new Set(dice.filter(value => run.includes(value))).size | |
| })) | |
| .sort((a, b) => b.matches - a.matches)[0].run; | |
| const heldValues = new Set(); | |
| const indexes = []; | |
| dice.forEach((value, index) => { | |
| if (bestRun.includes(value) && !heldValues.has(value)) { | |
| heldValues.add(value); | |
| indexes.push(index); | |
| } | |
| }); | |
| return indexes; | |
| } | |
| if (categoryId === "chance") { | |
| return dice.map((value, index) => value >= 4 ? index : -1).filter(index => index >= 0); | |
| } | |
| return []; | |
| } | |
| function getNextRollProbability(categoryId, dice) { | |
| if (categoryId === "chance" || state.rollsLeft <= 0) { | |
| return null; | |
| } | |
| const heldIndexes = new Set(getSuggestedHoldIndexes(categoryId, dice)); | |
| const rerollIndexes = dice | |
| .map((_, index) => index) | |
| .filter(index => !heldIndexes.has(index)); | |
| if (rerollIndexes.length === 0) { | |
| return meetsLearningTarget(categoryId, dice) ? 1 : 0; | |
| } | |
| let successes = 0; | |
| let total = 0; | |
| enumerateRolls(rerollIndexes.length, rolledValues => { | |
| const candidate = [...dice]; | |
| rerollIndexes.forEach((dieIndex, valueIndex) => { | |
| candidate[dieIndex] = rolledValues[valueIndex]; | |
| }); | |
| total += 1; | |
| if (meetsLearningTarget(categoryId, candidate)) { | |
| successes += 1; | |
| } | |
| }); | |
| return successes / total; | |
| } | |
| function getHoldDescription(categoryId, dice) { | |
| if (categoryId === "chance") { | |
| return "Keep high dice (4–6)"; | |
| } | |
| const indexes = getSuggestedHoldIndexes(categoryId, dice); | |
| if (indexes.length === 0) { | |
| return "Reroll all five"; | |
| } | |
| const values = indexes.map(index => dice[index]); | |
| return `Hold ${values.join("–")}`; | |
| } | |
| function getCategoryAdvice(category, score) { | |
| const face = UPPER_FACE_BY_CATEGORY[category.id]; | |
| const counts = getCounts(state.dice); | |
| const rollsLeft = state.rollsLeft; | |
| const upperTotal = getUpperTotal(); | |
| const upperSlotsUsed = CATEGORY_DEFINITIONS | |
| .filter(item => item.section === "upper" && state.scores[item.id] !== null) | |
| .length; | |
| const upperPaceNeeded = Math.max(0, 63 - upperTotal); | |
| const upperSlotsLeft = 6 - upperSlotsUsed; | |
| const requiredAverage = upperSlotsLeft > 0 ? upperPaceNeeded / upperSlotsLeft : 0; | |
| let rank = score; | |
| let reason = "Legal placement, but another slot may preserve more flexibility."; | |
| let strong = false; | |
| if (face) { | |
| const count = counts[face] || 0; | |
| const benchmark = face * 3; | |
| rank = score - benchmark + (face >= 4 ? 3 : 0); | |
| if (count >= 4) { | |
| rank += 30; | |
| strong = true; | |
| reason = `Excellent upper-section result: ${count} ${category.label.toLowerCase()} strongly supports the 63-point bonus.`; | |
| } else if (count === 3) { | |
| rank += 18; | |
| strong = true; | |
| reason = `On bonus pace: three of this number is the standard ${benchmark}-point benchmark.`; | |
| } else if (score >= requiredAverage && score > 0) { | |
| rank += 8; | |
| reason = `Useful for the upper bonus; you currently need about ${requiredAverage.toFixed(1)} points per remaining upper slot.`; | |
| } else if (score === 0) { | |
| rank -= face >= 5 ? 30 : face * 2; | |
| reason = rollsLeft > 0 | |
| ? "A zero here is premature while rerolls remain." | |
| : "A zero may be necessary, but high-number upper slots are expensive to sacrifice."; | |
| } else { | |
| reason = `Below the three-of-a-number bonus benchmark (${benchmark}), so this is usually a fallback.`; | |
| } | |
| } else { | |
| switch (category.id) { | |
| case "yahtzee": | |
| rank = score === 50 ? 120 : -45; | |
| strong = score === 50; | |
| reason = score === 50 | |
| ? "Take it. Yahtzee is both maximum value and extremely difficult to reproduce." | |
| : "Do not burn Yahtzee for zero while a safer sacrifice exists."; | |
| break; | |
| case "largeStraight": | |
| rank = score === 40 ? 95 : -28; | |
| strong = score === 40; | |
| reason = score === 40 | |
| ? "Maximum fixed score and hard to reproduce. This is an excellent lock-in." | |
| : "Keep this slot alive unless the scorecard leaves no cheaper sacrifice."; | |
| break; | |
| case "smallStraight": | |
| rank = score === 30 ? 65 : -14; | |
| strong = score === 30; | |
| reason = score === 30 | |
| ? "Maximum fixed score. Usually worth banking now." | |
| : "A useful category to preserve while rerolls remain."; | |
| break; | |
| case "fullHouse": | |
| rank = score === 25 ? 58 : -12; | |
| strong = score === 25; | |
| reason = score === 25 | |
| ? "Maximum fixed score. Banking the full house is normally correct." | |
| : "A fixed 25-point category is worth preserving when possible."; | |
| break; | |
| case "fourKind": | |
| rank = score > 0 ? score + (score >= 24 ? 30 : 12) : -18; | |
| strong = score >= 24; | |
| reason = score > 0 | |
| ? score >= 24 | |
| ? "Strong four-of-a-kind total. This is difficult to improve meaningfully." | |
| : "Valid, but low totals can be worth rerolling if you still have time." | |
| : "Four of a Kind is difficult; avoid sacrificing it early."; | |
| break; | |
| case "threeKind": | |
| rank = score > 0 ? score + (score >= 20 ? 18 : 5) : -6; | |
| strong = score >= 20; | |
| reason = score > 0 | |
| ? score >= 20 | |
| ? "A solid three-of-a-kind total that preserves Chance." | |
| : "Playable, but modest; consider rerolling if stronger paths are open." | |
| : "This is one of the less painful lower slots to sacrifice late."; | |
| break; | |
| case "chance": | |
| rank = score - 9; | |
| if (score >= 24) { | |
| rank += 16; | |
| strong = true; | |
| reason = "A high Chance score is useful, especially when no rarer category is completed."; | |
| } else { | |
| reason = "Chance is flexible. Avoid spending it on a mediocre total unless you need the escape hatch."; | |
| } | |
| break; | |
| } | |
| } | |
| if (rollsLeft > 0 && !strong) { | |
| rank -= 18; | |
| } | |
| return { rank, reason, strong }; | |
| } | |
| function getRecommendation(previews) { | |
| if (!state.hasRolled || state.isRolling) { | |
| return null; | |
| } | |
| const candidates = CATEGORY_DEFINITIONS | |
| .filter(category => state.scores[category.id] === null) | |
| .map(category => ({ | |
| category, | |
| score: previews[category.id], | |
| ...getCategoryAdvice(category, previews[category.id]) | |
| })) | |
| .sort((a, b) => b.rank - a.rank); | |
| const best = candidates[0]; | |
| if (!best) { | |
| return null; | |
| } | |
| // With rerolls remaining, only recommend scoring genuinely strong results. | |
| if (state.rollsLeft > 0 && !best.strong) { | |
| return null; | |
| } | |
| return best; | |
| } | |
| function buildStrategyTooltip(category, score, advice) { | |
| const baseOdds = getBaseOdds(category.id); | |
| const nextRollProbability = state.hasRolled | |
| ? getNextRollProbability(category.id, state.dice) | |
| : null; | |
| const tooltip = document.createElement("div"); | |
| tooltip.className = "strategy-tooltip"; | |
| tooltip.setAttribute("role", "tooltip"); | |
| const title = document.createElement("div"); | |
| title.className = "tooltip-title"; | |
| title.textContent = `${category.label} strategy`; | |
| const currentLine = document.createElement("div"); | |
| currentLine.className = "tooltip-line"; | |
| currentLine.innerHTML = `<span>Current score</span><strong>${state.hasRolled ? score : "Roll first"}</strong>`; | |
| const oddsLine = document.createElement("div"); | |
| oddsLine.className = "tooltip-line"; | |
| oddsLine.innerHTML = category.id === "chance" | |
| ? "<span>Random-hand average</span><strong>17.5 pts</strong>" | |
| : `<span>One-roll base chance</span><strong>${formatPercent(baseOdds)}</strong>`; | |
| const nextLine = document.createElement("div"); | |
| nextLine.className = "tooltip-line"; | |
| nextLine.innerHTML = state.hasRolled && state.rollsLeft > 0 | |
| ? `<span>Next-roll target chance</span><strong>${formatPercent(nextRollProbability)}</strong>` | |
| : `<span>Suggested hold</span><strong>${state.hasRolled ? "No rolls left" : "Shown after roll"}</strong>`; | |
| const holdLine = document.createElement("div"); | |
| holdLine.className = "tooltip-line"; | |
| holdLine.innerHTML = `<span>Learning hint</span><strong>${state.hasRolled && state.rollsLeft > 0 ? getHoldDescription(category.id, state.dice) : category.help}</strong>`; | |
| const adviceText = document.createElement("div"); | |
| adviceText.className = `tooltip-advice${advice.strong ? " good" : ""}`; | |
| adviceText.textContent = advice.reason; | |
| tooltip.append(title, currentLine, oddsLine, nextLine, holdLine, adviceText); | |
| return tooltip; | |
| } | |
| function getUsedCategoryCount() { | |
| return Object.values(state.scores).filter(score => score !== null).length; | |
| } | |
| function getUpperTotal() { | |
| return CATEGORY_DEFINITIONS | |
| .filter(category => category.section === "upper") | |
| .reduce((total, category) => total + (state.scores[category.id] || 0), 0); | |
| } | |
| function getBonus() { | |
| return getUpperTotal() >= 63 ? 35 : 0; | |
| } | |
| function getGrandTotal() { | |
| return Object.values(state.scores).reduce((total, score) => total + (score || 0), 0) + getBonus(); | |
| } | |
| function createDie(index) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "die-wrap"; | |
| wrap.dataset.index = String(index); | |
| wrap.dataset.held = String(state.held[index]); | |
| const die = document.createElement("button"); | |
| const questionMark = document.createElement("span"); | |
| questionMark.className = "die-question"; | |
| questionMark.textContent = "?"; | |
| die.appendChild(questionMark); | |
| die.type = "button"; | |
| die.className = "die"; | |
| die.setAttribute("aria-label", `Die ${index + 1}: ${state.dice[index]}`); | |
| die.addEventListener("click", () => toggleHold(index)); | |
| for (let row = 1; row <= 3; row += 1) { | |
| for (let column = 1; column <= 3; column += 1) { | |
| const pip = document.createElement("span"); | |
| pip.className = "pip"; | |
| pip.style.gridRow = String(row); | |
| pip.style.gridColumn = String(column); | |
| die.appendChild(pip); | |
| } | |
| } | |
| const holdLabel = document.createElement("div"); | |
| holdLabel.className = "hold-label"; | |
| holdLabel.textContent = "Held"; | |
| wrap.append(die, holdLabel); | |
| return wrap; | |
| } | |
| function getVisiblePipIndexes(value) { | |
| if (value === null) { | |
| return []; | |
| } | |
| const map = { | |
| 1: [4], | |
| 2: [0, 8], | |
| 3: [0, 4, 8], | |
| 4: [0, 2, 6, 8], | |
| 5: [0, 2, 4, 6, 8], | |
| 6: [0, 2, 3, 5, 6, 8] | |
| }; | |
| return map[value]; | |
| } | |
| function renderDice() { | |
| if (diceRow.children.length !== 5) { | |
| diceRow.innerHTML = ""; | |
| for (let index = 0; index < 5; index += 1) { | |
| diceRow.appendChild(createDie(index)); | |
| } | |
| } | |
| [...diceRow.children].forEach((wrap, index) => { | |
| const die = wrap.querySelector(".die"); | |
| const value = state.dice[index]; | |
| const visiblePips = new Set( | |
| value === null ? [] : getVisiblePipIndexes(value) | |
| ); | |
| const questionMark = die.querySelector(".die-question"); | |
| questionMark.style.opacity = value === null ? "1" : "0"; | |
| wrap.dataset.held = String(state.held[index]); | |
| const canHold = state.hasRolled && !state.isRolling; | |
| die.classList.toggle("held", state.held[index]); | |
| die.classList.toggle( | |
| "rolling", | |
| state.isRolling && !state.held[index] | |
| ); | |
| die.classList.toggle("hold-disabled", !canHold); | |
| die.setAttribute("aria-disabled", String(!canHold)); | |
| die.setAttribute( | |
| "aria-label", | |
| value === null | |
| ? `Die ${index + 1}: not rolled` | |
| : `Die ${index + 1}: ${value}${state.held[index] ? ", held" : ""}` | |
| ); | |
| [...die.querySelectorAll(".pip")].forEach((pip, pipIndex) => { | |
| pip.classList.toggle("visible", visiblePips.has(pipIndex)); | |
| }); | |
| }); | |
| } | |
| function renderScorecard() { | |
| const previews = Object.fromEntries( | |
| CATEGORY_DEFINITIONS.map(category => [ | |
| category.id, | |
| state.hasRolled ? calculateScore(category.id, state.dice) : null | |
| ]) | |
| ); | |
| const recommendation = getRecommendation(previews); | |
| scoreList.innerHTML = ""; | |
| scoreList.classList.toggle("strategy-off", !state.strategyEnabled); | |
| for (const section of ["upper", "lower"]) { | |
| const sectionLabel = document.createElement("div"); | |
| sectionLabel.className = "section-label"; | |
| sectionLabel.textContent = section === "upper" ? "Upper Deck" : "Lower Deck"; | |
| scoreList.appendChild(sectionLabel); | |
| CATEGORY_DEFINITIONS | |
| .filter(category => category.section === section) | |
| .forEach(category => { | |
| const usedScore = state.scores[category.id]; | |
| const currentScore = previews[category.id] ?? 0; | |
| const advice = state.hasRolled | |
| ? getCategoryAdvice(category, currentScore) | |
| : { | |
| rank: 0, | |
| strong: false, | |
| reason: category.section === "upper" | |
| ? "Three of each number keeps you on pace for the 63-point upper bonus." | |
| : "Hover after a roll for a current-hand recommendation." | |
| }; | |
| const row = document.createElement("div"); | |
| row.className = "score-row"; | |
| row.dataset.category = category.id; | |
| const isUsed = usedScore !== null; | |
| const isLocked = !state.hasRolled || state.isRolling; | |
| const isRecommended = state.strategyEnabled | |
| && recommendation?.category.id === category.id | |
| && !isUsed; | |
| row.classList.toggle("used", isUsed); | |
| row.classList.toggle("locked", !isUsed && isLocked); | |
| row.classList.toggle("recommended", isRecommended); | |
| const textWrap = document.createElement("div"); | |
| const name = document.createElement("div"); | |
| const help = document.createElement("div"); | |
| name.className = "category-name"; | |
| help.className = "category-help"; | |
| name.textContent = category.label; | |
| if (isRecommended) { | |
| const badge = document.createElement("span"); | |
| badge.className = "recommendation-badge"; | |
| badge.textContent = "Recommended"; | |
| name.appendChild(badge); | |
| } | |
| help.textContent = category.help; | |
| textWrap.append(name, help); | |
| const scoreValue = document.createElement("div"); | |
| scoreValue.className = "score-value"; | |
| if (isUsed) { | |
| scoreValue.textContent = String(usedScore); | |
| } else if (state.hasRolled) { | |
| scoreValue.classList.add("preview"); | |
| scoreValue.textContent = String(currentScore); | |
| } else { | |
| scoreValue.textContent = "—"; | |
| } | |
| row.append(textWrap, scoreValue); | |
| if (!isUsed) { | |
| row.appendChild(buildStrategyTooltip(category, currentScore, advice)); | |
| } | |
| if (!isUsed && state.hasRolled && !state.isRolling) { | |
| row.setAttribute("role", "button"); | |
| row.setAttribute("tabindex", "0"); | |
| row.setAttribute("aria-label", `Score ${category.label} for ${currentScore} points${isRecommended ? ", recommended" : ""}`); | |
| row.addEventListener("click", () => chooseCategory(category.id)); | |
| row.addEventListener("keydown", event => { | |
| if (event.key === "Enter" || event.key === " ") { | |
| event.preventDefault(); | |
| chooseCategory(category.id); | |
| } | |
| }); | |
| } | |
| scoreList.appendChild(row); | |
| }); | |
| } | |
| } | |
| function renderSummary() { | |
| const used = getUsedCategoryCount(); | |
| const round = Math.min(used + 1, 13); | |
| const upper = getUpperTotal(); | |
| const total = getGrandTotal(); | |
| roundLabel.textContent = used >= 13 ? "Game Complete" : `Round ${round} of 13`; | |
| rollsLeftEl.textContent = String(state.rollsLeft); | |
| upperTotalEl.textContent = String(upper); | |
| bonusValueEl.textContent = String(getBonus()); | |
| grandTotalEl.textContent = String(total); | |
| highScoreLabel.textContent = `Best: ${getHighScore()}`; | |
| bonusFillEl.style.width = `${Math.min(100, (upper / 63) * 100)}%`; | |
| rollBtn.disabled = state.isRolling || state.rollsLeft <= 0 || used >= 13; | |
| rollBtn.textContent = state.isRolling | |
| ? "Rolling..." | |
| : state.hasRolled | |
| ? state.rollsLeft > 0 | |
| ? `Roll Again (${state.rollsLeft})` | |
| : "Choose a Score" | |
| : "Roll Dice"; | |
| if (state.isRolling) { | |
| statusText.textContent = "The cubes are in motion..."; | |
| } else if (!state.hasRolled) { | |
| statusText.textContent = used === 0 ? "Lock in. Roll the dice." : "New round. Make it count."; | |
| } else if (state.rollsLeft === 0) { | |
| statusText.textContent = "No rolls left. Pick a category."; | |
| } else if (state.held.some(Boolean)) { | |
| statusText.textContent = `${state.held.filter(Boolean).length} held. Send the rest.`; | |
| } else { | |
| statusText.textContent = "Hold what you want. Re-roll the rest."; | |
| } | |
| const previews = Object.fromEntries( | |
| CATEGORY_DEFINITIONS.map(category => [ | |
| category.id, | |
| state.hasRolled ? calculateScore(category.id, state.dice) : null | |
| ]) | |
| ); | |
| const recommendation = state.strategyEnabled ? getRecommendation(previews) : null; | |
| if (recommendation) { | |
| hintText.innerHTML = `<strong style="color: var(--cyan)">Guide:</strong> ${recommendation.category.label} is a strong score now. Hover its row to see why.`; | |
| } else if (state.hasRolled && state.rollsLeft > 0 && state.strategyEnabled) { | |
| hintText.innerHTML = 'No urgent score yet. Improve the hand or follow the hover hints. <span class="kbd">Space</span> rolls, <span class="kbd">1–5</span> holds.'; | |
| } else { | |
| hintText.innerHTML = state.hasRolled | |
| ? 'Choose a score. Hover unused rows for a short strategy explanation.' | |
| : 'Roll, then click dice to hold them. Keyboard: <span class="kbd">Space</span> to roll, <span class="kbd">1–5</span> to hold.'; | |
| } | |
| } | |
| function render() { | |
| renderDice(); | |
| renderScorecard(); | |
| renderSummary(); | |
| soundBtn.textContent = state.soundEnabled ? "🔊" : "🔇"; | |
| soundBtn.setAttribute("aria-label", state.soundEnabled ? "Mute sound" : "Enable sound"); | |
| strategyBtn.textContent = state.strategyEnabled ? "Hints: On" : "Hints: Off"; | |
| strategyBtn.setAttribute("aria-pressed", String(state.strategyEnabled)); | |
| } | |
| function randomDie() { | |
| return Math.floor(Math.random() * 6) + 1; | |
| } | |
| function toggleHold(index) { | |
| if (!state.hasRolled || state.isRolling || state.rollsLeft === 3) { | |
| return; | |
| } | |
| state.held[index] = !state.held[index]; | |
| playTone(state.held[index] ? 520 : 320, 0.06, "sine", 0.025); | |
| render(); | |
| } | |
| async function rollDice() { | |
| if (state.isRolling || state.rollsLeft <= 0 || getUsedCategoryCount() >= 13) { | |
| return; | |
| } | |
| state.isRolling = true; | |
| state.hasRolled = true; | |
| state.rollsLeft -= 1; | |
| playRollSound(); | |
| render(); | |
| const shuffleTimer = window.setInterval(() => { | |
| state.dice = state.dice.map((value, index) => state.held[index] ? value : randomDie()); | |
| renderDice(); | |
| }, 85); | |
| await new Promise(resolve => window.setTimeout(resolve, 680)); | |
| window.clearInterval(shuffleTimer); | |
| state.dice = state.dice.map((value, index) => state.held[index] ? value : randomDie()); | |
| state.isRolling = false; | |
| render(); | |
| const yahtzeeScore = calculateScore("yahtzee", state.dice); | |
| if (yahtzeeScore === 50 && state.scores.yahtzee === null) { | |
| showToast("YAHTZEE detected. Fifty points are waiting."); | |
| playFanfare(); | |
| burstParticles(window.innerWidth * 0.5, window.innerHeight * 0.45, 32); | |
| } | |
| } | |
| function chooseCategory(categoryId) { | |
| if (!state.hasRolled || state.isRolling || state.scores[categoryId] !== null) { | |
| return; | |
| } | |
| const category = CATEGORY_DEFINITIONS.find(item => item.id === categoryId); | |
| const score = calculateScore(categoryId, state.dice); | |
| state.scores[categoryId] = score; | |
| playScoreSound(score); | |
| showToast(`${category.label}: ${score} point${score === 1 ? "" : "s"}.`); | |
| if (score >= 40) { | |
| const row = document.querySelector(`[data-category="${categoryId}"]`); | |
| const rect = row?.getBoundingClientRect(); | |
| burstParticles( | |
| rect ? rect.left + rect.width / 2 : window.innerWidth * 0.75, | |
| rect ? rect.top + rect.height / 2 : window.innerHeight * 0.4, | |
| score >= 50 ? 44 : 26 | |
| ); | |
| } | |
| if (getUsedCategoryCount() >= 13) { | |
| finishGame(); | |
| return; | |
| } | |
| state.dice = [null, null, null, null, null]; | |
| state.held = [false, false, false, false, false]; | |
| state.rollsLeft = 3; | |
| state.hasRolled = false; | |
| render(); | |
| } | |
| function finishGame() { | |
| const total = getGrandTotal(); | |
| const previousHigh = getHighScore(); | |
| let message = "Steel nerves. Solid run."; | |
| if (total > previousHigh) { | |
| setHighScore(total); | |
| message = "New high score. The machine approves."; | |
| burstParticles(window.innerWidth / 2, window.innerHeight / 2, 70); | |
| playFanfare(); | |
| } else if (total >= 250) { | |
| message = "That was ruthless. Strong score."; | |
| } else if (total < 150) { | |
| message = "The dice won this round. Rematch them."; | |
| } | |
| modalScore.textContent = String(total); | |
| modalText.textContent = message; | |
| modalBackdrop.classList.add("open"); | |
| render(); | |
| } | |
| function resetGame() { | |
| state.dice = [null, null, null, null, null]; | |
| state.held = [false, false, false, false, false]; | |
| state.rollsLeft = 3; | |
| state.hasRolled = false; | |
| state.isRolling = false; | |
| state.scores = Object.fromEntries(CATEGORY_DEFINITIONS.map(({ id }) => [id, null])); | |
| modalBackdrop.classList.remove("open"); | |
| render(); | |
| showToast("Fresh scorecard. No excuses."); | |
| } | |
| function showToast(message) { | |
| const toast = document.createElement("div"); | |
| toast.className = "toast"; | |
| toast.textContent = message; | |
| toastStack.appendChild(toast); | |
| window.setTimeout(() => toast.remove(), 2900); | |
| } | |
| function burstParticles(x, y, count) { | |
| const fragment = document.createDocumentFragment(); | |
| for (let index = 0; index < count; index += 1) { | |
| const particle = document.createElement("div"); | |
| const angle = Math.random() * Math.PI * 2; | |
| const distance = 60 + Math.random() * 180; | |
| particle.className = "particle"; | |
| particle.style.left = `${x}px`; | |
| particle.style.top = `${y}px`; | |
| particle.style.setProperty("--dx", `${Math.cos(angle) * distance}px`); | |
| particle.style.setProperty("--dy", `${Math.sin(angle) * distance}px`); | |
| particle.style.background = Math.random() > 0.25 ? "var(--cyan)" : "var(--gold)"; | |
| particle.style.width = `${4 + Math.random() * 7}px`; | |
| particle.style.height = particle.style.width; | |
| fragment.appendChild(particle); | |
| window.setTimeout(() => particle.remove(), 1000); | |
| } | |
| document.body.appendChild(fragment); | |
| } | |
| function ensureAudioContext() { | |
| if (!state.soundEnabled) { | |
| return null; | |
| } | |
| if (!audioContext) { | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| if (!AudioContextClass) { | |
| return null; | |
| } | |
| audioContext = new AudioContextClass(); | |
| } | |
| if (audioContext.state === "suspended") { | |
| audioContext.resume(); | |
| } | |
| return audioContext; | |
| } | |
| function playTone(frequency, duration, wave = "sine", volume = 0.04, delay = 0) { | |
| const context = ensureAudioContext(); | |
| if (!context) { | |
| return; | |
| } | |
| const oscillator = context.createOscillator(); | |
| const gain = context.createGain(); | |
| const start = context.currentTime + delay; | |
| const end = start + duration; | |
| oscillator.type = wave; | |
| oscillator.frequency.setValueAtTime(frequency, start); | |
| gain.gain.setValueAtTime(0.0001, start); | |
| gain.gain.exponentialRampToValueAtTime(volume, start + 0.012); | |
| gain.gain.exponentialRampToValueAtTime(0.0001, end); | |
| oscillator.connect(gain); | |
| gain.connect(context.destination); | |
| oscillator.start(start); | |
| oscillator.stop(end + 0.03); | |
| } | |
| function playRollSound() { | |
| [120, 165, 205, 145, 250].forEach((frequency, index) => { | |
| playTone(frequency, 0.09, "square", 0.018, index * 0.055); | |
| }); | |
| } | |
| function playScoreSound(score) { | |
| const base = score === 0 ? 140 : 360 + Math.min(score, 50) * 5; | |
| playTone(base, 0.12, score === 0 ? "sawtooth" : "triangle", 0.035); | |
| if (score >= 25) { | |
| playTone(base * 1.25, 0.18, "sine", 0.028, 0.08); | |
| } | |
| } | |
| function playFanfare() { | |
| [523.25, 659.25, 783.99, 1046.5].forEach((frequency, index) => { | |
| playTone(frequency, 0.24, "triangle", 0.04, index * 0.1); | |
| }); | |
| } | |
| rollBtn.addEventListener("click", rollDice); | |
| strategyBtn.addEventListener("click", () => { | |
| state.strategyEnabled = !state.strategyEnabled; | |
| localStorage.setItem("neon-yahtzee-strategy", state.strategyEnabled ? "on" : "off"); | |
| render(); | |
| showToast(state.strategyEnabled | |
| ? "Strategy hints enabled. Hover unused score rows." | |
| : "Strategy hints disabled."); | |
| }); | |
| newGameBtn.addEventListener("click", resetGame); | |
| playAgainBtn.addEventListener("click", resetGame); | |
| soundBtn.addEventListener("click", () => { | |
| state.soundEnabled = !state.soundEnabled; | |
| render(); | |
| if (state.soundEnabled) { | |
| playTone(660, 0.08, "sine", 0.03); | |
| } | |
| }); | |
| document.addEventListener("keydown", event => { | |
| const target = event.target; | |
| if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { | |
| return; | |
| } | |
| if (event.code === "Space") { | |
| event.preventDefault(); | |
| rollDice(); | |
| return; | |
| } | |
| const index = Number(event.key) - 1; | |
| if (index >= 0 && index < 5) { | |
| toggleHold(index); | |
| } | |
| }); | |
| render(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment