View Changes
HTML Changes
<div id="app">
<!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen active">
<div class="header">
<h1>🧩 Connect 3 Puzzle</h1>
<p>Match 3 or more blocks to clear them and create combos!</p>
</div>
<div class="leaderboard-container">
<h2>🏆 Top Scores</h2>
<div id="leaderboard-loading">Loading leaderboard...</div>
<table id="leaderboard-table" style="display: none;">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody id="leaderboard-body">
</tbody>
</table>
</div>
<button id="start-game-btn" class="primary-button">🎮 Start New Game</button>
</div>
<!-- Game Screen -->
<div id="game-screen" class="screen">
<div class="game-header">
<div class="game-info">
<div class="score-display">
<span class="label">Score:</span>
<span id="current-score">0</span>
</div>
<div class="level-display">
<span class="label">Level:</span>
<span id="current-level">1</span>
</div>
<div class="combo-display">
<span id="combo-text"></span>
</div>
</div>
<div class="game-controls">
<div class="volume-control">
<button id="volume-btn" class="control-button">🔊</button>
<div id="volume-slider-container" class="volume-slider-container">
<input type="range" id="volume-slider" class="volume-slider" min="0" max="100" value="70">
</div>
</div>
<button id="pause-btn" class="control-button">⏸️</button>
<button id="back-to-menu-btn" class="control-button">🏠</button>
</div>
</div>
<div class="game-container">
<div id="game-grid" class="game-grid">
<!-- Grid will be generated by JavaScript -->
</div>
+ <div id="game-over-overlay" class="game-over-overlay">
+ <div class="game-over-text">GAME OVER!</div>
+ </div>
</div>
<div class="game-footer">
<div class="next-preview">
<span class="label">Next Row:</span>
<div id="next-row-preview" class="next-row">
<!-- Preview will be generated by JavaScript -->
</div>
</div>
<div class="control-hints">
<div id="gamepad-status" class="gamepad-status"></div>
<div class="keyboard-hints">
<div class="hint-row">
<span>⌨️ <strong>Arrow Keys:</strong> Move cursor</span>
<span><strong>Space/Enter:</strong> Select/Swap</span>
</div>
<div class="hint-row">
<span><strong>Esc:</strong> Pause</span>
<span><strong>R:</strong> Restart (when game over)</span>
</div>
</div>
<div class="gamepad-hints">
<div class="hint-row">
<span>🎮 <strong>D-pad/Stick:</strong> Move cursor</span>
<span><strong>A:</strong> Select/Swap</span>
</div>
<div class="hint-row">
<span><strong>Start:</strong> Pause</span>
<span><strong>RB:</strong> Quick restart</span>
</div>
</div>
</div>
</div>
</div>
<!-- Game Over Modal -->
<div id="game-over-modal" class="modal">
<div class="modal-content">
<h2>🎯 Game Over!</h2>
<div class="final-score">
<span class="label">Final Score:</span>
<span id="final-score-value">0</span>
</div>
<div id="score-message"></div>
<div id="anonymous-message" style="display: none;">
<p>Log in to save your score!</p>
<a href="https://meta.discourse.org" target="_blank" class="login-link">Login to Discourse</a>
</div>
<div class="modal-buttons">
<button id="play-again-btn" class="primary-button">🔄 Play Again</button>
<button id="view-leaderboard-btn" class="secondary-button">🏆 View Leaderboard</button>
</div>
</div>
</div>
<!-- Pause Modal -->
<div id="pause-modal" class="modal">
<div class="modal-content">
<h2>⏸️ Game Paused</h2>
<div class="modal-buttons">
<button id="resume-btn" class="primary-button">▶️ Resume</button>
<button id="quit-btn" class="secondary-button">🏠 Main Menu</button>
</div>
</div>
</div>
</div>
CSS Changes
#app {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
width: 100%;
max-width: 600px;
height: 600px;
margin: 0 auto;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
overflow: hidden;
position: relative;
}
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
flex-direction: column;
padding: 15px;
box-sizing: border-box;
}
.screen.active {
display: flex;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
color: white;
font-size: 1.8em;
margin: 0;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
color: rgba(255,255,255,0.9);
margin: 5px 0 0 0;
font-size: 0.95em;
}
.leaderboard-container {
flex: 1;
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
overflow-y: auto;
}
.leaderboard-container h2 {
margin: 0 0 15px 0;
color: #333;
text-align: center;
}
#leaderboard-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
#leaderboard-table th,
#leaderboard-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
#leaderboard-table th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
#leaderboard-table tr:nth-child(even) {
background: #f8f9fa;
}
.rank-medal {
font-size: 1.2em;
}
.primary-button {
background: linear-gradient(45deg, #ff6b6b, #feca57);
border: none;
color: white;
font-size: 1.2em;
font-weight: 600;
padding: 15px 30px;
border-radius: 25px;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}
.primary-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
}
.secondary-button {
background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.5);
color: white;
font-size: 1em;
font-weight: 600;
padding: 10px 20px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.secondary-button:hover {
background: rgba(255,255,255,0.3);
border-color: rgba(255,255,255,0.8);
}
.game-header {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 10px 15px;
margin-bottom: 8px;
}
.game-info {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.score-display, .level-display {
font-weight: 600;
color: #333;
}
.label {
color: #666;
margin-right: 5px;
}
#current-score, #current-level {
color: #ff6b6b;
font-size: 1.2em;
}
.combo-display {
font-weight: bold;
color: #feca57;
min-width: 80px;
}
.game-controls {
display: flex;
gap: 10px;
align-items: center;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
}
.volume-slider-container {
position: absolute;
top: 50%;
right: 100%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 8px 15px;
margin-right: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 100;
}
.volume-slider-container.active {
opacity: 1;
visibility: visible;
}
.volume-slider {
width: 80px;
height: 4px;
background: #ddd;
border-radius: 2px;
outline: none;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: linear-gradient(45deg, #ff6b6b, #feca57);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.volume-slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: linear-gradient(45deg, #ff6b6b, #feca57);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.volume-slider::-webkit-slider-track {
background: linear-gradient(to right, #ff6b6b 0%, #feca57 100%);
height: 4px;
border-radius: 2px;
}
.volume-slider::-moz-range-track {
background: linear-gradient(to right, #ff6b6b 0%, #feca57 100%);
height: 4px;
border-radius: 2px;
border: none;
}
.control-button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 1.5em;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.control-button:hover {
background: rgba(255,255,255,0.3);
}
.control-button.muted {
opacity: 0.6;
background: rgba(255, 255, 255, 0.1);
}
.sound-pulse {
animation: sound-pulse 0.3s ease-out;
}
@keyframes sound-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); }
}
.game-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 10px 0;
}
.game-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: repeat(12, 1fr);
gap: 2px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 6px;
width: 240px;
height: 320px;
}
.block {
background: rgba(255,255,255,0.9);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
border: 2px solid transparent;
position: relative;
}
.block:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.block.selected {
border-color: #feca57;
box-shadow: 0 0 10px rgba(254, 202, 87, 0.5);
}
.block.cursor {
border-color: #00d4ff;
box-shadow: 0 0 15px rgba(0, 212, 255, 0.8), inset 0 0 10px rgba(0, 212, 255, 0.3);
animation: cursor-pulse 1.5s ease-in-out infinite;
}
.block.cursor.selected {
border-color: #ff6b6b;
box-shadow: 0 0 15px rgba(255, 107, 107, 0.8), 0 0 25px rgba(0, 212, 255, 0.6);
}
@keyframes cursor-pulse {
0%, 100% {
box-shadow: 0 0 15px rgba(0, 212, 255, 0.8), inset 0 0 10px rgba(0, 212, 255, 0.3);
}
50% {
box-shadow: 0 0 25px rgba(0, 212, 255, 1), inset 0 0 15px rgba(0, 212, 255, 0.5);
}
}
.block.falling {
animation: fall 0.4s ease-out;
}
.block.matched {
animation: matched-pop 0.5s ease-out forwards;
}
.block.swapping-right {
animation: swap-right 0.3s ease-in-out;
}
.block.swapping-left {
animation: swap-left 0.3s ease-in-out;
}
.block.swapping-up {
animation: swap-up 0.3s ease-in-out;
}
.block.swapping-down {
animation: swap-down 0.3s ease-in-out;
}
.block.new-row {
animation: slide-up 0.4s ease-out;
}
.block.gravity-fall {
animation: gravity-drop 0.3s ease-in;
}
@keyframes fall {
from {
transform: translateY(-100%);
opacity: 0.7;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes matched-pop {
0% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(1.4) rotate(10deg);
opacity: 1;
background: linear-gradient(45deg, #ff6b6b, #feca57);
}
70% {
transform: scale(1.6) rotate(-5deg);
opacity: 0.8;
}
100% {
transform: scale(0) rotate(180deg);
opacity: 0;
}
}
@keyframes swap-right {
0% { transform: translateX(0); }
50% { transform: translateX(calc(100% + 2px)); }
100% { transform: translateX(0); }
}
@keyframes swap-left {
0% { transform: translateX(0); }
50% { transform: translateX(calc(-100% - 2px)); }
100% { transform: translateX(0); }
}
@keyframes swap-up {
0% { transform: translateY(0); }
50% { transform: translateY(calc(-100% - 2px)); }
100% { transform: translateY(0); }
}
@keyframes swap-down {
0% { transform: translateY(0); }
50% { transform: translateY(calc(100% + 2px)); }
100% { transform: translateY(0); }
}
@keyframes slide-up {
0% {
transform: translateY(100%);
opacity: 0.6;
}
30% {
transform: translateY(50%);
opacity: 0.8;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes gravity-drop {
0% {
transform: translateY(-100%);
opacity: 0.8;
}
60% {
transform: translateY(10%);
opacity: 0.9;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.game-footer {
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 10px 15px;
margin-top: 8px;
}
.control-hints {
margin-top: 10px;
}
.gamepad-status {
text-align: center;
font-size: 0.75em;
color: #28a745;
font-weight: 600;
margin-bottom: 8px;
display: none;
}
.keyboard-hints, .gamepad-hints {
font-size: 0.8em;
color: #666;
margin: 5px 0;
}
.gamepad-hints {
border-top: 1px solid #eee;
padding-top: 8px;
margin-top: 8px;
}
.hint-row {
display: flex;
justify-content: space-between;
margin: 2px 0;
gap: 15px;
}
.hint-row span {
flex: 1;
}
.hint-row strong {
color: #333;
}
.next-row {
display: flex;
gap: 5px;
margin-top: 5px;
}
.next-block {
width: 30px;
height: 30px;
border-radius: 4px;
background: rgba(255,255,255,0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
border: 1px solid #ddd;
}
.modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 30px;
text-align: center;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.modal-content h2 {
margin: 0 0 20px 0;
color: #333;
}
.final-score {
margin: 20px 0;
font-size: 1.3em;
}
#final-score-value {
color: #ff6b6b;
font-weight: bold;
font-size: 1.5em;
}
.modal-buttons {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 25px;
flex-wrap: wrap;
}
.login-link {
display: inline-block;
background: #0088cc;
color: white;
padding: 10px 20px;
border-radius: 20px;
text-decoration: none;
margin: 10px 0;
transition: background 0.3s ease;
}
.login-link:hover {
background: #0066aa;
}
#leaderboard-loading {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
#score-message {
margin: 15px 0;
font-weight: 600;
color: #333;
}
.screen-shake {
animation: shake 0.3s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(-2px) translateY(-1px); }
50% { transform: translateX(2px) translateY(1px); }
75% { transform: translateX(-1px) translateY(-2px); }
}
+.game-over-flash {
+ animation: game-over-flash 0.5s ease-out;
+}
+
+@keyframes game-over-flash {
+ 0% {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ }
+ 30% {
+ background: linear-gradient(135deg, #ff4444 0%, #cc0000 100%);
+ transform: scale(1.02);
+ }
+ 60% {
+ background: linear-gradient(135deg, #ff6666 0%, #ee2222 100%);
+ transform: scale(1.01);
+ }
+ 100% {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ transform: scale(1);
+ }
+}
+
+.game-over-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(255, 0, 0, 0.8);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 500;
+ border-radius: 8px;
+}
+
+.game-over-overlay.active {
+ display: flex;
+ animation: game-over-overlay-appear 0.3s ease-out;
+}
+
+.game-over-text {
+ font-size: 2.5em;
+ font-weight: bold;
+ color: white;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
+ animation: game-over-text-pulse 0.6s ease-in-out;
+}
+
+@keyframes game-over-overlay-appear {
+ 0% {
+ opacity: 0;
+ background: rgba(255, 0, 0, 0);
+ }
+ 100% {
+ opacity: 1;
+ background: rgba(255, 0, 0, 0.8);
+ }
+}
+
+@keyframes game-over-text-pulse {
+ 0% {
+ transform: scale(0.5);
+ opacity: 0;
+ }
+ 50% {
+ transform: scale(1.2);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
.combo-particles {
position: absolute;
pointer-events: none;
width: 100%;
height: 100%;
}
.particle {
position: absolute;
font-size: 1.2em;
animation: particle-burst 1s ease-out forwards;
pointer-events: none;
}
@keyframes particle-burst {
0% {
opacity: 1;
transform: scale(0.5);
}
50% {
opacity: 0.8;
transform: scale(1.2);
}
100% {
opacity: 0;
transform: scale(0.3) translateY(-50px);
}
}
@media (max-width: 480px) {
#app {
border-radius: 0;
height: 100vh;
max-width: 100%;
}
.screen {
padding: 10px;
}
.game-grid {
width: 200px;
height: 280px;
}
.block {
font-size: 1.2em;
}
.game-info {
font-size: 0.85em;
}
.modal-content {
width: 95%;
padding: 20px;
}
.header h1 {
font-size: 1.5em;
}
.header p {
font-size: 0.85em;
}
.keyboard-hints, .gamepad-hints {
font-size: 0.7em;
}
.hint-row {
gap: 10px;
}
}
JS Changes
class GamepadManager {
constructor() {
this.gamepads = {};
this.buttonStates = {};
this.lastButtonStates = {};
this.analogStates = {};
this.deadzone = 0.3;
this.repeatDelay = 200; // ms
this.lastRepeatTime = {};
this.connected = false;
this.buttonMapping = {
FACE_DOWN: 0, // A button - Select/Swap
FACE_RIGHT: 1, // B button - Back/Cancel
FACE_LEFT: 2, // X button - (unused)
FACE_UP: 3, // Y button - (unused)
LEFT_SHOULDER: 4, // LB - Back to menu
RIGHT_SHOULDER: 5, // RB - Quick restart
LEFT_TRIGGER: 6, // LT - (unused)
RIGHT_TRIGGER: 7, // RT - (unused)
SELECT: 8, // Select/Back - Volume toggle
START: 9, // Start - Pause/Resume
LEFT_STICK: 10, // LS click - (unused)
RIGHT_STICK: 11, // RS click - (unused)
DPAD_UP: 12, // D-pad up
DPAD_DOWN: 13, // D-pad down
DPAD_LEFT: 14, // D-pad left
DPAD_RIGHT: 15 // D-pad right
};
this.bindEvents();
}
bindEvents() {
window.addEventListener('gamepadconnected', (e) => {
this.onGamepadConnected(e.gamepad);
});
window.addEventListener('gamepaddisconnected', (e) => {
this.onGamepadDisconnected(e.gamepad);
});
}
onGamepadConnected(gamepad) {
console.log(`Gamepad connected: ${gamepad.id}`);
this.gamepads[gamepad.index] = gamepad;
this.buttonStates[gamepad.index] = {};
this.lastButtonStates[gamepad.index] = {};
this.analogStates[gamepad.index] = { x: 0, y: 0 };
this.lastRepeatTime[gamepad.index] = {};
this.connected = true;
// Update UI to show gamepad is connected
this.updateConnectionStatus();
}
onGamepadDisconnected(gamepad) {
console.log(`Gamepad disconnected: ${gamepad.id}`);
delete this.gamepads[gamepad.index];
delete this.buttonStates[gamepad.index];
delete this.lastButtonStates[gamepad.index];
delete this.analogStates[gamepad.index];
delete this.lastRepeatTime[gamepad.index];
this.connected = Object.keys(this.gamepads).length > 0;
this.updateConnectionStatus();
}
updateConnectionStatus() {
const statusElement = document.getElementById('gamepad-status');
if (statusElement) {
statusElement.textContent = this.connected ? '🎮 Controller Connected' : '';
statusElement.style.display = this.connected ? 'block' : 'none';
}
}
poll() {
const gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; i++) {
if (gamepads[i] && this.gamepads[i]) {
this.gamepads[i] = gamepads[i];
this.updateButtonStates(i);
this.updateAnalogStates(i);
}
}
}
updateButtonStates(gamepadIndex) {
const gamepad = this.gamepads[gamepadIndex];
if (!gamepad) return;
this.lastButtonStates[gamepadIndex] = { ...this.buttonStates[gamepadIndex] };
for (let i = 0; i < gamepad.buttons.length; i++) {
this.buttonStates[gamepadIndex][i] = gamepad.buttons[i].pressed;
}
}
updateAnalogStates(gamepadIndex) {
const gamepad = this.gamepads[gamepadIndex];
if (!gamepad) return;
// Left stick (axes 0 and 1)
const x = Math.abs(gamepad.axes[0]) > this.deadzone ? gamepad.axes[0] : 0;
const y = Math.abs(gamepad.axes[1]) > this.deadzone ? gamepad.axes[1] : 0;
this.analogStates[gamepadIndex] = { x, y };
}
isButtonPressed(button, gamepadIndex = 0) {
return this.buttonStates[gamepadIndex] && this.buttonStates[gamepadIndex][button];
}
isButtonJustPressed(button, gamepadIndex = 0) {
const current = this.buttonStates[gamepadIndex] && this.buttonStates[gamepadIndex][button];
const last = this.lastButtonStates[gamepadIndex] && this.lastButtonStates[gamepadIndex][button];
return current && !last;
}
isDirectionPressed(direction, gamepadIndex = 0) {
const now = Date.now();
const repeatKey = `${gamepadIndex}_${direction}`;
// Check D-pad buttons
let pressed = false;
switch (direction) {
case 'up':
pressed = this.isButtonPressed(this.buttonMapping.DPAD_UP, gamepadIndex);
break;
case 'down':
pressed = this.isButtonPressed(this.buttonMapping.DPAD_DOWN, gamepadIndex);
break;
case 'left':
pressed = this.isButtonPressed(this.buttonMapping.DPAD_LEFT, gamepadIndex);
break;
case 'right':
pressed = this.isButtonPressed(this.buttonMapping.DPAD_RIGHT, gamepadIndex);
break;
}
// Check analog stick
if (!pressed && this.analogStates[gamepadIndex]) {
const { x, y } = this.analogStates[gamepadIndex];
switch (direction) {
case 'up':
pressed = y < -0.5;
break;
case 'down':
pressed = y > 0.5;
break;
case 'left':
pressed = x < -0.5;
break;
case 'right':
pressed = x > 0.5;
break;
}
}
if (!pressed) {
delete this.lastRepeatTime[repeatKey];
return false;
}
// Handle button repeat
const lastTime = this.lastRepeatTime[repeatKey] || 0;
if (now - lastTime >= this.repeatDelay) {
this.lastRepeatTime[repeatKey] = now;
return true;
}
return false;
}
vibrate(duration = 100, strength = 0.5) {
Object.values(this.gamepads).forEach(gamepad => {
if (gamepad.vibrationActuator) {
gamepad.vibrationActuator.playEffect('dual-rumble', {
duration,
strongMagnitude: strength,
weakMagnitude: strength * 0.5
});
}
});
}
}
class SoundManager {
constructor() {
this.audioContext = null;
this.masterGain = null;
this.volume = 0.7;
this.muted = false;
this.isInitialized = false;
// Load volume preferences
this.loadVolumePreferences();
}
async initialize() {
if (this.isInitialized) return;
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioContext.createGain();
this.masterGain.connect(this.audioContext.destination);
this.updateVolume();
this.isInitialized = true;
} catch (error) {
console.warn('Web Audio API not supported:', error);
}
}
updateVolume() {
if (this.masterGain) {
this.masterGain.gain.value = this.muted ? 0 : this.volume;
}
}
setVolume(volume) {
this.volume = Math.max(0, Math.min(1, volume));
this.updateVolume();
this.saveVolumePreferences();
}
toggleMute() {
this.muted = !this.muted;
this.updateVolume();
this.saveVolumePreferences();
}
saveVolumePreferences() {
try {
localStorage.setItem('connect3_volume', this.volume.toString());
localStorage.setItem('connect3_muted', this.muted.toString());
} catch (error) {
// Silently fail if localStorage is not available (sandboxed environment)
console.warn('Volume preferences could not be saved:', error);
}
}
loadVolumePreferences() {
try {
const savedVolume = localStorage.getItem('connect3_volume');
const savedMuted = localStorage.getItem('connect3_muted');
if (savedVolume !== null) {
this.volume = parseFloat(savedVolume);
}
if (savedMuted !== null) {
this.muted = savedMuted === 'true';
}
} catch (error) {
// Use default values if localStorage is not available
console.warn('Volume preferences could not be loaded, using defaults:', error);
this.volume = 0.7;
this.muted = false;
}
}
createOscillator(frequency, type = 'sine', duration = 0.3) {
if (!this.isInitialized) return null;
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, this.audioContext.currentTime);
gainNode.gain.setValueAtTime(0.3, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + duration);
oscillator.connect(gainNode);
gainNode.connect(this.masterGain);
return { oscillator, gainNode, duration };
}
createFilteredNoise(frequency, duration = 0.2) {
if (!this.isInitialized) return null;
const bufferSize = this.audioContext.sampleRate * duration;
const noiseBuffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const whiteNoise = this.audioContext.createBufferSource();
whiteNoise.buffer = noiseBuffer;
const bandpass = this.audioContext.createBiquadFilter();
bandpass.type = 'bandpass';
bandpass.frequency.value = frequency;
bandpass.Q.value = 30;
const gainNode = this.audioContext.createGain();
gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + duration);
whiteNoise.connect(bandpass);
bandpass.connect(gainNode);
gainNode.connect(this.masterGain);
return { source: whiteNoise, duration };
}
playSwapSound() {
if (!this.isInitialized) return;
const sound1 = this.createOscillator(400, 'sine', 0.15);
const sound2 = this.createOscillator(600, 'sine', 0.15);
if (sound1 && sound2) {
sound1.oscillator.start();
sound1.oscillator.stop(this.audioContext.currentTime + sound1.duration);
setTimeout(() => {
sound2.oscillator.start();
sound2.oscillator.stop(this.audioContext.currentTime + sound2.duration);
}, 50);
}
}
playMatchSound(blockCount = 3) {
if (!this.isInitialized) return;
const baseFreq = 220 + (blockCount * 40);
const sound = this.createOscillator(baseFreq, 'triangle', 0.4);
const harmonic = this.createOscillator(baseFreq * 1.5, 'sine', 0.4);
if (sound && harmonic) {
sound.gainNode.gain.setValueAtTime(0.4, this.audioContext.currentTime);
harmonic.gainNode.gain.setValueAtTime(0.2, this.audioContext.currentTime);
sound.oscillator.start();
harmonic.oscillator.start();
sound.oscillator.stop(this.audioContext.currentTime + sound.duration);
harmonic.oscillator.stop(this.audioContext.currentTime + harmonic.duration);
}
}
playComboSound(comboLevel) {
if (!this.isInitialized) return;
const frequencies = [440, 550, 660, 770, 880, 990, 1100, 1210, 1320, 1430];
const freq = frequencies[Math.min(comboLevel - 2, frequencies.length - 1)];
// Main tone
const main = this.createOscillator(freq, 'sawtooth', 0.6);
// Higher harmonic
const high = this.createOscillator(freq * 1.5, 'sine', 0.4);
// Lower bass
const bass = this.createOscillator(freq * 0.5, 'triangle', 0.8);
if (main && high && bass) {
main.gainNode.gain.setValueAtTime(0.3, this.audioContext.currentTime);
high.gainNode.gain.setValueAtTime(0.2, this.audioContext.currentTime);
bass.gainNode.gain.setValueAtTime(0.4, this.audioContext.currentTime);
main.oscillator.start();
high.oscillator.start();
bass.oscillator.start();
main.oscillator.stop(this.audioContext.currentTime + main.duration);
high.oscillator.stop(this.audioContext.currentTime + high.duration);
bass.oscillator.stop(this.audioContext.currentTime + bass.duration);
}
}
playNewRowSound() {
if (!this.isInitialized) return;
const sound = this.createFilteredNoise(800, 0.3);
if (sound) {
sound.source.start();
sound.source.stop(this.audioContext.currentTime + sound.duration);
}
}
playGameOverSound() {
if (!this.isInitialized) return;
const frequencies = [440, 370, 311, 262];
frequencies.forEach((freq, index) => {
setTimeout(() => {
const sound = this.createOscillator(freq, 'square', 0.8);
if (sound) {
sound.oscillator.start();
sound.oscillator.stop(this.audioContext.currentTime + sound.duration);
}
}, index * 200);
});
}
playButtonSound() {
if (!this.isInitialized) return;
const sound = this.createOscillator(800, 'sine', 0.1);
if (sound) {
sound.gainNode.gain.setValueAtTime(0.2, this.audioContext.currentTime);
sound.oscillator.start();
sound.oscillator.stop(this.audioContext.currentTime + sound.duration);
}
}
playLevelUpSound() {
if (!this.isInitialized) return;
const arpeggio = [523, 659, 784, 1047]; // C5, E5, G5, C6
arpeggio.forEach((freq, index) => {
setTimeout(() => {
const sound = this.createOscillator(freq, 'triangle', 0.3);
if (sound) {
sound.oscillator.start();
sound.oscillator.stop(this.audioContext.currentTime + sound.duration);
}
}, index * 100);
});
}
}
class Connect3Game {
constructor() {
this.GRID_WIDTH = 6;
this.GRID_HEIGHT = 12;
this.BLOCK_TYPES = ['🔴', '🔵', '🟡', '🟢', '🟣', '🟠'];
this.grid = [];
this.score = 0;
this.level = 1;
this.combo = 0;
this.gameRunning = false;
this.gamePaused = false;
this.selectedBlock = null;
this.cursorRow = 5;
this.cursorCol = 2;
this.nextRow = [];
this.fallSpeed = 5000; // milliseconds
this.lastFall = 0;
this.currentUser = null;
this.previousLevel = 1;
this.soundManager = new SoundManager();
this.gamepadManager = new GamepadManager();
this.initializeElements();
this.initializeGrid();
this.generateNextRow();
this.bindEvents();
this.loadLeaderboard();
this.checkAuthentication();
this.setupVolumeControls();
}
async checkAuthentication() {
try {
const initData = await window.discourseArtifactReady;
this.currentUser = {
username: initData.username,
name: initData.name,
user_id: initData.user_id
};
} catch (error) {
this.currentUser = null;
}
}
initializeElements() {
this.elements = {
leaderboardScreen: document.getElementById('leaderboard-screen'),
gameScreen: document.getElementById('game-screen'),
gameOverModal: document.getElementById('game-over-modal'),
pauseModal: document.getElementById('pause-modal'),
startGameBtn: document.getElementById('start-game-btn'),
pauseBtn: document.getElementById('pause-btn'),
backToMenuBtn: document.getElementById('back-to-menu-btn'),
resumeBtn: document.getElementById('resume-btn'),
quitBtn: document.getElementById('quit-btn'),
playAgainBtn: document.getElementById('play-again-btn'),
viewLeaderboardBtn: document.getElementById('view-leaderboard-btn'),
gameGrid: document.getElementById('game-grid'),
currentScore: document.getElementById('current-score'),
currentLevel: document.getElementById('current-level'),
comboText: document.getElementById('combo-text'),
finalScoreValue: document.getElementById('final-score-value'),
scoreMessage: document.getElementById('score-message'),
anonymousMessage: document.getElementById('anonymous-message'),
nextRowPreview: document.getElementById('next-row-preview'),
leaderboardBody: document.getElementById('leaderboard-body'),
leaderboardTable: document.getElementById('leaderboard-table'),
leaderboardLoading: document.getElementById('leaderboard-loading'),
volumeBtn: document.getElementById('volume-btn'),
volumeSlider: document.getElementById('volume-slider'),
+ volumeSliderContainer: document.getElementById('volume-slider-container'),
+ gameOverOverlay: document.getElementById('game-over-overlay')
- volumeSliderContainer: document.getElementById('volume-slider-container')
};
}
initializeGrid() {
this.grid = Array(this.GRID_HEIGHT).fill().map(() => Array(this.GRID_WIDTH).fill(null));
this.renderGrid();
}
generateNextRow() {
this.nextRow = Array(this.GRID_WIDTH).fill().map(() =>
this.BLOCK_TYPES[Math.floor(Math.random() * this.BLOCK_TYPES.length)]
);
this.renderNextRowPreview();
}
renderNextRowPreview() {
this.elements.nextRowPreview.innerHTML = '';
this.nextRow.forEach(blockType => {
const block = document.createElement('div');
block.className = 'next-block';
block.textContent = blockType;
this.elements.nextRowPreview.appendChild(block);
});
}
renderGrid() {
this.elements.gameGrid.innerHTML = '';
for (let row = 0; row < this.GRID_HEIGHT; row++) {
for (let col = 0; col < this.GRID_WIDTH; col++) {
const block = document.createElement('div');
block.className = 'block';
block.dataset.row = row;
block.dataset.col = col;
if (this.grid[row][col]) {
block.textContent = this.grid[row][col];
block.style.background = 'rgba(255,255,255,0.9)';
} else {
block.style.background = 'rgba(255,255,255,0.1)';
}
this.elements.gameGrid.appendChild(block);
}
}
this.updateCursor();
}
createParticleEffect(blockElement) {
const rect = blockElement.getBoundingClientRect();
const gridRect = this.elements.gameGrid.getBoundingClientRect();
for (let i = 0; i < 3; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.textContent = '✨';
const offsetX = (rect.left - gridRect.left) + (rect.width / 2);
const offsetY = (rect.top - gridRect.top) + (rect.height / 2);
particle.style.left = offsetX + 'px';
particle.style.top = offsetY + 'px';
particle.style.animationDelay = (i * 100) + 'ms';
this.elements.gameGrid.appendChild(particle);
setTimeout(() => {
if (particle.parentNode) {
particle.parentNode.removeChild(particle);
}
}, 1000);
}
}
bindEvents() {
this.elements.startGameBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.startGame();
});
this.elements.pauseBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.pauseGame();
});
this.elements.backToMenuBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.quitToMenu();
});
this.elements.resumeBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.resumeGame();
});
this.elements.quitBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.quitToMenu();
});
this.elements.playAgainBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.startGame();
});
this.elements.viewLeaderboardBtn.addEventListener('click', () => {
this.soundManager.playButtonSound();
this.showLeaderboard();
});
this.elements.gameGrid.addEventListener('click', (e) => this.handleBlockClick(e));
// Add keyboard event listener
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
}
startGame() {
this.initializeGrid();
this.score = 0;
this.level = 1;
this.combo = 0;
this.gameRunning = true;
this.gamePaused = false;
this.selectedBlock = null;
this.cursorRow = 5;
this.cursorCol = 2;
this.fallSpeed = 5000;
this.lastFall = Date.now();
// Fill initial rows
for (let row = this.GRID_HEIGHT - 4; row < this.GRID_HEIGHT; row++) {
for (let col = 0; col < this.GRID_WIDTH; col++) {
this.grid[row][col] = this.BLOCK_TYPES[Math.floor(Math.random() * this.BLOCK_TYPES.length)];
}
}
this.generateNextRow();
this.updateDisplay();
this.showGameScreen();
this.gameLoop();
}
handleBlockClick(e) {
if (!this.gameRunning || this.gamePaused) return;
const block = e.target;
if (!block.classList.contains('block') || !this.grid[block.dataset.row][block.dataset.col]) return;
const row = parseInt(block.dataset.row);
const col = parseInt(block.dataset.col);
if (this.selectedBlock) {
const selectedRow = this.selectedBlock.row;
const selectedCol = this.selectedBlock.col;
// Check if adjacent horizontally or vertically
if ((row === selectedRow && Math.abs(col - selectedCol) === 1) ||
(col === selectedCol && Math.abs(row - selectedRow) === 1)) {
this.swapBlocks(selectedRow, selectedCol, row, col);
}
this.clearSelection();
} else {
this.selectedBlock = { row, col };
this.updateSelection();
}
}
swapBlocks(row1, col1, row2, col2) {
this.soundManager.playSwapSound();
// Add swap animation
const block1 = this.elements.gameGrid.children[row1 * this.GRID_WIDTH + col1];
const block2 = this.elements.gameGrid.children[row2 * this.GRID_WIDTH + col2];
// Determine swap direction and apply appropriate animations
if (row1 === row2) {
// Horizontal swap
if (col2 > col1) {
block1.classList.add('swapping-right');
block2.classList.add('swapping-left');
} else {
block1.classList.add('swapping-left');
block2.classList.add('swapping-right');
}
} else {
// Vertical swap
if (row2 > row1) {
block1.classList.add('swapping-down');
block2.classList.add('swapping-up');
} else {
block1.classList.add('swapping-up');
block2.classList.add('swapping-down');
}
}
setTimeout(() => {
const temp = this.grid[row1][col1];
this.grid[row1][col1] = this.grid[row2][col2];
this.grid[row2][col2] = temp;
this.renderGrid();
this.checkMatches();
}, 150);
}
clearSelection() {
this.selectedBlock = null;
this.updateSelection();
}
updateSelection() {
const blocks = this.elements.gameGrid.querySelectorAll('.block');
blocks.forEach(block => block.classList.remove('selected'));
if (this.selectedBlock) {
const index = this.selectedBlock.row * this.GRID_WIDTH + this.selectedBlock.col;
blocks[index]?.classList.add('selected');
}
}
updateCursor() {
const blocks = this.elements.gameGrid.querySelectorAll('.block');
blocks.forEach(block => block.classList.remove('cursor'));
if (this.gameRunning && !this.gamePaused) {
const index = this.cursorRow * this.GRID_WIDTH + this.cursorCol;
blocks[index]?.classList.add('cursor');
}
}
moveCursor(deltaRow, deltaCol) {
this.cursorRow = (this.cursorRow + deltaRow + this.GRID_HEIGHT) % this.GRID_HEIGHT;
this.cursorCol = (this.cursorCol + deltaCol + this.GRID_WIDTH) % this.GRID_WIDTH;
this.updateCursor();
}
handleGamepadInput() {
// Handle different game states
if (this.elements.gameOverModal.classList.contains('active')) {
this.handleGameOverGamepad();
return;
}
if (this.elements.pauseModal.classList.contains('active')) {
this.handlePauseGamepad();
return;
}
if (this.elements.leaderboardScreen.classList.contains('active')) {
this.handleMenuGamepad();
return;
}
if (this.gameRunning) {
this.handleGameGamepad();
}
}
handleGameGamepad() {
// Movement
if (this.gamepadManager.isDirectionPressed('up')) {
this.moveCursor(-1, 0);
}
if (this.gamepadManager.isDirectionPressed('down')) {
this.moveCursor(1, 0);
}
if (this.gamepadManager.isDirectionPressed('left')) {
this.moveCursor(0, -1);
}
if (this.gamepadManager.isDirectionPressed('right')) {
this.moveCursor(0, 1);
}
// Actions
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_DOWN)) {
this.handleCursorSelect();
this.gamepadManager.vibrate(50, 0.3);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_RIGHT)) {
this.clearSelection();
this.gamepadManager.vibrate(30, 0.2);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.START)) {
if (this.gamePaused) {
this.resumeGame();
} else {
this.pauseGame();
}
this.gamepadManager.vibrate(100, 0.4);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.SELECT)) {
this.soundManager.toggleMute();
this.updateVolumeIcon();
this.gamepadManager.vibrate(50, 0.2);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.LEFT_SHOULDER)) {
this.quitToMenu();
this.gamepadManager.vibrate(100, 0.4);
}
}
handleGameOverGamepad() {
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.RIGHT_SHOULDER)) {
this.startGame();
this.gamepadManager.vibrate(100, 0.5);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_DOWN) ||
this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.START)) {
this.showLeaderboard();
this.gamepadManager.vibrate(100, 0.4);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_RIGHT)) {
this.startGame();
this.gamepadManager.vibrate(100, 0.5);
}
}
handlePauseGamepad() {
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.START) ||
this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_DOWN)) {
this.resumeGame();
this.gamepadManager.vibrate(100, 0.4);
}
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_RIGHT) ||
this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.LEFT_SHOULDER)) {
this.quitToMenu();
this.gamepadManager.vibrate(100, 0.4);
}
}
handleMenuGamepad() {
if (this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.FACE_DOWN) ||
this.gamepadManager.isButtonJustPressed(this.gamepadManager.buttonMapping.START)) {
this.startGame();
this.gamepadManager.vibrate(100, 0.5);
}
}
handleKeyDown(e) {
// Handle different game states
if (this.elements.gameOverModal.classList.contains('active')) {
this.handleGameOverKeys(e);
return;
}
if (this.elements.pauseModal.classList.contains('active')) {
this.handlePauseKeys(e);
return;
}
if (this.elements.leaderboardScreen.classList.contains('active')) {
this.handleMenuKeys(e);
return;
}
if (this.gameRunning) {
this.handleGameKeys(e);
}
}
handleGameKeys(e) {
switch(e.key.toLowerCase()) {
case 'arrowup':
case 'w':
e.preventDefault();
this.moveCursor(-1, 0);
break;
case 'arrowdown':
case 's':
e.preventDefault();
this.moveCursor(1, 0);
break;
case 'arrowleft':
case 'a':
e.preventDefault();
this.moveCursor(0, -1);
break;
case 'arrowright':
case 'd':
e.preventDefault();
this.moveCursor(0, 1);
break;
case ' ':
case 'enter':
e.preventDefault();
this.handleCursorSelect();
break;
case 'escape':
e.preventDefault();
if (this.gamePaused) {
this.resumeGame();
} else {
this.pauseGame();
}
break;
}
}
handleGameOverKeys(e) {
switch(e.key.toLowerCase()) {
case 'r':
e.preventDefault();
this.startGame();
break;
case 'escape':
case 'enter':
e.preventDefault();
this.showLeaderboard();
break;
}
}
handlePauseKeys(e) {
switch(e.key.toLowerCase()) {
case 'escape':
case 'enter':
case ' ':
e.preventDefault();
this.resumeGame();
break;
case 'q':
e.preventDefault();
this.quitToMenu();
break;
}
}
handleMenuKeys(e) {
switch(e.key.toLowerCase()) {
case 'enter':
case ' ':
e.preventDefault();
this.startGame();
break;
}
}
handleCursorSelect() {
if (!this.gameRunning || this.gamePaused) return;
// Check if there's a block at cursor position
if (!this.grid[this.cursorRow][this.cursorCol]) return;
if (this.selectedBlock) {
const selectedRow = this.selectedBlock.row;
const selectedCol = this.selectedBlock.col;
// Check if cursor is on selected block (deselect)
if (this.cursorRow === selectedRow && this.cursorCol === selectedCol) {
this.clearSelection();
return;
}
// Check if adjacent horizontally or vertically
if ((this.cursorRow === selectedRow && Math.abs(this.cursorCol - selectedCol) === 1) ||
(this.cursorCol === selectedCol && Math.abs(this.cursorRow - selectedRow) === 1)) {
this.swapBlocks(selectedRow, selectedCol, this.cursorRow, this.cursorCol);
}
this.clearSelection();
} else {
this.selectedBlock = { row: this.cursorRow, col: this.cursorCol };
this.updateSelection();
}
}
checkMatches() {
const matches = this.findMatches();
if (matches.length > 0) {
this.removeMatches(matches, () => {
// After blocks are removed, apply gravity
setTimeout(() => {
this.applyGravity(() => {
// After gravity is complete, check for new matches
setTimeout(() => this.checkMatches(), 200);
});
}, 150);
});
} else {
this.combo = 0;
this.updateDisplay();
}
}
findMatches() {
const matches = new Set();
// Check horizontal matches
for (let row = 0; row < this.GRID_HEIGHT; row++) {
let count = 1;
let current = this.grid[row][0];
for (let col = 1; col < this.GRID_WIDTH; col++) {
if (this.grid[row][col] === current && current !== null) {
count++;
} else {
if (count >= 3 && current !== null) {
for (let i = col - count; i < col; i++) {
matches.add(`${row}-${i}`);
}
}
count = 1;
current = this.grid[row][col];
}
}
if (count >= 3 && current !== null) {
for (let i = this.GRID_WIDTH - count; i < this.GRID_WIDTH; i++) {
matches.add(`${row}-${i}`);
}
}
}
// Check vertical matches
for (let col = 0; col < this.GRID_WIDTH; col++) {
let count = 1;
let current = this.grid[0][col];
for (let row = 1; row < this.GRID_HEIGHT; row++) {
if (this.grid[row][col] === current && current !== null) {
count++;
} else {
if (count >= 3 && current !== null) {
for (let i = row - count; i < row; i++) {
matches.add(`${i}-${col}`);
}
}
count = 1;
current = this.grid[row][col];
}
}
if (count >= 3 && current !== null) {
for (let i = this.GRID_HEIGHT - count; i < this.GRID_HEIGHT; i++) {
matches.add(`${i}-${col}`);
}
}
}
return Array.from(matches).map(pos => {
const [row, col] = pos.split('-').map(Number);
return { row, col };
});
}
removeMatches(matches, callback) {
this.combo++;
const baseScore = matches.length * 10;
const comboMultiplier = Math.min(this.combo, 10);
const levelMultiplier = this.level;
const points = baseScore * comboMultiplier * levelMultiplier;
this.score += points;
// Play appropriate sounds
if (this.combo > 1) {
this.soundManager.playComboSound(this.combo);
} else {
this.soundManager.playMatchSound(matches.length);
}
// Gamepad vibration for matches
if (this.combo > 1) {
this.gamepadManager.vibrate(200 + (this.combo * 50), 0.3 + (this.combo * 0.1));
} else {
this.gamepadManager.vibrate(100, 0.2);
}
// Add matched animation before removing
matches.forEach(({ row, col }, index) => {
const blockElement = this.elements.gameGrid.children[row * this.GRID_WIDTH + col];
if (blockElement) {
setTimeout(() => {
blockElement.classList.add('matched');
blockElement.classList.add('sound-pulse');
if (this.combo >= 3) {
this.createParticleEffect(blockElement);
}
}, index * 50);
}
});
// Remove blocks after animation and call callback
setTimeout(() => {
matches.forEach(({ row, col }) => {
this.grid[row][col] = null;
});
this.renderGrid();
this.updateDisplay();
if (this.combo > 1) {
this.elements.comboText.textContent = `${this.combo}x COMBO!`;
if (this.combo >= 7) {
document.getElementById('app').classList.add('screen-shake');
setTimeout(() => document.getElementById('app').classList.remove('screen-shake'), 300);
}
} else {
this.elements.comboText.textContent = '';
}
if (callback) callback();
}, 600);
}
applyGravity(callback) {
const fallingBlocks = [];
const columnData = [];
// Analyze each column and track falling blocks
for (let col = 0; col < this.GRID_WIDTH; col++) {
const column = [];
const originalPositions = [];
// Collect non-null blocks and their original positions
for (let row = this.GRID_HEIGHT - 1; row >= 0; row--) {
if (this.grid[row][col] !== null) {
column.push(this.grid[row][col]);
originalPositions.push(row);
}
}
columnData[col] = { blocks: column, originalPositions };
// Clear the column
for (let row = 0; row < this.GRID_HEIGHT; row++) {
this.grid[row][col] = null;
}
// Track falling blocks with distance information
for (let i = 0; i < column.length; i++) {
const oldRow = originalPositions[i];
const newRow = this.GRID_HEIGHT - 1 - i;
if (newRow > oldRow) {
fallingBlocks.push({
col,
oldRow,
newRow,
block: column[i],
fallDistance: newRow - oldRow
});
}
}
// Place blocks in their new positions
for (let i = 0; i < column.length; i++) {
this.grid[this.GRID_HEIGHT - 1 - i][col] = column[i];
}
}
// If no blocks need to fall, just call callback
if (fallingBlocks.length === 0) {
this.renderGrid();
if (callback) callback();
return;
}
// Render grid first
this.renderGrid();
// Apply falling animations with staggered timing
let maxAnimationTime = 0;
fallingBlocks.forEach((block, index) => {
const blockElement = this.elements.gameGrid.children[block.newRow * this.GRID_WIDTH + block.col];
if (blockElement) {
// Stagger animation start based on column and fall distance
const startDelay = (block.col * 50) + (block.fallDistance * 20);
const animationDuration = 400 + (block.fallDistance * 100);
maxAnimationTime = Math.max(maxAnimationTime, startDelay + animationDuration);
setTimeout(() => {
blockElement.classList.add('gravity-fall');
setTimeout(() => {
blockElement.classList.remove('gravity-fall');
}, animationDuration);
}, startDelay);
}
});
// Call callback after all animations complete
if (callback) {
setTimeout(callback, maxAnimationTime + 200);
}
}
addNewRow() {
// Check if top row has blocks (game over)
for (let col = 0; col < this.GRID_WIDTH; col++) {
if (this.grid[0][col] !== null) {
this.gameOver();
return;
}
}
this.soundManager.playNewRowSound();
+ // Move cursor up with the shifting rows to maintain relative position
+ this.cursorRow = Math.max(0, this.cursorRow - 1);
+
// Shift all rows up
for (let row = 0; row < this.GRID_HEIGHT - 1; row++) {
for (let col = 0; col < this.GRID_WIDTH; col++) {
this.grid[row][col] = this.grid[row + 1][col];
}
}
// Add new row at bottom
for (let col = 0; col < this.GRID_WIDTH; col++) {
this.grid[this.GRID_HEIGHT - 1][col] = this.nextRow[col];
}
this.generateNextRow();
// Wait a bit before rendering to avoid conflicts
setTimeout(() => {
this.renderGrid();
+ this.updateCursor(); // Update cursor position after grid shift
// Add new row animation with smoother staggering
const animationDuration = 500;
for (let col = 0; col < this.GRID_WIDTH; col++) {
const blockElement = this.elements.gameGrid.children[(this.GRID_HEIGHT - 1) * this.GRID_WIDTH + col];
if (blockElement && this.grid[this.GRID_HEIGHT - 1][col]) {
setTimeout(() => {
blockElement.classList.add('new-row');
setTimeout(() => blockElement.classList.remove('new-row'), animationDuration);
}, col * 80);
}
}
// Wait for all animations to complete before checking matches
const totalAnimationTime = (this.GRID_WIDTH - 1) * 80 + animationDuration;
setTimeout(() => this.checkMatches(), totalAnimationTime + 100);
}, 50);
}
gameLoop() {
if (!this.gameRunning || this.gamePaused) return;
// Poll gamepad input
this.gamepadManager.poll();
this.handleGamepadInput();
const now = Date.now();
if (now - this.lastFall >= this.fallSpeed) {
this.addNewRow();
this.lastFall = now;
// Increase level and speed
this.level = Math.floor(this.score / 1000) + 1;
this.fallSpeed = Math.max(2000, 5000 - (this.level - 1) * 100);
}
requestAnimationFrame(() => this.gameLoop());
}
updateDisplay() {
this.elements.currentScore.textContent = this.score.toLocaleString();
this.elements.currentLevel.textContent = this.level;
// Check for level up
if (this.level > this.previousLevel) {
this.soundManager.playLevelUpSound();
this.previousLevel = this.level;
}
}
pauseGame() {
this.gamePaused = true;
this.elements.pauseModal.classList.add('active');
this.updateCursor(); // Hide cursor when paused
}
resumeGame() {
this.gamePaused = false;
this.elements.pauseModal.classList.remove('active');
this.lastFall = Date.now();
this.updateCursor(); // Show cursor when resumed
this.gameLoop();
}
quitToMenu() {
this.gameRunning = false;
this.gamePaused = false;
this.elements.pauseModal.classList.remove('active');
this.updateCursor(); // Hide cursor when quitting
this.showLeaderboard();
}
async gameOver() {
+ // Immediately disable game and input
this.gameRunning = false;
+ this.gamePaused = true;
+
+ // Immediate visual feedback
+ document.getElementById('app').classList.add('game-over-flash');
+ this.elements.gameOverOverlay.classList.add('active');
+
+ // Play sounds and vibration
this.soundManager.playGameOverSound();
+ this.gamepadManager.vibrate(500, 0.8);
+
+ // Update cursor to hide it
+ this.updateCursor();
+
+ // Set final score immediately
- this.gamepadManager.vibrate(500, 0.8); // Strong vibration for game over
this.elements.finalScoreValue.textContent = this.score.toLocaleString();
+ // Wait for immediate feedback animation, then show modal
+ setTimeout(async () => {
+ // Hide immediate feedback
+ this.elements.gameOverOverlay.classList.remove('active');
+ document.getElementById('app').classList.remove('game-over-flash');
+
+ // Handle score saving and messaging
+ if (this.currentUser) {
+ await this.saveScore();
+ const position = await this.getScorePosition();
+ this.elements.scoreMessage.textContent = position <= 20 ?
+ `🎉 You made it to #${position} on the leaderboard!` :
+ `Your best score has been saved!`;
+ this.elements.anonymousMessage.style.display = 'none';
+ } else {
+ this.elements.scoreMessage.textContent = '';
+ this.elements.anonymousMessage.style.display = 'block';
+ }
+
+ // Show game over modal
+ this.elements.gameOverModal.classList.add('active');
+ }, 1000);
- if (this.currentUser) {
- await this.saveScore();
- const position = await this.getScorePosition();
- this.elements.scoreMessage.textContent = position <= 20 ?
- `🎉 You made it to #${position} on the leaderboard!` :
- `Your best score has been saved!`;
- this.elements.anonymousMessage.style.display = 'none';
- } else {
- this.elements.scoreMessage.textContent = '';
- this.elements.anonymousMessage.style.display = 'block';
- }
-
- this.elements.gameOverModal.classList.add('active');
}
async saveScore() {
if (!this.currentUser) return;
try {
const key = `score_${this.currentUser.username}`;
const existingData = await window.discourseArtifact.get(key);
let scoreData = {
username: this.currentUser.username,
name: this.currentUser.name,
score: this.score,
date: new Date().toISOString()
};
if (existingData) {
const existing = JSON.parse(existingData);
if (existing.score >= this.score) return; // Don't save if not a high score
}
await window.discourseArtifact.set(key, JSON.stringify(scoreData), { public: true });
} catch (error) {
console.error('Error saving score:', error);
}
}
async getScorePosition() {
try {
const result = await window.discourseArtifact.index({ all_users: true });
const scores = result.key_values
.filter(kv => kv.key.startsWith('score_'))
.map(kv => JSON.parse(kv.value))
.sort((a, b) => b.score - a.score);
const userScore = scores.find(s => s.username === this.currentUser.username);
return userScore ? scores.indexOf(userScore) + 1 : scores.length + 1;
} catch (error) {
return 999;
}
}
async loadLeaderboard() {
try {
this.elements.leaderboardLoading.style.display = 'block';
this.elements.leaderboardTable.style.display = 'none';
const result = await window.discourseArtifact.index({ all_users: true });
const scores = result.key_values
.filter(kv => kv.key.startsWith('score_'))
.map(kv => ({
...JSON.parse(kv.value),
user: kv.user
}))
.sort((a, b) => b.score - a.score)
.slice(0, 20);
this.renderLeaderboard(scores);
} catch (error) {
console.error('Error loading leaderboard:', error);
this.elements.leaderboardLoading.textContent = 'Error loading leaderboard';
}
}
renderLeaderboard(scores) {
this.elements.leaderboardBody.innerHTML = '';
if (scores.length === 0) {
this.elements.leaderboardLoading.textContent = 'No scores yet. Be the first to play!';
return;
}
scores.forEach((score, index) => {
const row = document.createElement('tr');
const rank = index + 1;
const medal = rank <= 3 ? ['🥇', '🥈', '🥉'][rank - 1] : rank;
const date = new Date(score.date).toLocaleDateString();
const displayName = score.user.name || score.username;
row.innerHTML = `
<td><span class="rank-medal">${medal}</span></td>
<td>${displayName}</td>
<td>${score.score.toLocaleString()}</td>
<td>${date}</td>
`;
this.elements.leaderboardBody.appendChild(row);
});
this.elements.leaderboardLoading.style.display = 'none';
this.elements.leaderboardTable.style.display = 'table';
}
setupVolumeControls() {
// Initialize volume slider with current volume
this.elements.volumeSlider.value = Math.round(this.soundManager.volume * 100);
this.updateVolumeIcon();
// Volume button click handler
this.elements.volumeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.soundManager.playButtonSound();
this.elements.volumeSliderContainer.classList.toggle('active');
});
// Volume slider change handler
this.elements.volumeSlider.addEventListener('input', (e) => {
const volume = parseInt(e.target.value) / 100;
this.soundManager.setVolume(volume);
this.soundManager.muted = false; // Unmute when adjusting volume
this.updateVolumeIcon();
});
// Close volume slider when clicking outside
document.addEventListener('click', (e) => {
if (!this.elements.volumeBtn.contains(e.target) &&
!this.elements.volumeSliderContainer.contains(e.target)) {
this.elements.volumeSliderContainer.classList.remove('active');
}
});
// Mute/unmute on right click or double click
this.elements.volumeBtn.addEventListener('dblclick', (e) => {
e.preventDefault();
this.soundManager.toggleMute();
this.updateVolumeIcon();
});
// Prevent volume slider from closing when clicking on it
this.elements.volumeSliderContainer.addEventListener('click', (e) => {
e.stopPropagation();
});
}
updateVolumeIcon() {
const volume = this.soundManager.volume;
const muted = this.soundManager.muted;
if (muted || volume === 0) {
this.elements.volumeBtn.textContent = '🔇';
this.elements.volumeBtn.classList.add('muted');
} else if (volume < 0.3) {
this.elements.volumeBtn.textContent = '🔉';
this.elements.volumeBtn.classList.remove('muted');
} else {
this.elements.volumeBtn.textContent = '🔊';
this.elements.volumeBtn.classList.remove('muted');
}
// Update slider position
this.elements.volumeSlider.value = Math.round(volume * 100);
}
showLeaderboard() {
this.elements.leaderboardScreen.classList.add('active');
this.elements.gameScreen.classList.remove('active');
this.elements.gameOverModal.classList.remove('active');
this.loadLeaderboard();
}
showGameScreen() {
this.elements.leaderboardScreen.classList.remove('active');
this.elements.gameScreen.classList.add('active');
this.elements.gameOverModal.classList.remove('active');
}
}
// Initialize game when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Connect3Game();
// Start gamepad polling
function pollGamepads() {
if (navigator.getGamepads) {
// The polling happens in the game loop, but we need this for connection detection
requestAnimationFrame(pollGamepads);
}
}
pollGamepads();
});