Skip to content

Instantly share code, notes, and snippets.

@aldoyh
Created June 23, 2026 21:24
Show Gist options
  • Select an option

  • Save aldoyh/3f51ff35ab7a03175143983e739a7727 to your computer and use it in GitHub Desktop.

Select an option

Save aldoyh/3f51ff35ab7a03175143983e739a7727 to your computer and use it in GitHub Desktop.
[GAME] Eight Neighbors
<!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">&nbsp;</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">&nbsp;</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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment