a mobile friendly version of eight neighbors with a gemini assist instead of claude
A Pen by Brad P. Taylor on CodePen.
a mobile friendly version of eight neighbors with a gemini assist instead of claude
A Pen by Brad P. Taylor on CodePen.
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Eight Neighbors — Powered by Unboxed</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.min.js"></script> | |
| <style> | |
| /* --- CSS Variables and Basic Reset --- */ | |
| :root { | |
| --primary-color: #4CAF50; | |
| --secondary-color: #007bff; | |
| --danger-color: #f44336; | |
| --background-color: #000033; | |
| --light-text: #ffffff; | |
| --dark-text: #333333; | |
| --overlay-bg: rgba(0, 0, 0, 0.85); | |
| --win-overlay-bg: rgba(20, 100, 20, 0.9); | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| background-color: var(--background-color); | |
| color: var(--light-text); | |
| position: fixed; | |
| width: 100%; | |
| height: 100%; | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| -webkit-touch-callout: none; | |
| } | |
| /* --- Screen Management --- */ | |
| .screen { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| display: none; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| box-sizing: border-box; | |
| background-color: var(--background-color); | |
| transition: opacity 0.5s ease; | |
| opacity: 0; | |
| } | |
| .screen.active { | |
| display: flex; | |
| opacity: 1; | |
| } | |
| /* --- Splash Screen Styling --- */ | |
| #splash-screen { | |
| background: radial-gradient(circle at center, #6b46c1 0%, #3b82f6 25%, #6b46c1 50%, #3b82f6 75%, #6b46c1 100%); | |
| background-size: 100% 100%; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #splash-screen::before { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| width: 200%; height: 200%; | |
| background: conic-gradient( | |
| from 0deg, | |
| #6b46c1 0deg 22.5deg, #3b82f6 22.5deg 45deg, | |
| #6b46c1 45deg 67.5deg, #3b82f6 67.5deg 90deg, | |
| #6b46c1 90deg 112.5deg, #3b82f6 112.5deg 135deg, | |
| #6b46c1 135deg 157.5deg, #3b82f6 157.5deg 180deg, | |
| #6b46c1 180deg 202.5deg, #3b82f6 202.5deg 225deg, | |
| #6b46c1 225deg 247.5deg, #3b82f6 247.5deg 270deg, | |
| #6b46c1 270deg 292.5deg, #3b82f6 292.5deg 315deg, | |
| #6b46c1 315deg 337.5deg, #3b82f6 337.5deg 360deg | |
| ); | |
| transform: translate(-50%, -50%); | |
| z-index: 1; | |
| animation: rotate 20s linear infinite; | |
| } | |
| @keyframes rotate { | |
| from { transform: translate(-50%, -50%) rotate(0deg); } | |
| to { transform: translate(-50%, -50%) rotate(360deg); } | |
| } | |
| #splash-3d-container { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| z-index: 2; | |
| pointer-events: none; | |
| } | |
| .splash-content { | |
| position: relative; | |
| z-index: 10; | |
| text-align: center; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| width: 100%; | |
| max-width: 500px; | |
| } | |
| .title-container { | |
| text-align: center; | |
| margin-bottom: 40px; | |
| position: relative; | |
| z-index: 10; | |
| width: 100%; | |
| } | |
| .splash-title { | |
| font-size: clamp(2.5rem, 10vw, 4.5rem); | |
| font-weight: 900; | |
| color: #ffffff; | |
| text-shadow: 4px 4px 0px #000, -4px -4px 0px #000, | |
| 4px -4px 0px #000, -4px 4px 0px #000, | |
| 6px 6px 10px rgba(0, 0, 0, 0.8); | |
| letter-spacing: 0.05em; | |
| margin: 0; | |
| line-height: 0.9; | |
| } | |
| .splash-subtitle { | |
| font-size: clamp(1rem, 4vw, 1.5rem); | |
| color: #ffffff; | |
| margin-top: 10px; | |
| margin-bottom: 0; | |
| text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); | |
| font-weight: 600; | |
| } | |
| .menu-container { | |
| width: 100%; | |
| max-width: 380px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 15px; | |
| position: relative; | |
| z-index: 10; | |
| margin: 0 auto; | |
| } | |
| .btn { | |
| padding: 18px 24px; | |
| border-radius: 15px; | |
| border: 4px solid #000; | |
| color: #fff; | |
| cursor: pointer; | |
| font-size: 1.4rem; | |
| font-weight: 900; | |
| text-transform: uppercase; | |
| transition: transform 0.2s ease, background-color 0.3s ease, border-color 0.3s ease; | |
| text-shadow: 2px 2px 0px #000; | |
| letter-spacing: 0.1em; | |
| position: relative; | |
| overflow: hidden; | |
| pointer-events: all; | |
| width: 100%; | |
| max-width: 280px; | |
| text-align: center; | |
| } | |
| .btn:hover { transform: translateY(-3px) scale(1.05); } | |
| .btn:active { transform: scale(0.96) translateY(0); } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); | |
| box-shadow: 0 6px 0 #92400e, 0 8px 15px rgba(0, 0, 0, 0.3); | |
| } | |
| .btn-primary:hover { box-shadow: 0 8px 0 #92400e, 0 12px 20px rgba(0, 0, 0, 0.4); } | |
| .btn-secondary { | |
| background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); | |
| box-shadow: 0 6px 0 #1e3a8a, 0 8px 15px rgba(0, 0, 0, 0.3); | |
| } | |
| .btn-secondary:hover { box-shadow: 0 8px 0 #1e3a8a, 0 12px 20px rgba(0, 0, 0, 0.4); } | |
| .btn-danger { | |
| background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); | |
| box-shadow: 0 6px 0 #991b1b, 0 8px 15px rgba(0, 0, 0, 0.3); | |
| } | |
| .btn-danger:hover { box-shadow: 0 8px 0 #991b1b, 0 12px 20px rgba(0, 0, 0, 0.4); } | |
| /* Floating CSS Shapes */ | |
| .floating-shapes { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| pointer-events: none; | |
| z-index: 3; | |
| } | |
| .floating-shape { | |
| position: absolute; | |
| animation: float 8s ease-in-out infinite; | |
| opacity: 0.6; | |
| } | |
| .floating-shape:nth-child(2n) { animation-direction: reverse; animation-duration: 10s; } | |
| .floating-shape:nth-child(3n) { animation-delay: -3s; } | |
| .floating-shape:nth-child(4n) { animation-delay: -6s; } | |
| @keyframes float { | |
| 0%, 100% { transform: translateY(0px) rotate(0deg) scale(1); } | |
| 33% { transform: translateY(-30px) rotate(120deg) scale(1.1); } | |
| 66% { transform: translateY(15px) rotate(240deg) scale(0.9); } | |
| } | |
| .shape-circle { width: 40px; height: 40px; border-radius: 50%; border: 6px solid; } | |
| .shape-square { width: 35px; height: 35px; border: 6px solid; border-radius: 6px; } | |
| .shape-triangle { width: 0; height: 0; border-left: 20px solid transparent; border-right: 20px solid transparent; border-bottom: 35px solid; } | |
| .color-red { border-color: #ef4444; } | |
| .color-blue { border-color: #3b82f6; } | |
| .color-green { border-color: #10b981; } | |
| .color-yellow { border-color: #fbbf24; } | |
| .color-purple { border-color: #8b5cf6; } | |
| .color-orange { border-color: #f97316; } | |
| .color-red.shape-triangle { border-bottom-color: #ef4444; border-left-color: transparent; border-right-color: transparent; } | |
| .color-blue.shape-triangle { border-bottom-color: #3b82f6; border-left-color: transparent; border-right-color: transparent; } | |
| .color-green.shape-triangle { border-bottom-color: #10b981; border-left-color: transparent; border-right-color: transparent; } | |
| /* --- Settings Screen Specific --- */ | |
| #settings-screen .menu-container { | |
| background-color: rgba(0, 0, 0, 0.8); | |
| padding: 30px; | |
| border-radius: 15px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| border: 4px solid #ffffff; | |
| } | |
| .setting-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 15px; | |
| width: 100%; | |
| } | |
| .setting-item label { font-size: 1.1rem; font-weight: 600; } | |
| .setting-item select { | |
| padding: 10px; | |
| border-radius: 8px; | |
| border: 2px solid #000; | |
| background-color: #fff; | |
| color: #000; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| width: 100px; | |
| text-align: center; | |
| } | |
| /* --- Game Screen & UI --- */ | |
| #game-screen { padding: 0; cursor: default; } | |
| #container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } | |
| #game-ui { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; | |
| z-index: 100; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| padding: 20px; | |
| pointer-events: none; | |
| } | |
| .rotation-controls { | |
| display: flex; | |
| flex-direction: row; | |
| gap: 10px; | |
| pointer-events: all; | |
| } | |
| .rotate-btn { | |
| width: 50px; height: 50px; | |
| border-radius: 50%; | |
| background-color: rgba(0, 0, 0, 0.7); | |
| color: var(--light-text); | |
| border: 2px solid rgba(255, 255, 255, 0.3); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.5rem; | |
| font-weight: bold; | |
| transition: all 0.2s ease; | |
| text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); | |
| } | |
| .rotate-btn:hover { transform: scale(1.1); background-color: rgba(0, 0, 0, 0.9); border-color: rgba(255, 255, 255, 0.6); } | |
| .rotate-btn:active { transform: scale(0.95); } | |
| .rotate-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } | |
| #game-menu-btn, #game-score { | |
| pointer-events: all; | |
| font-size: clamp(1.2rem, 5vw, 1.8rem); | |
| font-weight: bold; | |
| color: var(--light-text); | |
| background-color: rgba(0, 0, 0, 0.5); | |
| padding: 10px 18px; | |
| border-radius: 12px; | |
| text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); | |
| transition: transform 0.2s ease; | |
| } | |
| #game-menu-btn:hover { transform: scale(1.05); } | |
| #game-menu-btn { cursor: pointer; } | |
| /* --- Overlays (Game Over / Win) --- */ | |
| .overlay { | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%) scale(0.9); | |
| width: 90%; max-width: 400px; | |
| background-color: var(--overlay-bg); | |
| padding: 30px; | |
| border-radius: 20px; | |
| text-align: center; | |
| z-index: 200; | |
| box-shadow: 0 0 30px rgba(0, 0, 0, 0.5); | |
| display: none; | |
| opacity: 0; | |
| transition: transform 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28), opacity 0.3s ease; | |
| } | |
| .overlay.visible { | |
| display: block; | |
| opacity: 1; | |
| transform: translate(-50%, -50%) scale(1); | |
| } | |
| #gameWin { background-color: var(--win-overlay-bg); } | |
| .overlay h2 { margin-top: 0; font-size: clamp(1.8rem, 8vw, 2.5rem); } | |
| .overlay p { font-size: clamp(1rem, 4vw, 1.2rem); margin: 20px 0; } | |
| .overlay .btn { margin-top: 10px; width: 100%; } | |
| /* --- Level Transitions: progression controls on win/lose overlays --- */ | |
| .progression-row { | |
| display: grid; | |
| grid-template-columns: 44px 1fr 44px; | |
| gap: 8px; | |
| align-items: center; | |
| margin-top: 14px; | |
| } | |
| .progression-step-btn { | |
| width: 44px; height: 44px; | |
| border-radius: 12px; | |
| background: rgba(255,255,255,0.12); | |
| color: #fff; | |
| border: 2px solid rgba(255,255,255,0.35); | |
| font-size: 1.3rem; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: transform 0.15s ease, background 0.2s ease, opacity 0.2s ease; | |
| display: flex; align-items: center; justify-content: center; | |
| padding: 0; | |
| } | |
| .progression-step-btn:hover:not(:disabled) { | |
| background: rgba(255,255,255,0.25); | |
| transform: scale(1.05); | |
| } | |
| .progression-step-btn:active:not(:disabled) { transform: scale(0.95); } | |
| .progression-step-btn:disabled { opacity: 0.3; cursor: not-allowed; } | |
| .progression-shape-select { | |
| width: 100%; | |
| padding: 12px 10px; | |
| background: rgba(255,255,255,0.95); | |
| color: #1a1a1a; | |
| border: 2px solid rgba(255,255,255,0.35); | |
| border-radius: 12px; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| font-family: inherit; | |
| text-align: center; | |
| cursor: pointer; | |
| } | |
| .progression-hint { | |
| margin: 10px 0 0; | |
| font-size: 0.85rem; | |
| color: rgba(255,255,255,0.65); | |
| font-weight: 500; | |
| } | |
| .progression-hint .next-size { | |
| color: #fbbf24; | |
| font-weight: bold; | |
| } | |
| #gameWin .progression-hint .next-size { color: #fde68a; } | |
| /* --- Sound toggle button --- */ | |
| #sound-toggle-btn { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| width: 48px; height: 48px; | |
| border-radius: 50%; | |
| background: rgba(0,0,0,0.6); | |
| border: 2px solid rgba(255,255,255,0.3); | |
| color: #fff; | |
| font-size: 1.3rem; | |
| cursor: pointer; | |
| z-index: 50; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s; | |
| } | |
| #sound-toggle-btn:hover { background: rgba(0,0,0,0.8); border-color: rgba(255,255,255,0.6); } | |
| #sound-toggle-btn.muted { opacity: 0.5; } | |
| /* --- Toast --- */ | |
| #game-toast { | |
| position: fixed; bottom: 80px; left: 50%; | |
| transform: translateX(-50%) translateY(60px); | |
| background: rgba(15, 15, 30, 0.95); | |
| border: 1px solid #fbbf24; | |
| box-shadow: 0 0 25px rgba(251, 191, 36, 0.4); | |
| padding: 12px 22px; | |
| border-radius: 30px; | |
| display: flex; align-items: center; gap: 10px; | |
| color: #fff; | |
| opacity: 0; | |
| transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
| pointer-events: none; | |
| z-index: 300; | |
| } | |
| #game-toast.visible { transform: translateX(-50%) translateY(0); opacity: 1; } | |
| #game-toast .toast-icon { font-size: 1.4rem; } | |
| #game-toast .toast-text { font-size: 0.85rem; } | |
| #game-toast .toast-text strong { color: #fbbf24; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- SPLASH SCREEN --> | |
| <div id="splash-screen" class="screen active"> | |
| <div id="splash-3d-container"></div> | |
| <div class="floating-shapes"> | |
| <div class="floating-shape shape-circle color-red" style="top: 10%; left: 15%;"></div> | |
| <div class="floating-shape shape-square color-blue" style="top: 20%; right: 20%;"></div> | |
| <div class="floating-shape shape-triangle color-green" style="top: 70%; left: 10%;"></div> | |
| <div class="floating-shape shape-circle color-yellow" style="top: 80%; right: 15%;"></div> | |
| <div class="floating-shape shape-square color-purple" style="top: 15%; left: 80%;"></div> | |
| </div> | |
| <div class="splash-content"> | |
| <div class="title-container"> | |
| <h1 class="splash-title">EIGHT<br>NEIGHBORS</h1> | |
| <h2 class="splash-subtitle">Fireworks Edition</h2> | |
| </div> | |
| <div class="menu-container"> | |
| <button id="start-game-btn" class="btn btn-primary">Start Game</button> | |
| <button id="settings-btn" class="btn btn-secondary">Settings</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SETTINGS SCREEN --> | |
| <div id="settings-screen" class="screen"> | |
| <h1 class="splash-title">Settings</h1> | |
| <div class="menu-container"> | |
| <div class="setting-item"> | |
| <label for="rows-setting">Rows</label> | |
| <select id="rows-setting"> | |
| <option value="7">7</option> | |
| <option value="9">9</option> | |
| <option value="11">11</option> | |
| <option value="13">13</option> | |
| <option value="15">15</option> | |
| </select> | |
| </div> | |
| <div class="setting-item"> | |
| <label for="cols-setting">Columns</label> | |
| <select id="cols-setting"> | |
| <option value="7">7</option> | |
| <option value="9">9</option> | |
| <option value="11">11</option> | |
| <option value="13">13</option> | |
| <option value="15">15</option> | |
| </select> | |
| </div> | |
| <div class="setting-item"> | |
| <label for="template-setting">Board Shape</label> | |
| <select id="template-setting"> | |
| <option value="rectangle">Rectangle</option> | |
| <option value="diamond">Diamond</option> | |
| <option value="cross">Cross</option> | |
| <option value="circle">Circle</option> | |
| <option value="staircase">Staircase</option> | |
| <option value="corners">Corners</option> | |
| <option value="diagonalCorners">Diagonal Corners</option> | |
| <option value="triangle">Triangle</option> | |
| <option value="random">Random</option> | |
| </select> | |
| </div> | |
| <div class="setting-item"> | |
| <label for="mingroup-setting">Min Group Size</label> | |
| <select id="mingroup-setting"> | |
| <option value="2">2</option> | |
| <option value="3">3</option> | |
| <option value="4">4</option> | |
| <option value="5">5</option> | |
| </select> | |
| </div> | |
| <button id="back-to-splash-btn" class="btn btn-danger">Back</button> | |
| </div> | |
| </div> | |
| <!-- GAME SCREEN --> | |
| <div id="game-screen" class="screen"> | |
| <div id="container"></div> | |
| <div id="game-ui"> | |
| <div id="game-menu-btn">☰</div> | |
| <div class="rotation-controls"> | |
| <button id="rotate-left-btn" class="rotate-btn" title="Rotate Left (← or swipe left)">↺</button> | |
| <button id="rotate-right-btn" class="rotate-btn" title="Rotate Right (→ or swipe right)">↻</button> | |
| </div> | |
| <div id="game-score" style="visibility: hidden;">0</div> | |
| </div> | |
| <div id="gameOver" class="overlay"> | |
| <h2>Game Over!</h2> | |
| <p>No more moves available.</p> | |
| <button id="restartGame" class="btn btn-primary">Next Level</button> | |
| <div class="progression-row"> | |
| <button id="gameOver-smaller" class="progression-step-btn" aria-label="Smaller board" title="Smaller board">−</button> | |
| <select id="gameOver-shape" class="progression-shape-select" aria-label="Board shape"> | |
| <option value="rectangle">Rectangle</option> | |
| <option value="diamond">Diamond</option> | |
| <option value="cross">Cross</option> | |
| <option value="circle">Circle</option> | |
| <option value="staircase">Staircase</option> | |
| <option value="corners">Corners</option> | |
| <option value="diagonalCorners">Diagonal Corners</option> | |
| <option value="triangle">Triangle</option> | |
| <option value="random">Random</option> | |
| </select> | |
| <button id="gameOver-larger" class="progression-step-btn" aria-label="Larger board" title="Larger board">+</button> | |
| </div> | |
| <p class="progression-hint" id="gameOver-hint"> </p> | |
| </div> | |
| <div id="gameWin" class="overlay"> | |
| <h2>Level Complete!</h2> | |
| <p>You collected all the prizes!</p> | |
| <button id="nextLevel" class="btn btn-primary">Next Level</button> | |
| <div class="progression-row"> | |
| <button id="gameWin-smaller" class="progression-step-btn" aria-label="Smaller board" title="Smaller board">−</button> | |
| <select id="gameWin-shape" class="progression-shape-select" aria-label="Board shape"> | |
| <option value="rectangle">Rectangle</option> | |
| <option value="diamond">Diamond</option> | |
| <option value="cross">Cross</option> | |
| <option value="circle">Circle</option> | |
| <option value="staircase">Staircase</option> | |
| <option value="corners">Corners</option> | |
| <option value="diagonalCorners">Diagonal Corners</option> | |
| <option value="triangle">Triangle</option> | |
| <option value="random">Random</option> | |
| </select> | |
| <button id="gameWin-larger" class="progression-step-btn" aria-label="Larger board" title="Larger board">+</button> | |
| </div> | |
| <p class="progression-hint" id="gameWin-hint"> </p> | |
| </div> | |
| <div id="abandonConfirm" class="overlay"> | |
| <h2>Abandon level?</h2> | |
| <p>Your current board will be lost.</p> | |
| <button id="abandon-confirm" class="btn btn-danger">Abandon</button> | |
| <button id="abandon-cancel" class="btn btn-primary">Keep playing</button> | |
| </div> | |
| </div> | |
| <!-- Sound Toggle (visible on all screens) --> | |
| <button id="sound-toggle-btn">🔊</button> | |
| <!-- Toast (framework UIManager) --> | |
| <div id="game-toast"> | |
| <span class="toast-icon"></span> | |
| <span class="toast-text"></span> | |
| </div> | |
| <script> | |
| // ████████████████████████████████████████████████ | |
| // █ █ | |
| // █ UNBOXED ENGINE — CORE █ | |
| // █ █ | |
| // ████████████████████████████████████████████████ | |
| // ============================================ | |
| // SOUND ENGINE (Bank Pattern) | |
| // ============================================ | |
| class SoundEngine { | |
| constructor() { | |
| this.audioContext = null; | |
| this.masterGain = null; | |
| this.enabled = true; | |
| this._bank = {}; | |
| this._initAudio(); | |
| } | |
| _initAudio() { | |
| try { | |
| this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| this.masterGain = this.audioContext.createGain(); | |
| this.masterGain.gain.value = 0.25; | |
| this.masterGain.connect(this.audioContext.destination); | |
| } catch (e) { | |
| console.warn('Web Audio API not supported'); | |
| this.enabled = false; | |
| } | |
| } | |
| resume() { | |
| if (this.audioContext?.state === 'suspended') this.audioContext.resume(); | |
| } | |
| setEnabled(v) { this.enabled = v; } | |
| isEnabled() { return this.enabled; } | |
| playTone(freq, dur, wave = 'square', vol = 0.1) { | |
| if (!this.enabled || !this.audioContext) return; | |
| if (this.audioContext.state === 'suspended') return; | |
| const osc = this.audioContext.createOscillator(); | |
| const gain = this.audioContext.createGain(); | |
| const now = this.audioContext.currentTime; | |
| osc.type = wave; | |
| osc.frequency.setValueAtTime(freq, now); | |
| gain.gain.setValueAtTime(0, now); | |
| gain.gain.linearRampToValueAtTime(vol, now + 0.01); | |
| gain.gain.exponentialRampToValueAtTime(0.001, now + dur); | |
| osc.connect(gain); | |
| gain.connect(this.masterGain); | |
| osc.start(); | |
| osc.stop(now + dur); | |
| } | |
| playSweep(startFreq, endFreq, dur, wave = 'sawtooth', vol = 0.12) { | |
| if (!this.enabled || !this.audioContext) return; | |
| if (this.audioContext.state === 'suspended') return; | |
| const osc = this.audioContext.createOscillator(); | |
| const gain = this.audioContext.createGain(); | |
| const now = this.audioContext.currentTime; | |
| osc.type = wave; | |
| osc.frequency.setValueAtTime(startFreq, now); | |
| osc.frequency.exponentialRampToValueAtTime(endFreq, now + dur); | |
| gain.gain.setValueAtTime(vol, now); | |
| gain.gain.exponentialRampToValueAtTime(0.001, now + dur); | |
| osc.connect(gain); | |
| gain.connect(this.masterGain); | |
| osc.start(); | |
| osc.stop(now + dur); | |
| } | |
| playChord(freqs, dur, wave = 'square', vol = 0.06) { | |
| freqs.forEach((f, i) => setTimeout(() => this.playTone(f, dur, wave, vol), i * 50)); | |
| } | |
| register(name, fn) { this._bank[name] = fn; } | |
| registerAll(soundDefs) { | |
| for (const [name, fn] of Object.entries(soundDefs)) this._bank[name] = fn; | |
| } | |
| play(name, ...args) { | |
| const fn = this._bank[name]; | |
| if (fn) fn(this, ...args); | |
| } | |
| } | |
| const soundEngine = new SoundEngine(); | |
| // ============================================ | |
| // STORAGE ADAPTER | |
| // ============================================ | |
| class StorageAdapter { | |
| getItem(key) { throw new Error('not implemented'); } | |
| setItem(key, value) { throw new Error('not implemented'); } | |
| removeItem(key) { throw new Error('not implemented'); } | |
| keys() { throw new Error('not implemented'); } | |
| } | |
| class LocalStorageAdapter extends StorageAdapter { | |
| getItem(key) { try { return localStorage.getItem(key); } catch(e) { return null; } } | |
| setItem(key, value) { try { localStorage.setItem(key, value); } catch(e) {} } | |
| removeItem(key) { try { localStorage.removeItem(key); } catch(e) {} } | |
| keys() { try { const r=[]; for(let i=0;i<localStorage.length;i++) r.push(localStorage.key(i)); return r; } catch(e){ return []; } } | |
| } | |
| const storage = new LocalStorageAdapter(); | |
| // ============================================ | |
| // SETTINGS MANAGER | |
| // ============================================ | |
| class SettingsManager { | |
| constructor(storageKey, defaults) { | |
| this.storageKey = storageKey; | |
| this._defaults = defaults; | |
| this.data = this._load(); | |
| } | |
| _load() { | |
| try { | |
| const saved = storage.getItem(this.storageKey); | |
| if (saved) return { ...this._defaults, ...JSON.parse(saved) }; | |
| } catch(e) {} | |
| return { ...this._defaults }; | |
| } | |
| _save() { try { storage.setItem(this.storageKey, JSON.stringify(this.data)); } catch(e) {} } | |
| get(key) { return this.data[key]; } | |
| set(key, value) { this.data[key] = value; this._save(); } | |
| reset() { this.data = { ...this._defaults }; this._save(); } | |
| } | |
| // ████████████████████████████████████████████████ | |
| // █ █ | |
| // █ UNBOXED ENGINE — SERVICES █ | |
| // █ █ | |
| // ████████████████████████████████████████████████ | |
| // ============================================ | |
| // UI MANAGER | |
| // ============================================ | |
| class UIManager { | |
| constructor() { | |
| this._toastQueue = []; | |
| this._isShowingToast = false; | |
| } | |
| setupButton(id, handler) { | |
| const btn = document.getElementById(id); | |
| if (!btn) return null; | |
| btn.addEventListener('click', e => { e.stopPropagation(); handler(e); }); | |
| btn.addEventListener('touchend', e => { e.preventDefault(); e.stopPropagation(); handler(e); }); | |
| return btn; | |
| } | |
| openOverlay(id) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| el.style.display = 'block'; | |
| el.offsetHeight; | |
| el.classList.add('visible'); | |
| } | |
| closeOverlay(id) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| el.classList.remove('visible'); | |
| setTimeout(() => el.style.display = 'none', 300); | |
| } | |
| toggleOverlay(id, opts = {}) { | |
| const el = document.getElementById(id); | |
| if (!el) return false; | |
| const isOpen = el.classList.contains('visible'); | |
| if (isOpen) { this.closeOverlay(id); if (opts.onClose) opts.onClose(); } | |
| else { this.openOverlay(id); if (opts.onOpen) opts.onOpen(); } | |
| return !isOpen; | |
| } | |
| backdropClose(id, opts = {}) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| el.addEventListener('click', e => { | |
| if (e.target === el) { this.closeOverlay(id); if (opts.onClose) opts.onClose(); } | |
| }); | |
| } | |
| toast(icon, message, opts = {}) { | |
| const { elementId = 'game-toast', duration = 2500 } = opts; | |
| this._toastQueue.push({ icon, message, elementId, duration }); | |
| if (!this._isShowingToast) this._showNextToast(); | |
| } | |
| _showNextToast() { | |
| if (!this._toastQueue.length) { this._isShowingToast = false; return; } | |
| this._isShowingToast = true; | |
| const { icon, message, elementId, duration } = this._toastQueue.shift(); | |
| const el = document.getElementById(elementId); | |
| if (!el) { this._isShowingToast = false; return; } | |
| const iconEl = el.querySelector('.toast-icon'); | |
| const textEl = el.querySelector('.toast-text'); | |
| if (iconEl) iconEl.textContent = icon; | |
| if (textEl) textEl.innerHTML = message; | |
| el.classList.add('visible'); | |
| setTimeout(() => { el.classList.remove('visible'); setTimeout(() => this._showNextToast(), 300); }, duration); | |
| } | |
| floatingText(text, cssClass = '', opts = {}) { | |
| const { containerId = 'bonus-container', duration = 1500 } = opts; | |
| const container = document.getElementById(containerId); | |
| if (!container) return; | |
| const el = document.createElement('div'); | |
| el.className = `bonus-message ${cssClass}`; | |
| el.textContent = text; | |
| container.appendChild(el); | |
| setTimeout(() => el.remove(), duration); | |
| } | |
| confirm(overlayId, confirmBtnId, cancelBtnId) { | |
| return new Promise(resolve => { | |
| this.openOverlay(overlayId); | |
| const onConfirm = () => { cleanup(); this.closeOverlay(overlayId); resolve(true); }; | |
| const onCancel = () => { cleanup(); this.closeOverlay(overlayId); resolve(false); }; | |
| const confirmBtn = document.getElementById(confirmBtnId); | |
| const cancelBtn = document.getElementById(cancelBtnId); | |
| const cleanup = () => { | |
| if (confirmBtn) confirmBtn.removeEventListener('click', onConfirm); | |
| if (cancelBtn) cancelBtn.removeEventListener('click', onCancel); | |
| }; | |
| if (confirmBtn) confirmBtn.addEventListener('click', onConfirm); | |
| if (cancelBtn) cancelBtn.addEventListener('click', onCancel); | |
| }); | |
| } | |
| } | |
| const ui = new UIManager(); | |
| // ============================================ | |
| // INPUT MANAGER (with hover support) | |
| // ============================================ | |
| class InputManager { | |
| constructor(domElement, opts = {}) { | |
| this.domElement = domElement; | |
| this.tapThreshold = opts.tapThreshold || 15; | |
| this.ghostClickDelay = opts.ghostClickDelay || 500; | |
| this.excludeSelectors = opts.excludeSelectors || []; | |
| this._tapCallbacks = []; | |
| this._hoverCallbacks = []; | |
| this._lastTouchTime = 0; | |
| this._touchStartPos = null; | |
| this.hasUserInteracted = false; | |
| this._bindEvents(); | |
| } | |
| onTap(fn) { this._tapCallbacks.push(fn); } | |
| onHover(fn) { this._hoverCallbacks.push(fn); } | |
| _fireTap(clientX, clientY) { | |
| if (!this.hasUserInteracted) { | |
| this.hasUserInteracted = true; | |
| soundEngine.resume(); | |
| } | |
| for (const fn of this._tapCallbacks) fn(clientX, clientY); | |
| } | |
| _fireHover(clientX, clientY) { | |
| for (const fn of this._hoverCallbacks) fn(clientX, clientY); | |
| } | |
| _bindEvents() { | |
| window.addEventListener('click', e => { | |
| if (Date.now() - this._lastTouchTime < this.ghostClickDelay) return; | |
| for (const sel of this.excludeSelectors) { | |
| if (e.target.closest(sel)) return; | |
| } | |
| this._fireTap(e.clientX, e.clientY); | |
| }); | |
| this.domElement.addEventListener('touchstart', e => { | |
| for (const sel of this.excludeSelectors) { | |
| if (e.target.closest(sel)) return; | |
| } | |
| e.preventDefault(); | |
| this._lastTouchTime = Date.now(); | |
| this._touchStartPos = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| }, { passive: false }); | |
| this.domElement.addEventListener('touchend', e => { | |
| if (!this._touchStartPos) return; | |
| e.preventDefault(); | |
| this._lastTouchTime = Date.now(); | |
| const t = e.changedTouches[0]; | |
| const dx = t.clientX - this._touchStartPos.x; | |
| const dy = t.clientY - this._touchStartPos.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist <= this.tapThreshold) this._fireTap(t.clientX, t.clientY); | |
| this._touchStartPos = null; | |
| }, { passive: false }); | |
| window.addEventListener('mousemove', e => { | |
| for (const sel of this.excludeSelectors) { | |
| if (e.target.closest(sel)) { | |
| this._fireHover(null, null); | |
| return; | |
| } | |
| } | |
| this._fireHover(e.clientX, e.clientY); | |
| }); | |
| this.domElement.addEventListener('touchmove', e => { | |
| if (e.touches.length > 0) { | |
| const t = e.touches[0]; | |
| for (const sel of this.excludeSelectors) { | |
| if (t.target.closest(sel)) { | |
| this._fireHover(null, null); | |
| return; | |
| } | |
| } | |
| this._fireHover(t.clientX, t.clientY); | |
| } | |
| }, { passive: true }); | |
| } | |
| } | |
| // ============================================ | |
| // COLOR PALETTE | |
| // ============================================ | |
| class ColorPalette { | |
| constructor(storageKey, entries) { | |
| this.storageKey = storageKey; | |
| this.entries = entries; | |
| this.keys = entries.map(e => e.key); | |
| this._defaults = {}; | |
| for (const e of entries) this._defaults[e.key] = e.default; | |
| this.data = this._load(); | |
| } | |
| _load() { | |
| try { | |
| const saved = storage.getItem(this.storageKey); | |
| if (saved) { | |
| const parsed = JSON.parse(saved); | |
| return { colors: { ...this._defaults, ...(parsed.colors || parsed) } }; | |
| } | |
| } catch(e) {} | |
| return { colors: { ...this._defaults } }; | |
| } | |
| save() { storage.setItem(this.storageKey, JSON.stringify(this.data)); } | |
| reset() { this.data = { colors: { ...this._defaults } }; this.save(); } | |
| getColor(key) { return this.data.colors[key]; } | |
| setColor(key, val) { this.data.colors[key] = val; } | |
| getColorMap() { return { ...this.data.colors }; } | |
| getLabel(key) { return (this.entries.find(e => e.key === key) || {}).label || key; } | |
| hexToInt(hex) { return parseInt(hex.replace('#', ''), 16); } | |
| intToHex(int) { return '#' + int.toString(16).padStart(6, '0'); } | |
| } | |
| // ============================================ | |
| // ACHIEVEMENT MANAGER | |
| // ============================================ | |
| class AchievementManager { | |
| constructor(storageKey, achievements, statDefs = {}) { | |
| this.storageKey = storageKey; | |
| this._defs = {}; | |
| for (const a of achievements) this._defs[a.key] = a; | |
| this._statDefaults = statDefs; | |
| this.data = this._load(); | |
| } | |
| _getDefaultData() { | |
| const achievements = {}; | |
| for (const key of Object.keys(this._defs)) achievements[key] = { count: 0, firstDate: null }; | |
| return { achievements, stats: { ...this._statDefaults }, firstPlayed: new Date().toISOString(), lastPlayed: new Date().toISOString() }; | |
| } | |
| _load() { | |
| try { | |
| const saved = storage.getItem(this.storageKey); | |
| if (saved) { | |
| const data = JSON.parse(saved); | |
| const defaults = this._getDefaultData(); | |
| if (!data.achievements) data.achievements = {}; | |
| for (const key of Object.keys(defaults.achievements)) { | |
| if (!data.achievements[key]) data.achievements[key] = defaults.achievements[key]; | |
| } | |
| if (!data.stats) data.stats = {}; | |
| for (const key of Object.keys(defaults.stats)) { | |
| if (data.stats[key] === undefined) data.stats[key] = defaults.stats[key]; | |
| } | |
| return data; | |
| } | |
| } catch(e) {} | |
| return this._getDefaultData(); | |
| } | |
| save() { try { this.data.lastPlayed = new Date().toISOString(); storage.setItem(this.storageKey, JSON.stringify(this.data)); } catch(e) {} } | |
| reset() { this.data = this._getDefaultData(); this.save(); } | |
| record(key) { | |
| const entry = this.data.achievements[key]; | |
| if (!entry) return { wasNew: false }; | |
| const wasNew = entry.count === 0; | |
| entry.count++; | |
| if (!entry.firstDate) entry.firstDate = new Date().toISOString(); | |
| this.save(); | |
| return { wasNew, count: entry.count }; | |
| } | |
| incrementStat(key, amount = 1) { | |
| if (this.data.stats[key] === undefined) this.data.stats[key] = 0; | |
| this.data.stats[key] += amount; | |
| this.save(); | |
| } | |
| maxStat(key, value) { | |
| if (this.data.stats[key] === undefined) this.data.stats[key] = 0; | |
| if (value > this.data.stats[key]) { this.data.stats[key] = value; this.save(); } | |
| } | |
| getCount(key) { return this.data.achievements[key]?.count || 0; } | |
| getStat(key) { return this.data.stats[key]; } | |
| getDef(key) { return this._defs[key]; } | |
| } | |
| // ============================================ | |
| // STATE MANAGER | |
| // ============================================ | |
| class GameState { | |
| enter(engine) {} | |
| exit(engine) {} | |
| update(delta) {} | |
| render(delta) {} | |
| onTap(x, y) {} | |
| onHover(x, y) {} | |
| onResize() {} | |
| } | |
| class StateManager { | |
| constructor() { | |
| this._stack = []; | |
| this._engine = null; | |
| } | |
| wire(loopManager, inputManager) { | |
| this._engine = { loopManager, inputManager }; | |
| loopManager.onUpdate(delta => { const s = this.current(); if (s) s.update(delta); }); | |
| loopManager.onRender(delta => { const s = this.current(); if (s) s.render(delta); }); | |
| loopManager.onResize(() => { const s = this.current(); if (s) s.onResize(); }); | |
| inputManager.onTap((x, y) => { const s = this.current(); if (s) s.onTap(x, y); }); | |
| inputManager.onHover((x, y) => { const s = this.current(); if (s) s.onHover(x, y); }); | |
| } | |
| current() { return this._stack.length > 0 ? this._stack[this._stack.length - 1] : null; } | |
| switch(state) { | |
| while (this._stack.length > 0) this._stack.pop().exit(this._engine); | |
| this._stack.push(state); | |
| state.enter(this._engine); | |
| } | |
| push(state) { | |
| const prev = this.current(); | |
| if (prev && prev.pause) prev.pause(this._engine); | |
| this._stack.push(state); | |
| state.enter(this._engine); | |
| } | |
| pop() { | |
| if (this._stack.length === 0) return; | |
| const removed = this._stack.pop(); | |
| removed.exit(this._engine); | |
| const next = this.current(); | |
| if (next && next.resume) next.resume(this._engine); | |
| return removed; | |
| } | |
| isActive(StateClass) { return this.current() instanceof StateClass; } | |
| } | |
| const stateManager = new StateManager(); | |
| // ============================================ | |
| // LOOP MANAGER (for scene:false mode) | |
| // ============================================ | |
| class LoopManager { | |
| constructor() { | |
| this._updateHooks = []; | |
| this._renderHooks = []; | |
| this._resizeHook = null; | |
| this._running = false; | |
| this._lastTime = 0; | |
| window.addEventListener('resize', () => this._onResize()); | |
| } | |
| onUpdate(fn) { this._updateHooks.push(fn); } | |
| onRender(fn) { this._renderHooks.push(fn); } | |
| onResize(fn) { this._resizeHook = fn; } | |
| start() { | |
| if (this._running) return; | |
| this._running = true; | |
| this._lastTime = performance.now(); | |
| this._tick(); | |
| } | |
| stop() { this._running = false; } | |
| _tick() { | |
| if (!this._running) return; | |
| requestAnimationFrame(() => this._tick()); | |
| const now = performance.now(); | |
| const delta = (now - this._lastTime) / 1000; | |
| this._lastTime = now; | |
| for (const fn of this._updateHooks) fn(delta); | |
| for (const fn of this._renderHooks) fn(delta); | |
| } | |
| _onResize() { if (this._resizeHook) this._resizeHook(); } | |
| } | |
| // ============================================ | |
| // UNBOXED BOOTSTRAP | |
| // ============================================ | |
| const Unboxed = { | |
| GameState, SoundEngine, SettingsManager, UIManager, InputManager, | |
| ColorPalette, AchievementManager, StateManager, LoopManager, | |
| soundEngine, storage, ui, stateManager, | |
| sceneManager: null, | |
| inputManager: null, | |
| run(config = {}) { | |
| const { | |
| scene: sceneConfig = {}, | |
| input: inputConfig = {}, | |
| sounds = {}, | |
| loop = null, | |
| init = () => {}, | |
| } = config; | |
| if (Object.keys(sounds).length > 0) soundEngine.registerAll(sounds); | |
| if (sceneConfig === false) { | |
| this.sceneManager = new LoopManager(); | |
| if (loop) this.sceneManager.onUpdate(loop); | |
| const inputDom = inputConfig.domElement || document.body; | |
| this.inputManager = new InputManager(inputDom, inputConfig); | |
| } else { | |
| console.warn('Full SceneManager not included in this build — use scene:false'); | |
| } | |
| this.stateManager.wire(this.sceneManager, this.inputManager); | |
| init(this); | |
| }, | |
| start() { | |
| if (this.sceneManager) this.sceneManager.start(); | |
| } | |
| }; | |
| // ████████████████████████████████████████████████ | |
| // █ █ | |
| // █ GAME: EIGHT NEIGHBORS — FIREWORKS █ | |
| // █ █ | |
| // ████████████████████████████████████████████████ | |
| // ============================================ | |
| // EIGHT NEIGHBORS — SETTINGS (persisted) | |
| // ============================================ | |
| const settingsManager = new SettingsManager('eight-neighbors-settings', { | |
| rows: 9, | |
| cols: 9, | |
| template: 'rectangle', | |
| minGroupSize: 2, | |
| soundEnabled: true | |
| }); | |
| // Hydrate settings UI from saved values | |
| function hydrateSettingsUI() { | |
| document.getElementById('rows-setting').value = settingsManager.get('rows'); | |
| document.getElementById('cols-setting').value = settingsManager.get('cols'); | |
| document.getElementById('template-setting').value = settingsManager.get('template'); | |
| document.getElementById('mingroup-setting').value = settingsManager.get('minGroupSize'); | |
| } | |
| // Save settings from UI to SettingsManager | |
| function saveSettingsFromUI() { | |
| settingsManager.set('rows', parseInt(document.getElementById('rows-setting').value)); | |
| settingsManager.set('cols', parseInt(document.getElementById('cols-setting').value)); | |
| settingsManager.set('template', document.getElementById('template-setting').value); | |
| settingsManager.set('minGroupSize', parseInt(document.getElementById('mingroup-setting').value)); | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — SCREEN HELPER | |
| // ============================================ | |
| function showScreen(screenId) { | |
| document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); | |
| const el = document.getElementById(screenId); | |
| if (el) el.classList.add('active'); | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — TUBE CONNECTION | |
| // ============================================ | |
| class TubeConnection { | |
| constructor(start, end, scene) { | |
| this.startShape = start; | |
| this.endShape = end; | |
| this.scene = scene; | |
| this.create(); | |
| } | |
| create() { | |
| const startPos = this.startShape.mesh.position; | |
| const endPos = this.endShape.mesh.position; | |
| const distance = startPos.distanceTo(endPos); | |
| const tubeRadius = this.startShape.size * 0.15; | |
| const geometry = new THREE.CylinderGeometry(tubeRadius, tubeRadius, distance, 8, 1, false); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: this.startShape.color, | |
| transparent: true, opacity: 0.7, | |
| shininess: 80, | |
| emissive: this.startShape.color, | |
| emissiveIntensity: 0.4 | |
| }); | |
| this.mesh = new THREE.Mesh(geometry, material); | |
| this.mesh.position.lerpVectors(startPos, endPos, 0.5); | |
| const direction = new THREE.Vector3().subVectors(endPos, startPos).normalize(); | |
| const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction); | |
| this.mesh.setRotationFromQuaternion(quaternion); | |
| this.scene.add(this.mesh); | |
| } | |
| remove() { | |
| if (this.mesh) { | |
| this.scene.remove(this.mesh); | |
| this.mesh.geometry.dispose(); | |
| this.mesh.material.dispose(); | |
| this.mesh = null; | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — SHAPE CLASS | |
| // ============================================ | |
| class Shape { | |
| constructor(x, y, z, size, shapeData, row, col) { | |
| this.geometry = shapeData.geometryFactory(size * 0.8); | |
| this.material = new THREE.MeshPhongMaterial({ | |
| color: shapeData.color, shininess: 30, specular: 0x444444 | |
| }); | |
| this.mesh = new THREE.Mesh(this.geometry, this.material); | |
| this.mesh.position.set(x, y, z); | |
| if (shapeData.name !== 'Sphere') { | |
| this.mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); | |
| } | |
| this.shapeType = shapeData.name; | |
| this.color = shapeData.color; | |
| this.row = row; | |
| this.col = col; | |
| this.size = size; | |
| } | |
| popIn(delay = 0) { | |
| this.mesh.scale.set(0.01, 0.01, 0.01); | |
| new TWEEN.Tween(this.mesh.scale).to({ x: 1, y: 1, z: 1 }, 500) | |
| .easing(TWEEN.Easing.Elastic.Out).delay(delay).start(); | |
| } | |
| explode(delay = 0, onComplete) { | |
| const growTween = new TWEEN.Tween(this.mesh.scale) | |
| .to({ x: 1.5, y: 1.5, z: 1.5 }, 200) | |
| .easing(TWEEN.Easing.Quadratic.Out).delay(delay); | |
| const popTween = new TWEEN.Tween(this.mesh.scale) | |
| .to({ x: 0.01, y: 0.01, z: 0.01 }, 300) | |
| .easing(TWEEN.Easing.Back.In) | |
| .onComplete(() => { if (onComplete) onComplete(); }); | |
| growTween.chain(popTween); | |
| growTween.start(); | |
| } | |
| popOut(onComplete) { | |
| new TWEEN.Tween(this.mesh.scale).to({ x: 0.01, y: 0.01, z: 0.01 }, 300) | |
| .easing(TWEEN.Easing.Back.In).onComplete(() => { if (onComplete) onComplete(); }).start(); | |
| } | |
| dispose() { | |
| this.geometry.dispose(); | |
| this.material.dispose(); | |
| } | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — PRIZE CLASS | |
| // ============================================ | |
| class Prize { | |
| constructor(x, y, z, size) { | |
| this.geometry = new THREE.TorusGeometry(size, size / 4, 8, 50); | |
| this.material = new THREE.MeshPhongMaterial({ | |
| color: 0xffd700, shininess: 100, specular: 0xffffff | |
| }); | |
| this.mesh = new THREE.Mesh(this.geometry, this.material); | |
| this.mesh.position.set(x, y, z); | |
| } | |
| collect() { | |
| const flyUp = new TWEEN.Tween(this.mesh.position) | |
| .to({ z: this.mesh.position.z + 5 }, 500) | |
| .easing(TWEEN.Easing.Circular.In); | |
| new TWEEN.Tween(this.mesh.scale) | |
| .to({ x: 0.01, y: 0.01, z: 0.01 }, 500) | |
| .onComplete(() => this.dispose()) | |
| .chain(flyUp) | |
| .start(); | |
| } | |
| rotate() { | |
| this.mesh.rotation.y += 0.02; | |
| this.mesh.rotation.x += 0.01; | |
| } | |
| dispose() { | |
| if (this.mesh.parent) this.mesh.parent.remove(this.mesh); | |
| this.geometry.dispose(); | |
| this.material.dispose(); | |
| } | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — SPLASH SCENE | |
| // ============================================ | |
| class SplashScene { | |
| constructor() { | |
| this.container = document.getElementById('splash-3d-container'); | |
| this.scene = new THREE.Scene(); | |
| this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.renderer.setClearColor(0x000000, 0); | |
| this.container.appendChild(this.renderer.domElement); | |
| this.camera.position.z = 8; | |
| this.shapes = []; | |
| this.isAnimating = false; | |
| this.shapeAtlas = [ | |
| { name: 'Sphere', color: 0xff4444, geometryFactory: s => new THREE.SphereGeometry(s, 16, 12) }, | |
| { name: 'Cube', color: 0x44ff44, geometryFactory: s => new THREE.BoxGeometry(s, s, s) }, | |
| { name: 'Tetra', color: 0x4488ff, geometryFactory: s => new THREE.TetrahedronGeometry(s) }, | |
| { name: 'Octa', color: 0xffff44, geometryFactory: s => new THREE.OctahedronGeometry(s) }, | |
| { name: 'Icosa', color: 0xff44ff, geometryFactory: s => new THREE.IcosahedronGeometry(s) }, | |
| { name: 'Dodeca', color: 0x44ffff, geometryFactory: s => new THREE.DodecahedronGeometry(s) } | |
| ]; | |
| this.setupLighting(); | |
| this.createFloatingShapes(); | |
| } | |
| setupLighting() { | |
| this.scene.add(new THREE.AmbientLight(0xffffff, 0.6)); | |
| const d1 = new THREE.DirectionalLight(0xffffff, 0.8); | |
| d1.position.set(5, 5, 5); | |
| this.scene.add(d1); | |
| const d2 = new THREE.DirectionalLight(0x6b46c1, 0.4); | |
| d2.position.set(-5, -5, 3); | |
| this.scene.add(d2); | |
| } | |
| createFloatingShapes() { | |
| const positions = [ | |
| {x:-6,y:3,z:-2},{x:6,y:-2,z:-1},{x:-4,y:-3,z:1},{x:4,y:4,z:0}, | |
| {x:-2,y:1,z:-3},{x:2,y:-4,z:2},{x:7,y:1,z:-2},{x:-7,y:-1,z:1}, | |
| {x:0,y:5,z:-1},{x:-1,y:-5,z:0},{x:5,y:3,z:1},{x:-5,y:2,z:-1} | |
| ]; | |
| positions.forEach((pos, i) => { | |
| const sd = this.shapeAtlas[i % this.shapeAtlas.length]; | |
| const size = 0.4 + Math.random() * 0.3; | |
| const geometry = sd.geometryFactory(size); | |
| const material = new THREE.MeshPhongMaterial({ color: sd.color, shininess: 60, specular: 0x444444, transparent: true, opacity: 0.8 }); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| mesh.position.set(pos.x, pos.y, pos.z); | |
| mesh.rotation.set(Math.random()*Math.PI*2, Math.random()*Math.PI*2, Math.random()*Math.PI*2); | |
| mesh.userData = { | |
| originalPosition: { ...pos }, | |
| floatSpeed: 0.5 + Math.random() * 1.5, | |
| rotationSpeed: { x: (Math.random()-0.5)*0.02, y: (Math.random()-0.5)*0.02, z: (Math.random()-0.5)*0.02 }, | |
| floatOffset: Math.random() * Math.PI * 2 | |
| }; | |
| this.shapes.push(mesh); | |
| this.scene.add(mesh); | |
| mesh.scale.set(0.01, 0.01, 0.01); | |
| new TWEEN.Tween(mesh.scale).to({x:1,y:1,z:1}, 800).easing(TWEEN.Easing.Elastic.Out).delay(i*100).start(); | |
| }); | |
| } | |
| startAnimation() { this.isAnimating = true; } | |
| stopAnimation() { this.isAnimating = false; } | |
| update() { | |
| if (!this.isAnimating) return; | |
| const time = Date.now() * 0.001; | |
| this.shapes.forEach(shape => { | |
| const ud = shape.userData; | |
| shape.position.y = ud.originalPosition.y + Math.sin(time * ud.floatSpeed + ud.floatOffset) * 0.5; | |
| shape.position.x = ud.originalPosition.x + Math.cos(time * ud.floatSpeed * 0.7 + ud.floatOffset) * 0.2; | |
| shape.rotation.x += ud.rotationSpeed.x; | |
| shape.rotation.y += ud.rotationSpeed.y; | |
| shape.rotation.z += ud.rotationSpeed.z; | |
| }); | |
| } | |
| render() { | |
| if (!this.isAnimating) return; | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| onWindowResize() { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — MAIN GAME (BubblePopper) | |
| // ============================================ | |
| class BubblePopper { | |
| constructor() { | |
| this.container = document.getElementById('container'); | |
| this.scene = new THREE.Scene(); | |
| this.scene.background = new THREE.Color(0x000033); | |
| this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| this.renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.container.appendChild(this.renderer.domElement); | |
| this.raycaster = new THREE.Raycaster(); | |
| this.pointerPos = new THREE.Vector2(); | |
| this.hoveredShape = null; | |
| this.tubeConnections = []; | |
| this.shapes = []; | |
| this.prizes = []; | |
| this.shapeAtlas = [ | |
| { name: 'Sphere', color: 0xff0000, geometryFactory: s => new THREE.SphereGeometry(s * 1.1, 20, 16) }, | |
| { name: 'Cube', color: 0x00ff00, geometryFactory: s => new THREE.BoxGeometry(s*1.5, s*1.5, s*1.5) }, | |
| { name: 'Tetra', color: 0x0080ff, geometryFactory: s => new THREE.TetrahedronGeometry(s * 1.4) }, | |
| { name: 'Octa', color: 0xffff00, geometryFactory: s => new THREE.OctahedronGeometry(s * 1.5) }, | |
| { name: 'Icosa', color: 0xff00ff, geometryFactory: s => new THREE.IcosahedronGeometry(s * 1.25) }, | |
| { name: 'Dodeca', color: 0xffffff, geometryFactory: s => new THREE.DodecahedronGeometry(s * 1.1, 0) } | |
| ]; | |
| this.isAnimating = false; | |
| this.gamePaused = false; | |
| this.score = 0; | |
| this.scoreElement = document.getElementById('game-score'); | |
| this.rotateLeftBtn = document.getElementById('rotate-left-btn'); | |
| this.rotateRightBtn = document.getElementById('rotate-right-btn'); | |
| this.scene.add(new THREE.AmbientLight(0xffffff, 0.5)); | |
| const dl = new THREE.DirectionalLight(0xffffff, 0.8); | |
| dl.position.set(5, 10, 7.5); | |
| this.scene.add(dl); | |
| this.defineBoardTemplates(); | |
| } | |
| defineBoardTemplates() { | |
| this.boardTemplates = { | |
| rectangle: (r, c) => Array(r).fill().map(() => Array(c).fill(true)), | |
| diamond: (r, c) => { | |
| const layout = Array(r).fill().map(() => Array(c).fill(false)); | |
| const midR = Math.floor(r/2), midC = Math.floor(c/2); | |
| const maxDist = Math.min(midR, midC); | |
| for (let i=0;i<r;i++) for (let j=0;j<c;j++) | |
| if (Math.abs(i-midR)+Math.abs(j-midC) <= maxDist) layout[i][j] = true; | |
| return layout; | |
| }, | |
| cross: (r, c) => { | |
| const layout = Array(r).fill().map(() => Array(c).fill(false)); | |
| const midR = Math.floor(r/2), midC = Math.floor(c/2); | |
| const arm = Math.max(1, Math.floor(Math.min(r,c)/5)); | |
| for (let i=0;i<r;i++) for (let j=0;j<c;j++) | |
| if (Math.abs(i-midR) <= arm || Math.abs(j-midC) <= arm) layout[i][j] = true; | |
| return layout; | |
| }, | |
| circle: (r, c) => { | |
| const layout = Array(r).fill().map(() => Array(c).fill(false)); | |
| const cR = (r-1)/2, cC = (c-1)/2; | |
| const radius = Math.min(r,c)/2 - 0.5; | |
| for (let i=0;i<r;i++) for (let j=0;j<c;j++) | |
| if (Math.sqrt((i-cR)**2 + (j-cC)**2) <= radius) layout[i][j] = true; | |
| return layout; | |
| }, | |
| staircase: (r, c) => { | |
| const layout = Array(r).fill().map(() => Array(c).fill(false)); | |
| const nSteps = Math.max(2, Math.min(4, Math.round(Math.min(r, c) / 4))); | |
| const stepRows = Math.ceil(r / nSteps); | |
| const stepCols = Math.ceil(c / nSteps); | |
| for (let k = 0; k < nSteps; k++) { | |
| const rowStart = Math.max(0, r - (k + 1) * stepRows); | |
| const rowEnd = r - k * stepRows - 1; | |
| if (rowEnd < 0) continue; | |
| const colStart = k * stepCols; | |
| if (colStart >= c) continue; | |
| for (let i = rowStart; i <= rowEnd; i++) | |
| for (let j = colStart; j < c; j++) layout[i][j] = true; | |
| } | |
| return layout; | |
| }, | |
| corners: (r, c) => { | |
| const layout = Array(r).fill().map(() => Array(c).fill(false)); | |
| const M = Math.min(Math.floor((r - 1) / 2), Math.floor((c - 1) / 2)); | |
| if (M < 1) return layout; | |
| const corners = [ | |
| [0, 0], | |
| [0, c - M], | |
| [r - M, 0], | |
| [r - M, c - M], | |
| ]; | |
| for (const [rStart, cStart] of corners) | |
| for (let i = 0; i < M; i++) | |
| for (let j = 0; j < M; j++) layout[rStart + i][cStart + j] = true; | |
| return layout; | |
| }, | |
| diagonalCorners: (r, c) => { | |
| const M = Math.min(Math.floor((r - 1) / 2), Math.floor((c - 1) / 2)); | |
| const layout = Array(r).fill().map(() => Array(c).fill(false)); | |
| const initialFill = Array(r).fill().map(() => Array(c).fill(false)); | |
| if (M < 1) return { layout, initialFill }; | |
| const minis = [ | |
| [0, 0, false], | |
| [0, c - M, true], | |
| [r - M, 0, false], | |
| [r - M, c - M, true], | |
| ]; | |
| for (const [rOff, cOff, isRight] of minis) { | |
| for (let i = 0; i < M; i++) | |
| for (let j = 0; j < M; j++) { | |
| layout[rOff + i][cOff + j] = true; | |
| const filled = isRight ? (i + j >= M - 1) : (i >= j); | |
| if (filled) initialFill[rOff + i][cOff + j] = true; | |
| } | |
| } | |
| return { layout, initialFill }; | |
| }, | |
| triangle: (r, c) => { | |
| const layout = Array(r).fill().map(() => Array(c).fill(true)); | |
| const initialFill = Array(r).fill().map(() => Array(c).fill(false)); | |
| for (let i = 0; i < r; i++) | |
| for (let j = 0; j < c; j++) | |
| if (j * (r - 1) <= i * (c - 1)) initialFill[i][j] = true; | |
| return { layout, initialFill }; | |
| }, | |
| random: (r, c) => Array(r).fill().map(() => Array(c).fill(false).map(() => Math.random() > 0.4)) | |
| }; | |
| } | |
| getPointerCoordinates(clientX, clientY) { | |
| if (clientX === null || clientY === null || clientX === undefined || clientY === undefined) { | |
| this.pointerPos.set(NaN, NaN); | |
| return; | |
| } | |
| this.pointerPos.x = (clientX / window.innerWidth) * 2 - 1; | |
| this.pointerPos.y = -(clientY / window.innerHeight) * 2 + 1; | |
| } | |
| getShapeMeshes() { | |
| const meshes = []; | |
| for (let i = 0; i < this.shapes.length; i++) | |
| for (let j = 0; j < this.shapes[i].length; j++) | |
| if (this.shapes[i]?.[j]?.mesh) meshes.push(this.shapes[i][j].mesh); | |
| return meshes; | |
| } | |
| findShapeFromMesh(mesh) { | |
| for (let i = 0; i < this.shapes.length; i++) | |
| for (let j = 0; j < this.shapes[i].length; j++) | |
| if (this.shapes[i]?.[j]?.mesh === mesh) return this.shapes[i][j]; | |
| return null; | |
| } | |
| handleHover(clientX, clientY) { | |
| if (this.isAnimating || this.gamePaused) return; | |
| if (clientX === null || clientY === null) { | |
| this.clearTubeConnections(); | |
| this.hoveredShape = null; | |
| return; | |
| } | |
| this.getPointerCoordinates(clientX, clientY); | |
| this.raycaster.setFromCamera(this.pointerPos, this.camera); | |
| const intersects = this.raycaster.intersectObjects(this.getShapeMeshes()); | |
| let newHovered = null; | |
| if (intersects.length > 0) newHovered = this.findShapeFromMesh(intersects[0].object); | |
| if (newHovered !== this.hoveredShape) { | |
| this.clearTubeConnections(); | |
| if (newHovered) { | |
| this.hoveredShape = newHovered; | |
| this.highlightConnectedGroup(newHovered); | |
| } else { | |
| this.hoveredShape = null; | |
| } | |
| } | |
| } | |
| handleTap(clientX, clientY) { | |
| if (this.isAnimating || this.gamePaused) return; | |
| this.getPointerCoordinates(clientX, clientY); | |
| this.raycaster.setFromCamera(this.pointerPos, this.camera); | |
| const intersects = this.raycaster.intersectObjects(this.getShapeMeshes()); | |
| if (intersects.length > 0) { | |
| const shape = this.findShapeFromMesh(intersects[0].object); | |
| if (shape) { | |
| this.clearTubeConnections(); | |
| this.processShapeClick(shape.row, shape.col); | |
| } | |
| } | |
| } | |
| onWindowResize() { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.positionBoardElements(); | |
| } | |
| positionBoardElements() { | |
| if (this.shapes.length === 0 && this.prizes.length === 0) return; | |
| this.spacing = 1.1; | |
| this.pieceSize = this.spacing * 0.5; | |
| const boardWidth = this.columns * this.spacing; | |
| const boardHeight = this.rows * this.spacing; | |
| const fovInRadians = THREE.MathUtils.degToRad(this.camera.fov); | |
| const distH = (boardHeight / 2) / Math.tan(fovInRadians / 2); | |
| const distW = (boardWidth / 2) / (Math.tan(fovInRadians / 2) * this.camera.aspect); | |
| this.camera.position.z = Math.max(distH, distW) * 1.15; | |
| this.camera.lookAt(0, 0, 0); | |
| const offsetX = -((this.columns - 1) * this.spacing) / 2; | |
| const offsetY = ((this.rows - 1) * this.spacing) / 2; | |
| for (let i = 0; i < this.rows; i++) for (let j = 0; j < this.columns; j++) { | |
| if (this.shapes[i]?.[j]) { | |
| this.shapes[i][j].mesh.position.x = offsetX + j * this.spacing; | |
| this.shapes[i][j].mesh.position.y = offsetY - i * this.spacing; | |
| } | |
| } | |
| this.prizes.forEach(p => { | |
| if (p.prizeObj) { | |
| p.prizeObj.mesh.position.x = offsetX + p.col * this.spacing; | |
| p.prizeObj.mesh.position.y = offsetY - p.row * this.spacing; | |
| } | |
| }); | |
| } | |
| clearBoard(fullClear = true) { | |
| this.clearTubeConnections(); | |
| const toRemove = []; | |
| this.scene.traverse(o => { if (o.type === 'Mesh' || o.type === 'Sprite') toRemove.push(o); }); | |
| toRemove.forEach(o => { | |
| if (o === this.scene.background || !o.parent) return; | |
| o.parent.remove(o); | |
| if (o.geometry) o.geometry.dispose(); | |
| if (o.material) { | |
| if (Array.isArray(o.material)) o.material.forEach(m => m.dispose()); | |
| else o.material.dispose(); | |
| } | |
| }); | |
| this.shapes = []; | |
| this.prizes = []; | |
| if (fullClear) TWEEN.removeAll(); | |
| } | |
| createNewBoard() { | |
| this.clearBoard(); | |
| this.score = 0; | |
| this.hasMadeMove = false; | |
| this.updateScore(); | |
| this.pauseGame(false); | |
| this.isAnimating = true; | |
| this.setRotationButtonsEnabled(false); | |
| // Use a queued next-level choice (set by win/lose overlay) if present, | |
| // otherwise fall back to the persistent settings defaults. | |
| if (this.nextLevelChoice) { | |
| this.rows = this.nextLevelChoice.rows; | |
| this.columns = this.nextLevelChoice.cols; | |
| this.boardTemplate = this.nextLevelChoice.template; | |
| this.nextLevelChoice = null; | |
| } else { | |
| this.rows = settingsManager.get('rows'); | |
| this.columns = settingsManager.get('cols'); | |
| this.boardTemplate = settingsManager.get('template'); | |
| } | |
| this.minGroupSize = settingsManager.get('minGroupSize'); | |
| let attempts = 0; | |
| do { this.generateBoardState(); attempts++; } | |
| while (!this.hasValidMoves() && attempts < 50); | |
| if (attempts >= 50) console.error("Failed to generate a valid board."); | |
| this.positionBoardElements(); | |
| this.animateShapesIn(); | |
| soundEngine.play('newBoard'); | |
| } | |
| generateBoardState() { | |
| this.clearBoard(false); | |
| const tmpl = this.boardTemplates[this.boardTemplate](this.rows, this.columns); | |
| const layout = Array.isArray(tmpl) ? tmpl : tmpl.layout; | |
| const initialFill = Array.isArray(tmpl) ? tmpl : tmpl.initialFill; | |
| this.boardLayout = layout; | |
| this.shapes = Array(this.rows).fill().map(() => Array(this.columns).fill(null)); | |
| const offsetX = -((this.columns - 1) * 1.1) / 2; | |
| const offsetY = ((this.rows - 1) * 1.1) / 2; | |
| const activeCells = []; | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) | |
| if (initialFill[i][j]) activeCells.push({row:i, col:j}); | |
| this.prizes = []; | |
| const prizesCount = Math.min(Math.floor(activeCells.length / 5), 10); | |
| activeCells.sort(() => 0.5 - Math.random()); | |
| for (let p=0; p < Math.min(prizesCount, activeCells.length); p++) | |
| this.prizes.push({ ...activeCells[p], collected: false }); | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) { | |
| if (!initialFill[i][j]) continue; | |
| const shapeData = this.shapeAtlas[Math.floor(Math.random() * this.shapeAtlas.length)]; | |
| const x = offsetX + j*1.1, y = offsetY - i*1.1; | |
| const shape = new Shape(x, y, 0, 0.55, shapeData, i, j); | |
| this.shapes[i][j] = shape; | |
| this.scene.add(shape.mesh); | |
| const prizeData = this.prizes.find(p => p.row===i && p.col===j); | |
| if (prizeData) { | |
| const prize = new Prize(x, y, 0.1, 0.55 * 1.2); | |
| this.scene.add(prize.mesh); | |
| prizeData.prizeObj = prize; | |
| } | |
| } | |
| } | |
| animateShapesIn() { | |
| let maxDelay = 0; | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) { | |
| if (this.shapes[i]?.[j]) { | |
| const delay = (i + j) * 20; | |
| this.shapes[i][j].popIn(delay); | |
| if (delay > maxDelay) maxDelay = delay; | |
| } | |
| } | |
| setTimeout(() => { | |
| this.isAnimating = false; | |
| this.setRotationButtonsEnabled(true); | |
| }, maxDelay + 500); | |
| } | |
| rotateBoard(direction) { | |
| if (this.isAnimating || this.gamePaused) return; | |
| this.isAnimating = true; | |
| this.clearTubeConnections(); | |
| this.setRotationButtonsEnabled(false); | |
| this.lastRotationDirection = direction; | |
| soundEngine.play('rotate'); | |
| this.animateVisualRotation(() => { | |
| const rotatedLayout = this.rotateArray2D(this.boardLayout, direction); | |
| const rotatedShapes = this.rotateArray2D(this.shapes, direction); | |
| const oldRows = this.rows, oldCols = this.columns; | |
| this.rows = oldCols; | |
| this.columns = oldRows; | |
| this.boardLayout = rotatedLayout; | |
| this.shapes = rotatedShapes; | |
| this.updateShapePositions(); | |
| this.rotatePrizes(direction, oldRows, oldCols); | |
| this.positionBoardElements(); | |
| setTimeout(() => this.applyGravity(), 200); | |
| }); | |
| } | |
| rotateArray2D(array, direction) { | |
| const rows = array.length, cols = array[0].length; | |
| const rotated = Array(cols).fill().map(() => Array(rows).fill(null)); | |
| for (let i=0;i<rows;i++) for (let j=0;j<cols;j++) { | |
| if (direction === 'right') rotated[j][rows-1-i] = array[i][j]; | |
| else rotated[cols-1-j][i] = array[i][j]; | |
| } | |
| return rotated; | |
| } | |
| updateShapePositions() { | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) { | |
| const shape = this.shapes[i]?.[j]; | |
| if (shape) { shape.row = i; shape.col = j; } | |
| } | |
| } | |
| rotatePrizes(direction, oldRows, oldCols) { | |
| this.prizes.forEach(prize => { | |
| const oR = prize.row, oC = prize.col; | |
| if (direction === 'right') { prize.row = oC; prize.col = oldRows-1-oR; } | |
| else { prize.row = oldCols-1-oC; prize.col = oR; } | |
| }); | |
| } | |
| animateVisualRotation(onComplete) { | |
| let pending = 0; | |
| const duration = 800; | |
| const done = () => { if (--pending === 0 && onComplete) onComplete(); }; | |
| const animateElement = (mesh) => { | |
| const cx = mesh.position.x, cy = mesh.position.y; | |
| let nx, ny; | |
| if (this.lastRotationDirection === 'right') { nx = cy; ny = -cx; } | |
| else { nx = -cy; ny = cx; } | |
| pending++; | |
| new TWEEN.Tween(mesh.position).to({x:nx, y:ny}, duration) | |
| .easing(TWEEN.Easing.Quadratic.InOut).onComplete(done).start(); | |
| }; | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) { | |
| if (this.shapes[i]?.[j]) animateElement(this.shapes[i][j].mesh); | |
| } | |
| this.prizes.forEach(p => { if (p.prizeObj) animateElement(p.prizeObj.mesh); }); | |
| if (pending === 0 && onComplete) onComplete(); | |
| } | |
| setRotationButtonsEnabled(enabled) { | |
| this.rotateLeftBtn.disabled = !enabled; | |
| this.rotateRightBtn.disabled = !enabled; | |
| } | |
| processShapeClick(row, col) { | |
| const shape = this.shapes[row]?.[col]; | |
| if (!shape) return; | |
| const connected = this.findConnectedShapes(row, col, shape.shapeType); | |
| if (connected.length < this.minGroupSize) return; | |
| this.isAnimating = true; | |
| this.setRotationButtonsEnabled(false); | |
| const explosionWaves = this.groupShapesByDistance(connected, row, col); | |
| this.triggerFireworksExplosion(explosionWaves, connected); | |
| } | |
| groupShapesByDistance(connected, clickedRow, clickedCol) { | |
| const waves = {}; | |
| connected.forEach(({ row, col }) => { | |
| const dist = Math.max(Math.abs(row - clickedRow), Math.abs(col - clickedCol)); | |
| if (!waves[dist]) waves[dist] = []; | |
| waves[dist].push({ row, col }); | |
| }); | |
| return waves; | |
| } | |
| triggerFireworksExplosion(explosionWaves, allConnected) { | |
| this.score += allConnected.length * 10; | |
| this.hasMadeMove = true; | |
| let callbacksPending = allConnected.length; | |
| const onExplosionComplete = () => { | |
| if (--callbacksPending === 0) { | |
| allConnected.forEach(({ row: r, col: c }) => { | |
| if (this.shapes[r]) this.shapes[r][c] = null; | |
| }); | |
| this.updateScore(); | |
| setTimeout(() => this.applyGravity(), 200); | |
| } | |
| }; | |
| const waveDelays = Object.keys(explosionWaves).sort((a, b) => parseInt(a) - parseInt(b)); | |
| waveDelays.forEach((distance, waveIndex) => { | |
| const wave = explosionWaves[distance]; | |
| const baseDelay = waveIndex * 80; | |
| wave.forEach((pos, pieceIndex) => { | |
| const { row: r, col: c } = pos; | |
| const shapeToRemove = this.shapes[r]?.[c]; | |
| if (shapeToRemove) { | |
| const pieceDelay = baseDelay + (pieceIndex * 15); | |
| const prizeIdx = this.prizes.findIndex(p => p.row===r && p.col===c && !p.collected); | |
| if (prizeIdx !== -1) { | |
| this.prizes[prizeIdx].collected = true; | |
| this.score += 50; | |
| soundEngine.play('collect'); | |
| if (this.prizes[prizeIdx].prizeObj) { | |
| setTimeout(() => this.prizes[prizeIdx].prizeObj.collect(), pieceDelay); | |
| } | |
| } | |
| soundEngine.play('pop', pieceDelay, waveIndex); | |
| shapeToRemove.explode(pieceDelay, () => { | |
| this.scene.remove(shapeToRemove.mesh); | |
| shapeToRemove.dispose(); | |
| onExplosionComplete(); | |
| }); | |
| } else { | |
| onExplosionComplete(); | |
| } | |
| }); | |
| }); | |
| } | |
| findConnectedShapes(row, col, targetShapeType) { | |
| const visited = new Set(); | |
| const connected = []; | |
| const stack = [{ row, col }]; | |
| visited.add(`${row},${col}`); | |
| while (stack.length > 0) { | |
| const { row: r, col: c } = stack.pop(); | |
| if (!this.boardLayout[r]?.[c] || this.shapes[r]?.[c]?.shapeType !== targetShapeType) continue; | |
| connected.push({ row: r, col: c }); | |
| for (let dr=-1; dr<=1; dr++) for (let dc=-1; dc<=1; dc++) { | |
| if (dr===0 && dc===0) continue; | |
| const nr = r+dr, nc = c+dc; | |
| if (nr>=0 && nr<this.rows && nc>=0 && nc<this.columns && !visited.has(`${nr},${nc}`) && this.shapes[nr]?.[nc]) { | |
| visited.add(`${nr},${nc}`); | |
| stack.push({ row: nr, col: nc }); | |
| } | |
| } | |
| } | |
| return connected; | |
| } | |
| applyGravity() { | |
| let pending = 0; | |
| const offsetY = ((this.rows-1) * this.spacing) / 2; | |
| const onDone = () => { if (--pending === 0) this.removeEmptyColumns(); }; | |
| for (let j=0;j<this.columns;j++) { | |
| let emptySpaces = 0; | |
| for (let i=this.rows-1; i>=0; i--) { | |
| if (!this.boardLayout[i]?.[j]) { emptySpaces = 0; continue; } | |
| if (!this.shapes[i][j]) { emptySpaces++; } | |
| else if (emptySpaces > 0) { | |
| const shape = this.shapes[i][j]; | |
| const targetRow = i + emptySpaces; | |
| this.shapes[targetRow][j] = shape; this.shapes[i][j] = null; | |
| shape.row = targetRow; | |
| const newY = offsetY - targetRow * this.spacing; | |
| pending++; | |
| new TWEEN.Tween(shape.mesh.position).to({y:newY}, 500) | |
| .easing(TWEEN.Easing.Bounce.Out).onComplete(onDone).start(); | |
| this.prizes.filter(p => p.row===i && p.col===j && !p.collected).forEach(p => { | |
| p.row = targetRow; | |
| if (p.prizeObj) new TWEEN.Tween(p.prizeObj.mesh.position).to({y:newY}, 500).easing(TWEEN.Easing.Bounce.Out).start(); | |
| }); | |
| } | |
| } | |
| } | |
| if (pending === 0) this.removeEmptyColumns(); | |
| } | |
| removeEmptyColumns() { | |
| const emptyCols = []; | |
| for (let j=0;j<this.columns;j++) { | |
| if (this.boardLayout.some(r => r[j]) && !this.shapes.some(r => r[j])) emptyCols.push(j); | |
| } | |
| if (emptyCols.length > 0) { | |
| this.score += emptyCols.length * 100; | |
| this.updateScore(); | |
| let pending = 0; | |
| const newColCount = this.columns - emptyCols.length; | |
| const offsetX = -((newColCount - 1) * this.spacing) / 2; | |
| const onDone = () => { if (--pending === 0) this.finalizeColumnRemoval(emptyCols); }; | |
| for (let j=0;j<this.columns;j++) { | |
| const shift = emptyCols.filter(c => c<j).length; | |
| if (shift > 0) { | |
| const newCol = j - shift; | |
| const newX = offsetX + newCol * this.spacing; | |
| for (let i=0;i<this.rows;i++) { | |
| const shape = this.shapes[i][j]; | |
| if (shape) { | |
| pending++; | |
| new TWEEN.Tween(shape.mesh.position).to({x:newX}, 500) | |
| .easing(TWEEN.Easing.Quadratic.Out).onComplete(onDone).start(); | |
| const prize = this.prizes.find(p => p.row===i && p.col===j && p.prizeObj); | |
| if (prize) new TWEEN.Tween(prize.prizeObj.mesh.position).to({x:newX}, 500).easing(TWEEN.Easing.Quadratic.Out).start(); | |
| } | |
| } | |
| } | |
| } | |
| if (pending === 0) this.finalizeColumnRemoval(emptyCols); | |
| } else { | |
| this.checkGameState(); | |
| } | |
| } | |
| finalizeColumnRemoval(emptyCols) { | |
| emptyCols.sort((a,b)=>b-a).forEach(col => { | |
| for (let i=0;i<this.rows;i++) { | |
| this.shapes[i].splice(col, 1); | |
| this.boardLayout[i].splice(col, 1); | |
| } | |
| }); | |
| this.columns -= emptyCols.length; | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) { | |
| if (this.shapes[i]?.[j]) this.shapes[i][j].col = j; | |
| } | |
| this.prizes.forEach(p => { | |
| const shift = emptyCols.filter(c => c<p.col).length; | |
| if (shift > 0) p.col -= shift; | |
| }); | |
| this.positionBoardElements(); | |
| this.checkGameState(); | |
| } | |
| checkGameState() { | |
| this.isAnimating = false; | |
| this.setRotationButtonsEnabled(true); | |
| if (this.prizes.length > 0 && this.prizes.every(p => p.collected)) { | |
| this.winLevel(); | |
| return; | |
| } | |
| if (!this.hasValidMoves()) this.endGame(); | |
| } | |
| hasValidMoves() { | |
| for (let i=0;i<this.rows;i++) for (let j=0;j<this.columns;j++) { | |
| const s = this.shapes[i]?.[j]; | |
| if (s && this.findConnectedShapes(i,j,s.shapeType).length >= this.minGroupSize) return true; | |
| } | |
| return false; | |
| } | |
| // --- Level Transitions: size stepping + overlay state --- | |
| // Sizes step along: 7×7 → 7×9 → 9×9 → 9×11 → 11×11 → 11×13 → 13×13 → 13×15 → 15×15 | |
| // Rule: Larger grows the smaller dim; if tied, grow cols. | |
| // Smaller shrinks the larger dim; if tied, shrink rows. | |
| static SIZE_STEPS = [7, 9, 11, 13, 15]; | |
| bumpSize(rows, cols, direction) { | |
| const MIN = 7, MAX = 15; | |
| if (direction === 'larger') { | |
| if (rows >= MAX && cols >= MAX) return { rows, cols }; | |
| if (cols < rows) return { rows, cols: cols + 2 }; // grow smaller (cols) | |
| if (rows < cols) return { rows: rows + 2, cols }; // grow smaller (rows) | |
| // tied: grow cols if possible, else rows | |
| return cols < MAX ? { rows, cols: cols + 2 } : { rows: rows + 2, cols }; | |
| } else { // 'smaller' | |
| if (rows <= MIN && cols <= MIN) return { rows, cols }; | |
| if (cols > rows) return { rows, cols: cols - 2 }; // shrink larger (cols) | |
| if (rows > cols) return { rows: rows - 2, cols }; // shrink larger (rows) | |
| // tied: shrink rows if possible, else cols | |
| return rows > MIN ? { rows: rows - 2, cols } : { rows, cols: cols - 2 }; | |
| } | |
| } | |
| canBump(rows, cols, direction) { | |
| const after = this.bumpSize(rows, cols, direction); | |
| return after.rows !== rows || after.cols !== cols; | |
| } | |
| // The progression-overlay state is stored on a per-overlay basis ('win' or 'over') | |
| // so opening the other overlay doesn't inherit the first's tweaks. | |
| primeProgressionOverlay(which) { | |
| const direction = (which === 'win') ? 'larger' : 'smaller'; | |
| // Start from whatever board the player just finished. | |
| const startRows = this.rows; | |
| const startCols = this.columns; | |
| // Auto-bump in the appropriate direction (no-op if already at the bound). | |
| const bumped = this.bumpSize(startRows, startCols, direction); | |
| this.progressionChoice = { | |
| which, | |
| startRows, startCols, | |
| rows: bumped.rows, | |
| cols: bumped.cols, | |
| template: this.boardTemplate, | |
| }; | |
| this.refreshProgressionUI(); | |
| } | |
| adjustProgressionSize(direction) { | |
| if (!this.progressionChoice) return; | |
| const c = this.progressionChoice; | |
| const after = this.bumpSize(c.rows, c.cols, direction); | |
| c.rows = after.rows; | |
| c.cols = after.cols; | |
| this.refreshProgressionUI(); | |
| soundEngine.play('click'); | |
| } | |
| setProgressionTemplate(template) { | |
| if (!this.progressionChoice) return; | |
| this.progressionChoice.template = template; | |
| this.refreshProgressionUI(); | |
| } | |
| refreshProgressionUI() { | |
| if (!this.progressionChoice) return; | |
| const c = this.progressionChoice; | |
| const prefix = c.which === 'win' ? 'gameWin' : 'gameOver'; | |
| // Shape select | |
| const sel = document.getElementById(prefix + '-shape'); | |
| if (sel && sel.value !== c.template) sel.value = c.template; | |
| // Smaller/Larger button enabled state | |
| const smaller = document.getElementById(prefix + '-smaller'); | |
| const larger = document.getElementById(prefix + '-larger'); | |
| if (smaller) smaller.disabled = !this.canBump(c.rows, c.cols, 'smaller'); | |
| if (larger) larger.disabled = !this.canBump(c.rows, c.cols, 'larger'); | |
| // Hint | |
| const hint = document.getElementById(prefix + '-hint'); | |
| if (hint) { | |
| const sameSize = (c.rows === c.startRows && c.cols === c.startCols); | |
| if (sameSize) { | |
| hint.innerHTML = `Next: <span class="next-size">${c.rows} × ${c.cols}</span>`; | |
| } else { | |
| hint.innerHTML = `${c.startRows} × ${c.startCols} → <span class="next-size">${c.rows} × ${c.cols}</span>`; | |
| } | |
| } | |
| } | |
| commitProgressionChoice() { | |
| if (!this.progressionChoice) return; | |
| const c = this.progressionChoice; | |
| this.nextLevelChoice = { rows: c.rows, cols: c.cols, template: c.template }; | |
| this.progressionChoice = null; | |
| } | |
| isLevelInProgress() { | |
| // A level is "in progress" if neither end-of-level overlay is open. | |
| const winEl = document.getElementById('gameWin'); | |
| const overEl = document.getElementById('gameOver'); | |
| const winOpen = winEl && winEl.classList.contains('visible'); | |
| const overOpen = overEl && overEl.classList.contains('visible'); | |
| return !winOpen && !overOpen; | |
| } | |
| endGame() { | |
| this.pauseGame(true); | |
| this.setRotationButtonsEnabled(false); | |
| soundEngine.play('lose'); | |
| this.primeProgressionOverlay('over'); | |
| ui.openOverlay('gameOver'); | |
| } | |
| winLevel() { | |
| this.pauseGame(true); | |
| this.setRotationButtonsEnabled(false); | |
| soundEngine.play('win'); | |
| this.primeProgressionOverlay('win'); | |
| ui.openOverlay('gameWin'); | |
| } | |
| pauseGame(isPaused) { | |
| this.gamePaused = isPaused; | |
| this.isAnimating = isPaused; | |
| this.setRotationButtonsEnabled(!isPaused); | |
| } | |
| updateScore() { | |
| this.scoreElement.textContent = this.score; | |
| } | |
| clearTubeConnections() { | |
| this.tubeConnections.forEach(t => t.remove()); | |
| this.tubeConnections = []; | |
| this.shapes.flat().filter(s => s).forEach(s => s.mesh.material.emissive.setHex(0x000000)); | |
| } | |
| highlightConnectedGroup(shape) { | |
| const connected = this.findConnectedShapes(shape.row, shape.col, shape.shapeType); | |
| if (connected.length < this.minGroupSize) return; | |
| connected.map(({row,col}) => this.shapes[row][col]).forEach(s => s.mesh.material.emissive.setHex(0x555555)); | |
| this.createTubesBetweenConnected(connected); | |
| } | |
| createTubesBetweenConnected(points) { | |
| const memo = new Set(points.map(p => `${p.row},${p.col}`)); | |
| const connections = new Set(); | |
| points.forEach(({row,col}) => { | |
| for (let dr=-1;dr<=1;dr++) for (let dc=-1;dc<=1;dc++) { | |
| if (dr===0 && dc===0) continue; | |
| const nr=row+dr, nc=col+dc; | |
| if (memo.has(`${nr},${nc}`)) { | |
| // Canonical undirected-edge ID: the two cell keys, lexicographically | |
| // ordered, separated by '|'. (The previous flat-sort of all four | |
| // coordinates collided: e.g. {(r,c)-(r+1,c+1)} and {(r,c+1)-(r+1,c)} | |
| // both sorted to "r,r+1,c,c+1" — dropping one diagonal of every | |
| // monochrome 2x2 in the hover group.) | |
| const a = `${row},${col}`, b = `${nr},${nc}`; | |
| const id = a < b ? `${a}|${b}` : `${b}|${a}`; | |
| if (!connections.has(id)) { | |
| connections.add(id); | |
| this.tubeConnections.push(new TubeConnection(this.shapes[row][col], this.shapes[nr][nc], this.scene)); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| update() { | |
| if (!this.gamePaused) { | |
| this.prizes.forEach(p => { if (!p.collected && p.prizeObj) p.prizeObj.rotate(); }); | |
| } | |
| } | |
| render() { | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — GAME STATES | |
| // ============================================ | |
| let splashScene, game; | |
| class SplashState extends GameState { | |
| enter() { | |
| showScreen('splash-screen'); | |
| if (splashScene) splashScene.startAnimation(); | |
| } | |
| exit() { | |
| if (splashScene) splashScene.stopAnimation(); | |
| } | |
| update() { | |
| if (splashScene) splashScene.update(); | |
| } | |
| render() { | |
| if (splashScene) splashScene.render(); | |
| } | |
| onResize() { | |
| if (splashScene) splashScene.onWindowResize(); | |
| } | |
| } | |
| class SettingsState extends GameState { | |
| enter() { | |
| showScreen('settings-screen'); | |
| } | |
| exit() { | |
| saveSettingsFromUI(); | |
| } | |
| } | |
| class PlayState extends GameState { | |
| constructor(startFresh = true) { | |
| super(); | |
| this._startFresh = startFresh; | |
| } | |
| enter() { | |
| showScreen('game-screen'); | |
| if (this._startFresh) { | |
| game.createNewBoard(); | |
| } else { | |
| game.pauseGame(false); | |
| } | |
| } | |
| update() { | |
| game.update(); | |
| } | |
| render() { | |
| game.render(); | |
| } | |
| onTap(x, y) { | |
| game.handleTap(x, y); | |
| } | |
| onHover(x, y) { | |
| game.handleHover(x, y); | |
| } | |
| onResize() { | |
| game.onWindowResize(); | |
| } | |
| } | |
| // ============================================ | |
| // EIGHT NEIGHBORS — BOOT VIA Unboxed.run() | |
| // ============================================ | |
| Unboxed.run({ | |
| scene: false, | |
| loop: () => { | |
| TWEEN.update(); | |
| }, | |
| input: { | |
| excludeSelectors: ['#splash-screen', '#settings-screen', '#gameOver', '#gameWin', '#abandonConfirm', '#game-ui'] | |
| }, | |
| sounds: { | |
| pop: (e, delay = 0, waveIdx = 0) => { | |
| const freqs = [523, 587, 659, 698, 784, 880, 988, 1047]; | |
| const freq = freqs[waveIdx % freqs.length]; | |
| setTimeout(() => e.playTone(freq, 0.15, 'sine', 0.06), delay); | |
| }, | |
| explode: (e) => { | |
| e.playSweep(800, 200, 0.3, 'sawtooth', 0.08); | |
| }, | |
| collect: (e) => { | |
| e.playChord([784, 988, 1175], 0.4, 'triangle', 0.07); | |
| }, | |
| rotate: (e) => { | |
| e.playSweep(300, 600, 0.3, 'sine', 0.06); | |
| }, | |
| win: (e) => { | |
| e.playChord([523, 659, 784], 0.3, 'triangle', 0.08); | |
| setTimeout(() => e.playChord([587, 740, 880], 0.3, 'triangle', 0.08), 200); | |
| setTimeout(() => e.playChord([659, 784, 1047], 0.5, 'triangle', 0.1), 400); | |
| }, | |
| lose: (e) => { | |
| e.playChord([392, 349, 330], 0.5, 'sawtooth', 0.06); | |
| }, | |
| click: (e) => { | |
| e.playTone(150, 0.1, 'sine', 0.12); | |
| e.playTone(300, 0.1, 'square', 0.06); | |
| }, | |
| newBoard: (e) => { | |
| e.playSweep(200, 800, 0.4, 'sine', 0.05); | |
| } | |
| } | |
| }); | |
| splashScene = new SplashScene(); | |
| game = new BubblePopper(); | |
| hydrateSettingsUI(); | |
| const soundEnabled = settingsManager.get('soundEnabled'); | |
| soundEngine.setEnabled(soundEnabled !== false); | |
| const soundBtn = document.getElementById('sound-toggle-btn'); | |
| soundBtn.textContent = soundEngine.isEnabled() ? '🔊' : '🔇'; | |
| soundBtn.classList.toggle('muted', !soundEngine.isEnabled()); | |
| ui.setupButton('sound-toggle-btn', () => { | |
| const enabled = !soundEngine.isEnabled(); | |
| soundEngine.setEnabled(enabled); | |
| settingsManager.set('soundEnabled', enabled); | |
| soundBtn.textContent = enabled ? '🔊' : '🔇'; | |
| soundBtn.classList.toggle('muted', !enabled); | |
| if (enabled) soundEngine.play('click'); | |
| }); | |
| ui.setupButton('start-game-btn', () => { | |
| soundEngine.play('click'); | |
| saveSettingsFromUI(); | |
| stateManager.switch(new PlayState(true)); | |
| }); | |
| ui.setupButton('settings-btn', () => { | |
| soundEngine.play('click'); | |
| stateManager.switch(new SettingsState()); | |
| }); | |
| ui.setupButton('back-to-splash-btn', () => { | |
| soundEngine.play('click'); | |
| stateManager.switch(new SplashState()); | |
| }); | |
| ui.setupButton('game-menu-btn', () => { | |
| soundEngine.play('click'); | |
| // Confirm abandon only when the player has actually committed a move on | |
| // the current board. A pristine, untapped board is identical to one they | |
| // could re-deal from the splash, so there's nothing to lose by leaving — | |
| // they almost certainly want to change settings. | |
| if (game.isLevelInProgress() && game.hasMadeMove) { | |
| ui.openOverlay('abandonConfirm'); | |
| } else { | |
| game.pauseGame(true); | |
| stateManager.switch(new SplashState()); | |
| } | |
| }); | |
| ui.setupButton('abandon-cancel', () => { | |
| soundEngine.play('click'); | |
| ui.closeOverlay('abandonConfirm'); | |
| }); | |
| ui.setupButton('abandon-confirm', () => { | |
| soundEngine.play('click'); | |
| ui.closeOverlay('abandonConfirm'); | |
| game.pauseGame(true); | |
| stateManager.switch(new SplashState()); | |
| }); | |
| ui.setupButton('restartGame', () => { | |
| soundEngine.play('click'); | |
| game.commitProgressionChoice(); | |
| ui.closeOverlay('gameOver'); | |
| game.createNewBoard(); | |
| }); | |
| ui.setupButton('nextLevel', () => { | |
| soundEngine.play('click'); | |
| game.commitProgressionChoice(); | |
| ui.closeOverlay('gameWin'); | |
| game.createNewBoard(); | |
| }); | |
| // Progression controls: smaller / larger / shape, on both overlays | |
| ui.setupButton('gameWin-smaller', () => game.adjustProgressionSize('smaller')); | |
| ui.setupButton('gameWin-larger', () => game.adjustProgressionSize('larger')); | |
| ui.setupButton('gameOver-smaller', () => game.adjustProgressionSize('smaller')); | |
| ui.setupButton('gameOver-larger', () => game.adjustProgressionSize('larger')); | |
| document.getElementById('gameWin-shape').addEventListener('change', (e) => { | |
| game.setProgressionTemplate(e.target.value); | |
| }); | |
| document.getElementById('gameOver-shape').addEventListener('change', (e) => { | |
| game.setProgressionTemplate(e.target.value); | |
| }); | |
| ui.setupButton('rotate-left-btn', () => { | |
| game.rotateBoard('left'); | |
| }); | |
| ui.setupButton('rotate-right-btn', () => { | |
| game.rotateBoard('right'); | |
| }); | |
| // --- Keyboard rotation: arrow keys during PlayState --- | |
| // rotateBoard() already gates on isAnimating/gamePaused, so calls during | |
| // pops, falls, or open overlays are silent no-ops. Holding the key produces | |
| // at most one rotation per ~800ms (the rotation animation duration). | |
| window.addEventListener('keydown', (e) => { | |
| if (!(stateManager.current() instanceof PlayState)) return; | |
| const ae = document.activeElement; | |
| if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return; | |
| if (e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| game.rotateBoard('left'); | |
| } else if (e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| game.rotateBoard('right'); | |
| } | |
| }); | |
| // --- Touch swipe rotation: horizontal drag across the play surface --- | |
| // Coexists with InputManager's tap detection: that fires only for movements | |
| // <= 15px, while we require >= 60px AND a dominant horizontal axis. A swipe | |
| // will never accidentally pop a piece, and a tap will never accidentally | |
| // rotate the board. | |
| (() => { | |
| const container = document.getElementById('container'); | |
| const SWIPE_DIST = 60; // px; min horizontal travel | |
| const SWIPE_RATIO = 1.5; // |dx| must exceed this * |dy| | |
| const SWIPE_MAX_TIME = 600; // ms; slow drags don't count | |
| let start = null; | |
| container.addEventListener('touchstart', (e) => { | |
| if (e.touches.length !== 1) { start = null; return; } | |
| const t = e.touches[0]; | |
| start = { x: t.clientX, y: t.clientY, time: Date.now() }; | |
| }, { passive: true }); | |
| container.addEventListener('touchend', (e) => { | |
| if (!start) return; | |
| const s = start; start = null; | |
| if (!(stateManager.current() instanceof PlayState)) return; | |
| const t = e.changedTouches[0]; | |
| const dx = t.clientX - s.x; | |
| const dy = t.clientY - s.y; | |
| const dt = Date.now() - s.time; | |
| if (dt > SWIPE_MAX_TIME) return; | |
| if (Math.abs(dx) < SWIPE_DIST) return; | |
| if (Math.abs(dx) < SWIPE_RATIO * Math.abs(dy)) return; | |
| game.rotateBoard(dx > 0 ? 'right' : 'left'); | |
| }, { passive: true }); | |
| })(); | |
| stateManager.switch(new SplashState()); | |
| Unboxed.start(); | |
| </script> | |
| </body> | |
| </html> |