Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active September 10, 2025 20:02
Show Gist options
  • Select an option

  • Save lardratboy/63abf0de5534e8a521cc1b458a41e0ed to your computer and use it in GitHub Desktop.

Select an option

Save lardratboy/63abf0de5534e8a521cc1b458a41e0ed to your computer and use it in GitHub Desktop.
Eight Neighbors Game Prototype
<!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: Fireworks Edition</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);
}
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%;
}
/* --- 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); }
}
/* 3D Canvas for splash screen */
#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 #000000,
-4px -4px 0px #000000,
4px -4px 0px #000000,
-4px 4px 0px #000000,
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 #000000;
color: #ffffff;
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 #000000;
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%);
border-color: #000000;
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%);
border-color: #000000;
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%);
border-color: #000000;
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 (smaller and fewer to complement 3D 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;
}
.setting-item label {
font-size: 1.1rem;
font-weight: 600;
}
.setting-item select {
padding: 10px;
border-radius: 8px;
border: 2px solid #000000;
background-color: #ffffff;
color: #000000;
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%;
}
</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" selected>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" selected>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="random">Random</option>
</select>
</div>
<div class="setting-item">
<label for="mingroup-setting">Min Group Size</label>
<select id="mingroup-setting">
<option value="2" selected>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">↺</button>
<button id="rotate-right-btn" class="rotate-btn" title="Rotate Right">↻</button>
</div>
<div id="game-score">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">Play Again</button>
</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>
</div>
<script>
// --- SCREEN MANAGEMENT ---
const screens = document.querySelectorAll('.screen');
const gameOverOverlay = document.getElementById('gameOver');
const gameWinOverlay = document.getElementById('gameWin');
function showScreen(screenId) {
screens.forEach(screen => screen.classList.remove('active'));
const activeScreen = document.getElementById(screenId);
if (activeScreen) activeScreen.classList.add('active');
// Handle splash screen 3D scene
if (screenId === 'splash-screen' && window.splashScene) {
window.splashScene.startAnimation();
} else if (window.splashScene) {
window.splashScene.stopAnimation();
}
}
function showOverlay(overlayElement) {
overlayElement.classList.add('visible');
}
function hideOverlay(overlayElement) {
overlayElement.classList.remove('visible');
}
// --- SPLASH SCREEN 3D 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 // Enable transparency
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(0x000000, 0); // Transparent background
this.container.appendChild(this.renderer.domElement);
this.camera.position.z = 8;
this.shapes = [];
this.isAnimating = false;
// Shape atlas from the game
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();
window.addEventListener('resize', this.onWindowResize.bind(this));
}
setupLighting() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0x6b46c1, 0.4);
directionalLight2.position.set(-5, -5, 3);
this.scene.add(directionalLight2);
}
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 shapeData = this.shapeAtlas[i % this.shapeAtlas.length];
const size = 0.4 + Math.random() * 0.3;
const geometry = shapeData.geometryFactory(size);
const material = new THREE.MeshPhongMaterial({
color: shapeData.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);
// Random initial rotation
mesh.rotation.set(
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2
);
// Animation properties
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);
// Pop-in animation
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;
this.animate();
}
stopAnimation() {
this.isAnimating = false;
}
animate() {
if (!this.isAnimating) return;
requestAnimationFrame(this.animate.bind(this));
TWEEN.update();
const time = Date.now() * 0.001;
this.shapes.forEach(shape => {
const userData = shape.userData;
// Floating animation
shape.position.y = userData.originalPosition.y +
Math.sin(time * userData.floatSpeed + userData.floatOffset) * 0.5;
shape.position.x = userData.originalPosition.x +
Math.cos(time * userData.floatSpeed * 0.7 + userData.floatOffset) * 0.2;
// Rotation animation
shape.rotation.x += userData.rotationSpeed.x;
shape.rotation.y += userData.rotationSpeed.y;
shape.rotation.z += userData.rotationSpeed.z;
});
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);
}
}
// --- GLOBAL UI EVENT LISTENERS ---
document.addEventListener('DOMContentLoaded', () => {
// Initialize splash scene
window.splashScene = new SplashScene();
window.splashScene.startAnimation();
const game = new BubblePopper();
game.animate();
document.getElementById('start-game-btn').addEventListener('click', () => {
showScreen('game-screen');
game.createNewBoard();
});
document.getElementById('settings-btn').addEventListener('click', () => showScreen('settings-screen'));
document.getElementById('back-to-splash-btn').addEventListener('click', () => showScreen('splash-screen'));
document.getElementById('game-menu-btn').addEventListener('click', () => {
game.pauseGame(true);
showScreen('splash-screen');
});
document.getElementById('restartGame').addEventListener('click', () => {
hideOverlay(gameOverOverlay);
game.createNewBoard();
});
document.getElementById('nextLevel').addEventListener('click', () => {
hideOverlay(gameWinOverlay);
game.createNewBoard();
});
document.getElementById('rotate-left-btn').addEventListener('click', () => {
game.rotateBoard('left');
});
document.getElementById('rotate-right-btn').addEventListener('click', () => {
game.rotateBoard('right');
});
});
// --- TUBE CONNECTION CLASS ---
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;
}
}
}
// --- 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;
this.originalScale = { x: 1, y: 1, z: 1 };
}
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();
}
// New fireworks-style explosion with grow then pop
explode(delay = 0, onComplete) {
// First stage: Grow dramatically
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);
// Second stage: Pop out cleanly
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();
});
// Chain the animations: grow then pop
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();
}
}
// --- 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();
}
}
// --- MAIN GAME CLASS ---
class BubblePopper {
constructor() {
this.init();
}
init() {
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.scoreElement = document.getElementById('game-score');
this.gameOverElement = document.getElementById('gameOver');
this.gameWinElement = document.getElementById('gameWin');
this.rotateLeftBtn = document.getElementById('rotate-left-btn');
this.rotateRightBtn = document.getElementById('rotate-right-btn');
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 7.5);
this.scene.add(directionalLight);
this.defineBoardTemplates();
window.addEventListener('resize', this.onWindowResize.bind(this));
this.container.addEventListener('mousedown', this.onPointerDown.bind(this));
this.container.addEventListener('mousemove', this.onPointerMove.bind(this));
this.container.addEventListener('touchstart', this.onPointerDown.bind(this), { passive: false });
this.container.addEventListener('touchmove', this.onPointerMove.bind(this), { passive: false });
}
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;
},
random: (r, c) => Array(r).fill().map(() => Array(c).fill(false)).map(row => row.map(() => Math.random() > 0.4))
};
}
// --- Unified Input Handlers ---
getPointerCoordinates(e) {
const eventX = e.touches ? e.touches[0].clientX : e.clientX;
const eventY = e.touches ? e.touches[0].clientY : e.clientY;
this.pointerPos.x = (eventX / window.innerWidth) * 2 - 1;
this.pointerPos.y = -(eventY / window.innerHeight) * 2 + 1;
}
onPointerMove(e) {
if (this.isAnimating || this.gamePaused) return;
this.getPointerCoordinates(e);
this.raycaster.setFromCamera(this.pointerPos, this.camera);
// Only check shape meshes, not prizes
const shapeMeshes = [];
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) {
shapeMeshes.push(this.shapes[i][j].mesh);
}
}
}
const intersects = this.raycaster.intersectObjects(shapeMeshes);
let newHoveredShape = null;
if (intersects.length > 0) {
const obj = intersects[0].object;
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 === obj) {
newHoveredShape = this.shapes[i][j];
break;
}
}
}
if (newHoveredShape !== this.hoveredShape) {
this.clearTubeConnections();
if (newHoveredShape) {
this.hoveredShape = newHoveredShape;
this.highlightConnectedGroup(newHoveredShape);
} else {
this.hoveredShape = null;
}
}
}
onPointerDown(e) {
if (e.type === 'touchstart') e.preventDefault();
if (this.isAnimating || this.gamePaused) return;
this.getPointerCoordinates(e);
this.raycaster.setFromCamera(this.pointerPos, this.camera);
// Only check shape meshes, not prizes
const shapeMeshes = [];
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) {
shapeMeshes.push(this.shapes[i][j].mesh);
}
}
}
const intersects = this.raycaster.intersectObjects(shapeMeshes);
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
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 === clickedObject) {
this.clearTubeConnections();
this.processShapeClick(i, j);
return;
}
}
}
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.positionBoardElements();
// Update splash scene if it exists
if (window.splashScene) {
window.splashScene.onWindowResize();
}
}
// --- Board Setup and Management ---
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.updateScore();
this.pauseGame(false);
this.isAnimating = true;
this.setRotationButtonsEnabled(false);
this.rows = parseInt(document.getElementById('rows-setting').value);
this.columns = parseInt(document.getElementById('cols-setting').value);
this.boardTemplate = document.getElementById('template-setting').value;
this.minGroupSize = parseInt(document.getElementById('mingroup-setting').value);
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();
}
generateBoardState() {
this.clearBoard(false);
this.boardLayout = this.boardTemplates[this.boardTemplate](this.rows, this.columns);
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 (this.boardLayout[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 (!this.boardLayout[i][j]) continue;
const shapeData = this.shapeAtlas[Math.floor(Math.random() * this.shapeAtlas.length)];
const x = offsetX + j * 1.1;
const 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);
}
// --- Board Rotation ---
rotateBoard(direction) {
if (this.isAnimating || this.gamePaused) return;
this.isAnimating = true;
this.clearTubeConnections();
this.setRotationButtonsEnabled(false);
// Store rotation direction for animation
this.lastRotationDirection = direction;
// First animate the visual rotation, then update data structures
this.animateVisualRotation(() => {
// After visual rotation completes, update the logical board structure
const rotatedLayout = this.rotateArray2D(this.boardLayout, direction);
const rotatedShapes = this.rotateArray2D(this.shapes, direction);
// Store old dimensions
const oldRows = this.rows;
const oldCols = this.columns;
// Update dimensions (they swap during rotation)
this.rows = oldCols;
this.columns = oldRows;
// Update board layout and shapes
this.boardLayout = rotatedLayout;
this.shapes = rotatedShapes;
// Update row/col properties for all shapes and rotate prizes
this.updateShapePositions(direction);
this.rotatePrizes(direction, oldRows, oldCols);
// Snap pieces to proper grid positions
this.positionBoardElements();
// Apply gravity after a brief pause
setTimeout(() => this.applyGravity(), 200);
});
}
rotateArray2D(array, direction) {
const rows = array.length;
const 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') {
// Clockwise: new[j][rows-1-i] = old[i][j]
rotated[j][rows - 1 - i] = array[i][j];
} else {
// Counter-clockwise: new[cols-1-j][i] = old[i][j]
rotated[cols - 1 - j][i] = array[i][j];
}
}
}
return rotated;
}
updateShapePositions(direction) {
// Update row/col properties for all shapes after rotation
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) {
// Update prize positions after board rotation
this.prizes.forEach(prize => {
const oldRow = prize.row;
const oldCol = prize.col;
if (direction === 'right') {
prize.row = oldCol;
prize.col = oldRows - 1 - oldRow;
} else {
prize.row = oldCols - 1 - oldCol;
prize.col = oldRow;
}
});
}
animateVisualRotation(onComplete) {
let animationsPending = 0;
const animationDuration = 800;
// Calculate board center for rotation
const boardCenterX = 0; // Board is already centered at origin
const boardCenterY = 0;
const onAnimationComplete = () => {
if (--animationsPending === 0) {
if (onComplete) onComplete();
}
};
// Animate all shapes rotating around board center
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.columns; j++) {
const shape = this.shapes[i]?.[j];
if (shape) {
const currentPos = shape.mesh.position;
// Calculate the rotated position around board center
const relativeX = currentPos.x - boardCenterX;
const relativeY = currentPos.y - boardCenterY;
let newRelativeX, newRelativeY;
if (this.lastRotationDirection === 'right') {
// 90° clockwise: (x,y) -> (y,-x)
newRelativeX = relativeY;
newRelativeY = -relativeX;
} else {
// 90° counter-clockwise: (x,y) -> (-y,x)
newRelativeX = -relativeY;
newRelativeY = relativeX;
}
const finalX = boardCenterX + newRelativeX;
const finalY = boardCenterY + newRelativeY;
animationsPending++;
new TWEEN.Tween(shape.mesh.position)
.to({ x: finalX, y: finalY }, animationDuration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onComplete(onAnimationComplete)
.start();
}
}
}
// Animate prizes rotating around board center
this.prizes.forEach(prize => {
if (prize.prizeObj) {
const currentPos = prize.prizeObj.mesh.position;
// Calculate the rotated position around board center
const relativeX = currentPos.x - boardCenterX;
const relativeY = currentPos.y - boardCenterY;
let newRelativeX, newRelativeY;
if (this.lastRotationDirection === 'right') {
// 90° clockwise: (x,y) -> (y,-x)
newRelativeX = relativeY;
newRelativeY = -relativeX;
} else {
// 90° counter-clockwise: (x,y) -> (-y,x)
newRelativeX = -relativeY;
newRelativeY = relativeX;
}
const finalX = boardCenterX + newRelativeX;
const finalY = boardCenterY + newRelativeY;
animationsPending++;
new TWEEN.Tween(prize.prizeObj.mesh.position)
.to({ x: finalX, y: finalY }, animationDuration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onComplete(onAnimationComplete)
.start();
}
});
if (animationsPending === 0) {
if (onComplete) onComplete();
}
}
setRotationButtonsEnabled(enabled) {
this.rotateLeftBtn.disabled = !enabled;
this.rotateRightBtn.disabled = !enabled;
}
// --- Gameplay Logic ---
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);
// Calculate distance from clicked piece for fireworks timing
const clickedRow = row;
const clickedCol = col;
const explosionWaves = this.groupShapesByDistance(connected, clickedRow, clickedCol);
this.triggerFireworksExplosion(explosionWaves, connected);
}
// Group shapes by their distance from the clicked shape for wave-based explosions
groupShapesByDistance(connected, clickedRow, clickedCol) {
const waves = {};
connected.forEach(({ row, col }) => {
const distance = Math.max(Math.abs(row - clickedRow), Math.abs(col - clickedCol));
if (!waves[distance]) waves[distance] = [];
waves[distance].push({ row, col });
});
return waves;
}
// Trigger the fireworks explosion with staggered timing
triggerFireworksExplosion(explosionWaves, allConnected) {
this.score += allConnected.length * 10;
let callbacksPending = allConnected.length;
const onExplosionComplete = () => {
if (--callbacksPending === 0) {
// Clear the shapes from the board after all explosions
allConnected.forEach(({ row: r, col: c }) => {
if (this.shapes[r]) this.shapes[r][c] = null
});
this.updateScore();
setTimeout(() => this.applyGravity(), 200);
}
};
// Explode each wave with increasing delay
const waveDelays = Object.keys(explosionWaves).sort((a, b) => parseInt(a) - parseInt(b));
waveDelays.forEach((distance, waveIndex) => {
const wave = explosionWaves[distance];
const baseDelay = waveIndex * 80; // 80ms between waves for snappy timing
wave.forEach((pos, pieceIndex) => {
const { row: r, col: c } = pos;
const shapeToRemove = this.shapes[r]?.[c];
if (shapeToRemove) {
// Small random offset within the wave for more natural explosion
const pieceDelay = baseDelay + (pieceIndex * 15);
// Handle prize collection
const prizeIndex = this.prizes.findIndex(p => p.row === r && p.col === c && !p.collected);
if (prizeIndex !== -1) {
this.prizes[prizeIndex].collected = true;
this.score += 50;
if (this.prizes[prizeIndex].prizeObj) {
setTimeout(() => {
this.prizes[prizeIndex].prizeObj.collect();
}, pieceDelay);
}
}
// Trigger the fireworks explosion
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 animationsPending = 0;
const offsetY = ((this.rows - 1) * this.spacing) / 2;
const onGravityComplete = () => {
if (--animationsPending === 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;
animationsPending++;
new TWEEN.Tween(shape.mesh.position).to({ y: newY }, 500)
.easing(TWEEN.Easing.Bounce.Out).onComplete(onGravityComplete).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 (animationsPending === 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 animationsPending = 0;
const newColCount = this.columns - emptyCols.length;
const offsetX = -((newColCount - 1) * this.spacing) / 2;
const onShiftComplete = () => {
if (--animationsPending === 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) {
animationsPending++;
new TWEEN.Tween(shape.mesh.position).to({ x: newX }, 500)
.easing(TWEEN.Easing.Quadratic.Out).onComplete(onShiftComplete).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 (animationsPending === 0) this.finalizeColumnRemoval(emptyCols);
} else {
this.checkGameState();
}
}
finalizeColumnRemoval(emptyCols) {
// This function now runs AFTER the shift animations are complete.
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;
// Update internal col property for all shapes and prizes
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;
});
// Recenter camera based on the new final state
this.positionBoardElements();
this.checkGameState();
}
// --- Game State and UI Updates ---
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;
}
endGame() {
this.pauseGame(true);
this.setRotationButtonsEnabled(false);
showOverlay(this.gameOverElement);
}
winLevel() {
this.pauseGame(true);
this.setRotationButtonsEnabled(false);
showOverlay(this.gameWinElement);
}
pauseGame(isPaused) {
this.gamePaused = isPaused;
this.isAnimating = isPaused;
this.setRotationButtonsEnabled(!isPaused);
}
updateScore() {
this.scoreElement.textContent = this.score;
}
// --- Highlighting ---
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 pointMemo = new Set(points.map(pos => `${pos.row},${pos.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 (pointMemo.has(`${nr},${nc}`)) {
const id = [row, col, nr, nc].sort((a,b) => a-b).join(',');
if (!connections.has(id)) {
connections.add(id);
this.tubeConnections.push(new TubeConnection(this.shapes[row][col], this.shapes[nr][nc], this.scene));
}
}
}
});
}
// --- Animation Loop ---
animate() {
requestAnimationFrame(this.animate.bind(this));
TWEEN.update();
if(!this.gamePaused) {
this.prizes.forEach(p => {
if (!p.collected && p.prizeObj) p.prizeObj.rotate();
});
}
this.renderer.render(this.scene, this.camera);
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment