Catena — videogioco completo in un file HTML
Gioco 8x8 a gemme colorate: trascina per formare catene di 3+ gemme dello stesso colore e fare punti. - Griglia 8x8, 6 colori, input mouse e touch - Timer 90 secondi, punteggio e record in sessione - Animazioni: scale-out (rimozione), drop (caduta), shake (mescola) - Pulsante Mescola: 50pt + 10s per rimescolare le gemme - Responsive, funziona da desktop e mobile - Nessuna libreria esterna
This commit is contained in:
commit
68bed620a0
3 files changed
+899
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
Desktop.ini
|
||||
|
||||
# Editor files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
.idea/
|
||||
+888
@@ -0,0 +1,888 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Catena</title>
|
||||
<style>
|
||||
/* ===== RESET & BASE ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #0d1b2a;
|
||||
color: #fff;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* ===== SCREENS ===== */
|
||||
.screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.screen.hidden {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== START SCREEN ===== */
|
||||
#start-screen h1 {
|
||||
font-size: clamp(3rem, 12vw, 5rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5em;
|
||||
background: linear-gradient(135deg, #ff6b6b, #ffd93d, #6bcb77, #4d96ff, #9b59b6, #ff8c32);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
#start-screen p {
|
||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
color: #c0d0e0;
|
||||
margin-bottom: 2em;
|
||||
max-width: 28em;
|
||||
padding: 0 1em;
|
||||
}
|
||||
#start-screen button, #gameover-screen button {
|
||||
font-size: clamp(1.1rem, 3vw, 1.3rem);
|
||||
font-weight: 700;
|
||||
padding: 0.8em 2.5em;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
background: linear-gradient(135deg, #4d96ff, #2a6fd4);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(77,150,255,0.4);
|
||||
}
|
||||
#start-screen button:hover, #gameover-screen button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 30px rgba(77,150,255,0.6);
|
||||
}
|
||||
#start-screen button:active, #gameover-screen button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ===== GAME SCREEN ===== */
|
||||
#game-screen {
|
||||
justify-content: flex-start;
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
#header {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#score {
|
||||
font-size: clamp(1rem, 4vw, 1.3rem);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
min-width: 4em;
|
||||
color: #ffd93d;
|
||||
}
|
||||
#high-score {
|
||||
font-size: clamp(0.75rem, 3vw, 0.9rem);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: #8aaccc;
|
||||
}
|
||||
#timer-container {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
#timer-bar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(90deg, #6bcb77, #ffd93d 50%, #ff6b6b);
|
||||
transition: width 0.3s linear;
|
||||
}
|
||||
#timer-bar.urgent {
|
||||
background: linear-gradient(90deg, #ff6b6b, #ff4444);
|
||||
animation: pulseBar 0.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseBar {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* ===== BOARD ===== */
|
||||
#board-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 6px 10px 10px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
#board-wrapper {
|
||||
position: relative;
|
||||
width: min(92vw, calc(100vh - 100px), 500px);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
#board {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* ===== GEMS ===== */
|
||||
.gem {
|
||||
width: 86%;
|
||||
height: 86%;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, opacity 0.15s, box-shadow 0.1s;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.gem::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
left: 14%;
|
||||
width: 38%;
|
||||
height: 30%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 40%, rgba(255,255,255,0.5), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.gem.selected {
|
||||
box-shadow: 0 0 0 3px #fff, 0 0 12px rgba(255,255,255,0.5);
|
||||
transform: scale(1.08);
|
||||
z-index: 2;
|
||||
}
|
||||
.gem.removing {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
transition: transform 0.2s ease-in, opacity 0.2s ease-in;
|
||||
}
|
||||
.gem.drop {
|
||||
animation: gemDrop 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes gemDrop {
|
||||
from { transform: translateY(-40px); opacity: 0.4; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Gem colors — radial gradient for 3D effect */
|
||||
.gem-0 { background: radial-gradient(circle at 35% 32%, #ff9e9e, #ff6b6b 55%, #d94444); }
|
||||
.gem-1 { background: radial-gradient(circle at 35% 32%, #ffe680, #ffd93d 55%, #d4ad00); }
|
||||
.gem-2 { background: radial-gradient(circle at 35% 32%, #9edfa6, #6bcb77 55%, #48a854); }
|
||||
.gem-3 { background: radial-gradient(circle at 35% 32%, #80b5ff, #4d96ff 55%, #2a6fd4); }
|
||||
.gem-4 { background: radial-gradient(circle at 35% 32%, #c08ad6, #9b59b6 55%, #7d3c98); }
|
||||
.gem-5 { background: radial-gradient(circle at 35% 32%, #ffb070, #ff8c32 55%, #d46a1a); }
|
||||
|
||||
/* ===== SVG LINE ===== */
|
||||
#line-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
#path-line-glow {
|
||||
stroke: rgba(255,255,255,0.25);
|
||||
stroke-width: 10;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
#path-line {
|
||||
stroke: #fff;
|
||||
stroke-width: 3.5;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* ===== COMBO ===== */
|
||||
#combo {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
font-size: clamp(2rem, 8vw, 3.5rem);
|
||||
font-weight: 900;
|
||||
color: #ffd93d;
|
||||
text-shadow: 0 0 30px rgba(255,217,61,0.6), 0 4px 12px rgba(0,0,0,0.5);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transition: none;
|
||||
}
|
||||
#combo.show {
|
||||
animation: comboIn 0.8s ease-out forwards;
|
||||
}
|
||||
@keyframes comboIn {
|
||||
0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0; }
|
||||
25% { transform: translate(-50%, -50%) scale(1.2); opacity: 1; }
|
||||
50% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
100% { transform: translate(-50%, -80%) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ===== SHUFFLE BUTTON ===== */
|
||||
#toolbar {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2px 12px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shuffle-btn {
|
||||
font-size: clamp(0.8rem, 2.5vw, 0.95rem);
|
||||
font-weight: 600;
|
||||
padding: 0.35em 1em;
|
||||
border: 1.5px solid rgba(255,255,255,0.25);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #89a;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shuffle-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.shuffle-btn:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.18);
|
||||
color: #dde;
|
||||
border-color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.shuffle-btn:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* ===== SHAKE ANIMATION ===== */
|
||||
@keyframes screenShake {
|
||||
0% { transform: translate(0, 0); }
|
||||
10% { transform: translate(-6px, 4px); }
|
||||
20% { transform: translate(5px, -3px); }
|
||||
30% { transform: translate(-4px, 5px); }
|
||||
40% { transform: translate(3px, -2px); }
|
||||
50% { transform: translate(-5px, 3px); }
|
||||
60% { transform: translate(2px, -4px); }
|
||||
70% { transform: translate(-3px, 2px); }
|
||||
80% { transform: translate(4px, -5px); }
|
||||
90% { transform: translate(-2px, 3px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
#game-screen.shaking {
|
||||
animation: screenShake 0.45s ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== GAME OVER ===== */
|
||||
#gameover-screen h1 {
|
||||
font-size: clamp(2.5rem, 10vw, 4rem);
|
||||
margin-bottom: 0.4em;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
#gameover-screen p {
|
||||
font-size: clamp(1.1rem, 4vw, 1.5rem);
|
||||
margin-bottom: 0.4em;
|
||||
color: #c0d0e0;
|
||||
}
|
||||
#gameover-screen .final-score {
|
||||
font-size: clamp(2rem, 7vw, 3rem);
|
||||
font-weight: 800;
|
||||
color: #ffd93d;
|
||||
}
|
||||
#gameover-screen .final-record {
|
||||
font-size: clamp(1rem, 3.5vw, 1.3rem);
|
||||
color: #8aaccc;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
#gameover-screen .btn-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
#gameover-screen .btn-group button:last-child {
|
||||
background: rgba(255,255,255,0.12);
|
||||
box-shadow: none;
|
||||
}
|
||||
#gameover-screen .btn-group button:last-child:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-height: 500px) {
|
||||
#board-wrapper { width: min(85vw, calc(100vh - 70px), 400px); }
|
||||
#header { padding: 4px 10px; gap: 8px; }
|
||||
#timer-container { height: 10px; }
|
||||
#score { font-size: 0.9rem; }
|
||||
}
|
||||
@media (min-width: 800px) {
|
||||
#board-wrapper { width: min(70vh, 70vw, 500px); }
|
||||
}
|
||||
|
||||
/* ===== UTILITY ===== */
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- START SCREEN -->
|
||||
<div id="start-screen" class="screen">
|
||||
<h1>Catena</h1>
|
||||
<p>Trascina su gemme dello stesso colore.<br>Almeno 3 per fare punti.</p>
|
||||
<button id="btn-start">Inizia</button>
|
||||
</div>
|
||||
|
||||
<!-- GAME SCREEN -->
|
||||
<div id="game-screen" class="screen hidden">
|
||||
<div id="header">
|
||||
<div id="score">0</div>
|
||||
<div id="timer-container"><div id="timer-bar"></div></div>
|
||||
<div id="high-score">Record: 0</div>
|
||||
</div>
|
||||
<div id="toolbar">
|
||||
<button id="btn-shuffle" class="shuffle-btn" disabled>⟳ Mescola (50pt + 10s)</button>
|
||||
</div>
|
||||
<div id="board-container">
|
||||
<div id="board-wrapper">
|
||||
<div id="board"></div>
|
||||
<svg id="line-svg" viewBox="0 0 100 100">
|
||||
<polyline id="path-line-glow" points=""/>
|
||||
<polyline id="path-line" points=""/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="combo">Combo!</div>
|
||||
</div>
|
||||
|
||||
<!-- GAME OVER SCREEN -->
|
||||
<div id="gameover-screen" class="screen hidden">
|
||||
<h1>Game Over</h1>
|
||||
<p class="final-score" id="final-score">0</p>
|
||||
<p>Punteggio finale</p>
|
||||
<p class="final-record" id="final-record">Record: 0</p>
|
||||
<div class="btn-group">
|
||||
<button id="btn-restart">Rigioca</button>
|
||||
<button id="btn-home">Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ===================================================================
|
||||
CATENA — Gioco di gemme a percorsi
|
||||
=================================================================== */
|
||||
|
||||
// ===== CONFIG =====
|
||||
const ROWS = 8;
|
||||
const COLS = 8;
|
||||
const NUM_COLORS = 6;
|
||||
const MIN_CHAIN = 3;
|
||||
const GAME_TIME = 90;
|
||||
const POINTS = { 3: 10, 4: 15, 5: 25, 6: 40 };
|
||||
|
||||
// ===== STATE =====
|
||||
let grid = []; // ROWS x COLS: colorIndex (0-5)
|
||||
let score = 0;
|
||||
let highScore = 0;
|
||||
let timeLeft = GAME_TIME;
|
||||
let currentPath = []; // [{row, col}, ...]
|
||||
let isPlaying = false;
|
||||
let isDragging = false;
|
||||
let isAnimating = false;
|
||||
let timerId = null;
|
||||
|
||||
// ===== DOM REFS =====
|
||||
const $ = id => document.getElementById(id);
|
||||
const startScreen = $('start-screen');
|
||||
const gameScreen = $('game-screen');
|
||||
const gameoverScreen = $('gameover-screen');
|
||||
const boardEl = $('board');
|
||||
const boardWrapper = $('board-wrapper');
|
||||
const scoreEl = $('score');
|
||||
const highScoreEl = $('high-score');
|
||||
const timerBar = $('timer-bar');
|
||||
const finalScore = $('final-score');
|
||||
const finalRecord = $('final-record');
|
||||
const comboEl = $('combo');
|
||||
const lineSvg = $('line-svg');
|
||||
const pathLine = $('path-line');
|
||||
const pathLineGlow = $('path-line-glow');
|
||||
|
||||
// ===== GRID =====
|
||||
function createGrid() {
|
||||
const data = [];
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
data[r] = [];
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
data[r][c] = Math.floor(Math.random() * NUM_COLORS);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
let html = '';
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const ci = grid[r][c];
|
||||
const cls = ci !== null ? `gem gem-${ci}` : 'gem gem-empty';
|
||||
html += `<div class="${cls}" data-row="${r}" data-col="${c}"></div>`;
|
||||
}
|
||||
}
|
||||
boardEl.innerHTML = html;
|
||||
updateSVGViewBox();
|
||||
}
|
||||
|
||||
function updateSVGViewBox() {
|
||||
const rect = boardEl.getBoundingClientRect();
|
||||
const w = rect.width, h = rect.height;
|
||||
if (w > 0 && h > 0) {
|
||||
lineSvg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PATH LINE =====
|
||||
function updatePathLine() {
|
||||
if (currentPath.length < 2) {
|
||||
pathLine.setAttribute('points', '');
|
||||
pathLineGlow.setAttribute('points', '');
|
||||
return;
|
||||
}
|
||||
const rect = boardEl.getBoundingClientRect();
|
||||
const cellW = rect.width / COLS;
|
||||
const cellH = rect.height / ROWS;
|
||||
const pts = currentPath.map(p => {
|
||||
const x = p.col * cellW + cellW / 2;
|
||||
const y = p.row * cellH + cellH / 2;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
pathLine.setAttribute('points', pts);
|
||||
pathLineGlow.setAttribute('points', pts);
|
||||
}
|
||||
|
||||
function clearLine() {
|
||||
pathLine.setAttribute('points', '');
|
||||
pathLineGlow.setAttribute('points', '');
|
||||
}
|
||||
|
||||
// ===== SELECTION VISUALS =====
|
||||
function updateSelection() {
|
||||
// Remove selected class from all gems
|
||||
const allGems = boardEl.querySelectorAll('.gem.selected');
|
||||
for (const g of allGems) g.classList.remove('selected');
|
||||
// Add selected class to current path gems
|
||||
for (const p of currentPath) {
|
||||
const gem = boardEl.querySelector(`[data-row="${p.row}"][data-col="${p.col}"]`);
|
||||
if (gem) gem.classList.add('selected');
|
||||
}
|
||||
updatePathLine();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const allGems = boardEl.querySelectorAll('.gem.selected');
|
||||
for (const g of allGems) g.classList.remove('selected');
|
||||
currentPath = [];
|
||||
clearLine();
|
||||
}
|
||||
|
||||
// ===== INPUT =====
|
||||
function getGridCell(clientX, clientY) {
|
||||
const rect = boardEl.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
if (x < 0 || x >= rect.width || y < 0 || y >= rect.height) return null;
|
||||
const cellW = rect.width / COLS;
|
||||
const cellH = rect.height / ROWS;
|
||||
const col = Math.floor(x / cellW);
|
||||
const row = Math.floor(y / cellH);
|
||||
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return null;
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
function isAdjacent(r1, c1, r2, c2) {
|
||||
return Math.abs(r1 - r2) + Math.abs(c1 - c2) === 1;
|
||||
}
|
||||
|
||||
function isInPath(row, col) {
|
||||
return currentPath.some(p => p.row === row && p.col === col);
|
||||
}
|
||||
|
||||
function canExtendPath(row, col) {
|
||||
if (currentPath.length === 0) return true;
|
||||
const last = currentPath[currentPath.length - 1];
|
||||
if (!isAdjacent(last.row, last.col, row, col)) return false;
|
||||
if (isInPath(row, col)) return false;
|
||||
if (grid[row][col] === null) return false;
|
||||
const firstColor = grid[currentPath[0].row][currentPath[0].col];
|
||||
return grid[row][col] === firstColor;
|
||||
}
|
||||
|
||||
// ===== INPUT =====
|
||||
function startDrag(clientX, clientY) {
|
||||
console.log('startDrag', clientX, clientY, 'isPlaying:', isPlaying, 'isAnimating:', isAnimating);
|
||||
if (!isPlaying || isAnimating) return;
|
||||
const cell = getGridCell(clientX, clientY);
|
||||
console.log(' cell:', cell);
|
||||
if (!cell) return;
|
||||
if (grid[cell.row][cell.col] === null) return;
|
||||
console.log(' color index:', grid[cell.row][cell.col]);
|
||||
isDragging = true;
|
||||
currentPath = [cell];
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function continueDrag(clientX, clientY) {
|
||||
console.log('continueDrag', clientX, clientY, 'isDragging:', isDragging);
|
||||
if (!isPlaying || !isDragging || isAnimating) return;
|
||||
const cell = getGridCell(clientX, clientY);
|
||||
console.log(' cell:', cell);
|
||||
if (!cell) return;
|
||||
const last = currentPath[currentPath.length - 1];
|
||||
if (last && last.row === cell.row && last.col === cell.col) return;
|
||||
const canExtend = canExtendPath(cell.row, cell.col);
|
||||
console.log(' canExtendPath:', canExtend, 'last:', last, 'grid[last]', last ? grid[last.row][last.col] : '?', 'grid[cell]', grid[cell.row][cell.col]);
|
||||
if (canExtend) {
|
||||
currentPath.push(cell);
|
||||
updateSelection();
|
||||
console.log(' -> PATH EXTENDED! path length:', currentPath.length);
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
console.log('endDrag, isDragging:', isDragging);
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
processPath();
|
||||
} else {
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function bindInput() {
|
||||
// ----- Mouse (window-level) -----
|
||||
window.addEventListener('mousedown', e => {
|
||||
console.log('MOUSE DOWN', e.clientX, e.clientY, 'target:', e.target.id || e.target.className);
|
||||
if (!boardEl.contains(e.target)) { console.log(' -> outside board'); return; }
|
||||
startDrag(e.clientX, e.clientY);
|
||||
e.preventDefault();
|
||||
});
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (isDragging) {
|
||||
continueDrag(e.clientX, e.clientY);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (isDragging) {
|
||||
endDrag();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Touch (window-level) -----
|
||||
window.addEventListener('touchstart', e => {
|
||||
console.log('TOUCH START', 'touches:', e.touches.length);
|
||||
const touch = e.touches[0];
|
||||
if (!touch || !boardEl.contains(e.target)) { console.log(' -> ignored'); return; }
|
||||
startDrag(touch.clientX, touch.clientY);
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
window.addEventListener('touchmove', e => {
|
||||
if (isDragging) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) { continueDrag(touch.clientX, touch.clientY); }
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
window.addEventListener('touchend', e => {
|
||||
if (isDragging) {
|
||||
endDrag();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
window.addEventListener('touchcancel', () => {
|
||||
if (!isAnimating) clearSelection();
|
||||
isDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== GAME LOGIC =====
|
||||
function processPath() {
|
||||
if (currentPath.length < MIN_CHAIN) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const len = currentPath.length;
|
||||
const ppg = len <= 6 ? POINTS[len] : POINTS[6]; // 6+ uses 40 pts/gem
|
||||
const totalPoints = len * ppg;
|
||||
|
||||
// Update score
|
||||
score += totalPoints;
|
||||
updateScoreDisplay();
|
||||
|
||||
// Show combo for 6+
|
||||
if (len >= 6) showCombo();
|
||||
|
||||
// Animate removal
|
||||
isAnimating = true;
|
||||
const pathCopy = currentPath.slice();
|
||||
clearLine();
|
||||
|
||||
for (const p of pathCopy) {
|
||||
const gem = boardEl.querySelector(`[data-row="${p.row}"][data-col="${p.col}"]`);
|
||||
if (gem) gem.classList.add('removing');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isPlaying) { isAnimating = false; return; } // Game ended during animation
|
||||
// Remove gems from grid
|
||||
for (const p of pathCopy) {
|
||||
grid[p.row][p.col] = null;
|
||||
}
|
||||
// Gravity & fill
|
||||
applyGravity();
|
||||
// Re-render
|
||||
renderBoard();
|
||||
// Drop animation
|
||||
const allGems = boardEl.querySelectorAll('.gem:not(.gem-empty)');
|
||||
for (const g of allGems) g.classList.add('drop');
|
||||
// Clean up
|
||||
currentPath = [];
|
||||
isAnimating = false;
|
||||
updateSVGViewBox();
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function applyGravity() {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
let writeRow = ROWS - 1;
|
||||
for (let r = ROWS - 1; r >= 0; r--) {
|
||||
if (grid[r][c] !== null) {
|
||||
grid[writeRow][c] = grid[r][c];
|
||||
if (writeRow !== r) grid[r][c] = null;
|
||||
writeRow--;
|
||||
}
|
||||
}
|
||||
// Fill top empty cells with new random gems
|
||||
for (let r = writeRow; r >= 0; r--) {
|
||||
grid[r][c] = Math.floor(Math.random() * NUM_COLORS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== COMBO =====
|
||||
let comboTimeout = null;
|
||||
function showCombo() {
|
||||
if (comboTimeout) clearTimeout(comboTimeout);
|
||||
comboEl.classList.remove('show');
|
||||
// Force reflow
|
||||
void comboEl.offsetWidth;
|
||||
comboEl.classList.add('show');
|
||||
comboTimeout = setTimeout(() => comboEl.classList.remove('show'), 900);
|
||||
}
|
||||
|
||||
// ===== SCORE =====
|
||||
function updateScoreDisplay() {
|
||||
scoreEl.textContent = score;
|
||||
if (score > highScore) {
|
||||
highScore = score;
|
||||
highScoreEl.textContent = 'Record: ' + highScore;
|
||||
}
|
||||
if (isPlaying) updateShuffleBtn();
|
||||
}
|
||||
|
||||
// ===== TIMER =====
|
||||
function startTimer() {
|
||||
timeLeft = GAME_TIME;
|
||||
updateTimerDisplay();
|
||||
timerId = setInterval(() => {
|
||||
timeLeft--;
|
||||
updateTimerDisplay();
|
||||
if (timeLeft <= 5) {
|
||||
timerBar.classList.add('urgent');
|
||||
}
|
||||
if (timeLeft <= 0) {
|
||||
endGame();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateTimerDisplay() {
|
||||
const pct = Math.max(0, (timeLeft / GAME_TIME) * 100);
|
||||
timerBar.style.width = pct + '%';
|
||||
if (isPlaying) updateShuffleBtn();
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
timerBar.classList.remove('urgent');
|
||||
}
|
||||
|
||||
// ===== SCREENS =====
|
||||
function showScreen(screen) {
|
||||
[startScreen, gameScreen, gameoverScreen].forEach(s => s.classList.add('hidden'));
|
||||
screen.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
// Reset state
|
||||
stopTimer();
|
||||
grid = createGrid();
|
||||
score = 0;
|
||||
currentPath = [];
|
||||
isPlaying = true;
|
||||
isDragging = false;
|
||||
isAnimating = false;
|
||||
timerBar.classList.remove('urgent');
|
||||
|
||||
// Update displays
|
||||
scoreEl.textContent = '0';
|
||||
highScoreEl.textContent = 'Record: ' + highScore;
|
||||
updateTimerDisplay();
|
||||
|
||||
// Render
|
||||
renderBoard();
|
||||
clearLine();
|
||||
|
||||
// Show game screen
|
||||
showScreen(gameScreen);
|
||||
updateShuffleBtn();
|
||||
|
||||
// Start timer after a brief delay to avoid accidental pointer events
|
||||
// Also need to update SVG viewBox after rendering
|
||||
requestAnimationFrame(() => {
|
||||
updateSVGViewBox();
|
||||
startTimer();
|
||||
});
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
isPlaying = false;
|
||||
isDragging = false;
|
||||
isAnimating = false;
|
||||
stopTimer();
|
||||
|
||||
if (score > highScore) {
|
||||
highScore = score;
|
||||
}
|
||||
|
||||
finalScore.textContent = score;
|
||||
finalRecord.textContent = 'Record: ' + highScore;
|
||||
showScreen(gameoverScreen);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
isPlaying = false;
|
||||
isDragging = false;
|
||||
stopTimer();
|
||||
showScreen(startScreen);
|
||||
}
|
||||
|
||||
// ===== SHUFFLE =====
|
||||
function shuffleBoard() {
|
||||
if (!isPlaying || isAnimating) return;
|
||||
if (score < 50 || timeLeft < 10) return;
|
||||
|
||||
// Deduct cost
|
||||
score -= 50;
|
||||
timeLeft -= 10;
|
||||
updateScoreDisplay();
|
||||
updateTimerDisplay();
|
||||
updateShuffleBtn();
|
||||
|
||||
// Collect all gem colours and Fisher-Yates shuffle
|
||||
const all = [];
|
||||
for (let r = 0; r < ROWS; r++)
|
||||
for (let c = 0; c < COLS; c++)
|
||||
all.push(grid[r][c]);
|
||||
for (let i = all.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[all[i], all[j]] = [all[j], all[i]];
|
||||
}
|
||||
|
||||
// Refill grid
|
||||
let idx = 0;
|
||||
for (let r = 0; r < ROWS; r++)
|
||||
for (let c = 0; c < COLS; c++)
|
||||
grid[r][c] = all[idx++];
|
||||
|
||||
// Screen shake
|
||||
gameScreen.classList.add('shaking');
|
||||
setTimeout(() => gameScreen.classList.remove('shaking'), 500);
|
||||
|
||||
// Re-render
|
||||
clearSelection();
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function updateShuffleBtn() {
|
||||
const btn = $('btn-shuffle');
|
||||
if (!btn) return;
|
||||
const canAfford = score >= 50 && timeLeft >= 10;
|
||||
btn.disabled = !canAfford;
|
||||
btn.textContent = canAfford ? '⟳ Mescola (50pt + 10s)' : '⟳ Mescola (50pt + 10s)';
|
||||
}
|
||||
|
||||
// ===== RESIZE =====
|
||||
let resizeTimeout = null;
|
||||
function onResize() {
|
||||
if (resizeTimeout) cancelAnimationFrame(resizeTimeout);
|
||||
resizeTimeout = requestAnimationFrame(() => {
|
||||
if (isPlaying) {
|
||||
updateSVGViewBox();
|
||||
updatePathLine();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== INIT =====
|
||||
function init() {
|
||||
console.log('Catena init OK');
|
||||
bindInput();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
$('btn-start').addEventListener('click', startGame);
|
||||
$('btn-restart').addEventListener('click', startGame);
|
||||
$('btn-home').addEventListener('click', goHome);
|
||||
$('btn-shuffle').addEventListener('click', shuffleBoard);
|
||||
|
||||
showScreen(startScreen);
|
||||
}
|
||||
|
||||
// Start — safe for both early and late script execution
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 779 KiB |
Reference in new issue
Block a user