887 lines
26 KiB
JavaScript
887 lines
26 KiB
JavaScript
(() => {
|
|
const bootstrapData = window.APP_BOOTSTRAP || {};
|
|
const apiUrl = bootstrapData.apiUrl || 'api/scores.php';
|
|
const boardCanvas = document.getElementById('tetris-board');
|
|
const nextCanvas = document.getElementById('next-piece');
|
|
const holdCanvas = document.getElementById('hold-piece');
|
|
const startButton = document.getElementById('start-game-btn');
|
|
const pauseButton = document.getElementById('pause-game-btn');
|
|
const resetButton = document.getElementById('reset-run-btn');
|
|
const soundButton = document.getElementById('sound-toggle-btn');
|
|
const refreshBoardButton = document.getElementById('refresh-board-btn');
|
|
const scoreForm = document.getElementById('score-form');
|
|
const submitButton = document.getElementById('submit-score-btn');
|
|
const playerNameInput = document.getElementById('player-name');
|
|
const submissionState = document.getElementById('submission-state');
|
|
const leaderboardList = document.getElementById('leaderboard-list');
|
|
const overlay = document.getElementById('board-overlay');
|
|
const overlayTitle = document.getElementById('overlay-title');
|
|
const overlayCopy = document.getElementById('overlay-copy');
|
|
const toastElement = document.getElementById('app-toast');
|
|
const toastMessage = document.getElementById('toast-message');
|
|
const toastContext = document.getElementById('toast-context');
|
|
const controls = document.querySelectorAll('[data-control]');
|
|
|
|
if (!boardCanvas) {
|
|
return;
|
|
}
|
|
|
|
const ctx = boardCanvas.getContext('2d');
|
|
const nextCtx = nextCanvas ? nextCanvas.getContext('2d') : null;
|
|
const holdCtx = holdCanvas ? holdCanvas.getContext('2d') : null;
|
|
const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 2600 }) : null;
|
|
const COLS = 10;
|
|
const ROWS = 20;
|
|
const BLOCK = 30;
|
|
const PREVIEW_BLOCK = 24;
|
|
const LOCAL_BEST_KEY = 'retrostack-best-score';
|
|
const PLAYER_NAME_KEY = 'retrostack-player-name';
|
|
const DEVICE_KEY = 'retrostack-device-id';
|
|
|
|
boardCanvas.width = COLS * BLOCK;
|
|
boardCanvas.height = ROWS * BLOCK;
|
|
if (nextCanvas) {
|
|
nextCanvas.width = 120;
|
|
nextCanvas.height = 120;
|
|
}
|
|
if (holdCanvas) {
|
|
holdCanvas.width = 120;
|
|
holdCanvas.height = 120;
|
|
}
|
|
|
|
const palette = {
|
|
I: '#39f6ff',
|
|
J: '#3d8bff',
|
|
L: '#ff9a3d',
|
|
O: '#ffe45c',
|
|
S: '#33ffb5',
|
|
T: '#ff4fd8',
|
|
Z: '#ff5f7a'
|
|
};
|
|
|
|
const shapes = {
|
|
I: [[1, 1, 1, 1]],
|
|
J: [[1, 0, 0], [1, 1, 1]],
|
|
L: [[0, 0, 1], [1, 1, 1]],
|
|
O: [[1, 1], [1, 1]],
|
|
S: [[0, 1, 1], [1, 1, 0]],
|
|
T: [[0, 1, 0], [1, 1, 1]],
|
|
Z: [[1, 1, 0], [0, 1, 1]]
|
|
};
|
|
|
|
const genericKicks = [
|
|
[0, 0],
|
|
[-1, 0],
|
|
[1, 0],
|
|
[0, -1],
|
|
[-2, 0],
|
|
[2, 0],
|
|
[0, -2]
|
|
];
|
|
|
|
const audio = {
|
|
enabled: false,
|
|
ctx: null,
|
|
play(type) {
|
|
if (!this.enabled) return;
|
|
if (!this.ctx) {
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
if (!AudioContext) return;
|
|
this.ctx = new AudioContext();
|
|
}
|
|
const now = this.ctx.currentTime;
|
|
const oscillator = this.ctx.createOscillator();
|
|
const gain = this.ctx.createGain();
|
|
oscillator.connect(gain);
|
|
gain.connect(this.ctx.destination);
|
|
const tones = {
|
|
move: [200, 0.03, 'square'],
|
|
rotate: [280, 0.04, 'triangle'],
|
|
clear: [420, 0.12, 'sawtooth'],
|
|
drop: [160, 0.06, 'square'],
|
|
hold: [320, 0.06, 'triangle'],
|
|
gameOver: [110, 0.3, 'sine']
|
|
};
|
|
const [frequency, duration, wave] = tones[type] || tones.move;
|
|
oscillator.type = wave;
|
|
oscillator.frequency.setValueAtTime(frequency, now);
|
|
gain.gain.setValueAtTime(0.0001, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.1, now + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
|
oscillator.start(now);
|
|
oscillator.stop(now + duration + 0.02);
|
|
}
|
|
};
|
|
|
|
const initialPlayerName = window.localStorage.getItem(PLAYER_NAME_KEY) || '';
|
|
if (playerNameInput && initialPlayerName) {
|
|
playerNameInput.value = initialPlayerName;
|
|
}
|
|
|
|
const state = {
|
|
board: createBoard(),
|
|
activePiece: null,
|
|
queue: [],
|
|
holdType: null,
|
|
canHold: true,
|
|
score: 0,
|
|
lines: 0,
|
|
level: 1,
|
|
gameOver: false,
|
|
paused: false,
|
|
running: false,
|
|
dropAccumulator: 0,
|
|
dropInterval: 900,
|
|
lastFrame: 0,
|
|
animationFrame: null,
|
|
durationStart: null,
|
|
lastResult: { score: 0, lines: 0, level: 1, duration: 0 },
|
|
localBest: Number(window.localStorage.getItem(LOCAL_BEST_KEY) || 0),
|
|
submitting: false,
|
|
particles: [],
|
|
lineBursts: [],
|
|
screenFlash: 0
|
|
};
|
|
|
|
updateBestDisplay();
|
|
updateScoreDisplays();
|
|
updateActionState();
|
|
renderLeaderboard(Array.isArray(bootstrapData.topScores) ? bootstrapData.topScores : []);
|
|
renderBoard();
|
|
renderPreview(nextCtx, null);
|
|
renderPreview(holdCtx, null);
|
|
|
|
// setOverlay('Press Start', 'The board is idle. Start a run, clear lines, then submit your score online.', true);
|
|
// Replaced manual call to overlay since it was removed/simplified in HTML
|
|
console.log("Game initialized.");
|
|
|
|
startButton?.addEventListener('click', () => {
|
|
releaseActiveButtonFocus();
|
|
startGame(true);
|
|
});
|
|
pauseButton?.addEventListener('click', togglePause);
|
|
// resetButton?.addEventListener('click', () => startGame(true));
|
|
// soundButton?.addEventListener('click', toggleSound);
|
|
// refreshBoardButton?.addEventListener('click', () => fetchLeaderboard(true));
|
|
scoreForm?.addEventListener('submit', submitScore);
|
|
|
|
controls.forEach((button) => {
|
|
const action = () => handleControl(button.dataset.control || '');
|
|
button.addEventListener('click', action);
|
|
button.addEventListener('touchstart', (event) => {
|
|
event.preventDefault();
|
|
action();
|
|
}, { passive: false });
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName || '')) {
|
|
return;
|
|
}
|
|
|
|
switch (event.code) {
|
|
case 'ArrowLeft':
|
|
event.preventDefault();
|
|
handleControl('left');
|
|
break;
|
|
case 'ArrowRight':
|
|
event.preventDefault();
|
|
handleControl('right');
|
|
break;
|
|
case 'ArrowUp':
|
|
case 'KeyX':
|
|
event.preventDefault();
|
|
handleControl('rotate');
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
handleControl('softDrop');
|
|
break;
|
|
case 'Space':
|
|
event.preventDefault();
|
|
handleControl('hardDrop');
|
|
break;
|
|
case 'KeyC':
|
|
case 'ShiftLeft':
|
|
case 'ShiftRight':
|
|
event.preventDefault();
|
|
handleControl('hold');
|
|
break;
|
|
case 'Enter':
|
|
case 'NumpadEnter':
|
|
event.preventDefault();
|
|
if (!state.running || state.gameOver) {
|
|
releaseActiveButtonFocus();
|
|
startGame(true);
|
|
}
|
|
break;
|
|
case 'KeyP':
|
|
event.preventDefault();
|
|
togglePause();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
function releaseActiveButtonFocus() {
|
|
const activeElement = document.activeElement;
|
|
if (activeElement instanceof HTMLElement && activeElement.tagName === 'BUTTON') {
|
|
activeElement.blur();
|
|
}
|
|
}
|
|
|
|
function createBoard() {
|
|
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
|
}
|
|
|
|
function createBag() {
|
|
const bag = Object.keys(shapes).slice();
|
|
for (let i = bag.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[bag[i], bag[j]] = [bag[j], bag[i]];
|
|
}
|
|
return bag;
|
|
}
|
|
|
|
function cloneMatrix(matrix) {
|
|
return matrix.map((row) => row.slice());
|
|
}
|
|
|
|
function createPiece(type) {
|
|
const matrix = cloneMatrix(shapes[type]);
|
|
return {
|
|
type,
|
|
matrix,
|
|
x: Math.floor((COLS - matrix[0].length) / 2),
|
|
y: -getTopPadding(matrix),
|
|
color: palette[type]
|
|
};
|
|
}
|
|
|
|
function getTopPadding(matrix) {
|
|
let padding = 0;
|
|
for (const row of matrix) {
|
|
if (row.every((cell) => cell === 0)) {
|
|
padding += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return padding;
|
|
}
|
|
|
|
function ensureQueue() {
|
|
while (state.queue.length < 5) {
|
|
state.queue.push(...createBag());
|
|
}
|
|
}
|
|
|
|
function spawnPiece() {
|
|
ensureQueue();
|
|
const type = state.queue.shift();
|
|
state.activePiece = createPiece(type);
|
|
state.canHold = true;
|
|
if (collides(state.activePiece, 0, 0, state.activePiece.matrix)) {
|
|
endGame();
|
|
}
|
|
renderPreview(nextCtx, state.queue[0]);
|
|
}
|
|
|
|
function startGame(showToast = false) {
|
|
state.board = createBoard();
|
|
state.queue = [];
|
|
state.holdType = null;
|
|
state.canHold = true;
|
|
state.score = 0;
|
|
state.lines = 0;
|
|
state.level = 1;
|
|
state.dropInterval = getDropInterval();
|
|
state.dropAccumulator = 0;
|
|
state.gameOver = false;
|
|
state.paused = false;
|
|
state.running = true;
|
|
state.durationStart = performance.now();
|
|
state.lastResult = { score: 0, lines: 0, level: 1, duration: 0 };
|
|
state.particles = [];
|
|
state.lineBursts = [];
|
|
state.screenFlash = 0;
|
|
if (submissionState) {
|
|
submissionState.textContent = 'Finish a run, then save.';
|
|
}
|
|
if (pauseButton) {
|
|
pauseButton.textContent = 'Pause';
|
|
}
|
|
updateScoreDisplays();
|
|
updateActionState();
|
|
renderPreview(holdCtx, null);
|
|
ensureQueue();
|
|
spawnPiece();
|
|
// submissionState.textContent = 'Complete the run to unlock submission';
|
|
// submitButton.disabled = true;
|
|
|
|
if (state.animationFrame) {
|
|
cancelAnimationFrame(state.animationFrame);
|
|
}
|
|
state.lastFrame = 0;
|
|
tick(0);
|
|
}
|
|
|
|
function getDropInterval() {
|
|
return Math.max(110, 900 - (state.level - 1) * 70);
|
|
}
|
|
|
|
function tick(timestamp) {
|
|
if (!state.running) {
|
|
renderBoard();
|
|
return;
|
|
}
|
|
if (!state.lastFrame) {
|
|
state.lastFrame = timestamp;
|
|
}
|
|
const delta = timestamp - state.lastFrame;
|
|
state.lastFrame = timestamp;
|
|
|
|
if (!state.paused && !state.gameOver) {
|
|
state.dropAccumulator += delta;
|
|
if (state.dropAccumulator >= state.dropInterval) {
|
|
state.dropAccumulator = 0;
|
|
stepDown();
|
|
}
|
|
}
|
|
|
|
updateEffects(delta);
|
|
renderBoard();
|
|
state.animationFrame = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function collides(piece, offsetX = 0, offsetY = 0, matrix = piece.matrix) {
|
|
for (let y = 0; y < matrix.length; y += 1) {
|
|
for (let x = 0; x < matrix[y].length; x += 1) {
|
|
if (!matrix[y][x]) continue;
|
|
const boardX = piece.x + x + offsetX;
|
|
const boardY = piece.y + y + offsetY;
|
|
if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
|
|
return true;
|
|
}
|
|
if (boardY >= 0 && state.board[boardY][boardX]) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function mergePiece() {
|
|
const lockedCells = [];
|
|
state.activePiece.matrix.forEach((row, y) => {
|
|
row.forEach((cell, x) => {
|
|
if (!cell) return;
|
|
const boardY = state.activePiece.y + y;
|
|
const boardX = state.activePiece.x + x;
|
|
if (boardY >= 0 && boardY < ROWS && boardX >= 0 && boardX < COLS) {
|
|
state.board[boardY][boardX] = state.activePiece.color;
|
|
lockedCells.push({ x: boardX, y: boardY, color: state.activePiece.color });
|
|
}
|
|
});
|
|
});
|
|
return lockedCells;
|
|
}
|
|
|
|
function clearLines() {
|
|
let cleared = 0;
|
|
const clearedRows = [];
|
|
for (let y = ROWS - 1; y >= 0; y -= 1) {
|
|
if (state.board[y].every(Boolean)) {
|
|
clearedRows.push(y);
|
|
state.board.splice(y, 1);
|
|
state.board.unshift(Array(COLS).fill(null));
|
|
cleared += 1;
|
|
y += 1;
|
|
}
|
|
}
|
|
|
|
if (cleared > 0) {
|
|
const scoreTable = [0, 100, 300, 500, 800];
|
|
state.score += scoreTable[cleared] * state.level;
|
|
state.lines += cleared;
|
|
state.level = Math.floor(state.lines / 10) + 1;
|
|
state.dropInterval = getDropInterval();
|
|
triggerLineClearEffect(clearedRows);
|
|
audio.play('clear');
|
|
}
|
|
|
|
return clearedRows;
|
|
}
|
|
|
|
function stepDown() {
|
|
if (!state.activePiece) return;
|
|
if (!collides(state.activePiece, 0, 1)) {
|
|
state.activePiece.y += 1;
|
|
return;
|
|
}
|
|
const lockedCells = mergePiece();
|
|
triggerLockEffect(lockedCells);
|
|
clearLines();
|
|
updateScoreDisplays();
|
|
spawnPiece();
|
|
}
|
|
|
|
function move(direction) {
|
|
if (!canPlay()) return;
|
|
if (!collides(state.activePiece, direction, 0)) {
|
|
state.activePiece.x += direction;
|
|
audio.play('move');
|
|
renderBoard();
|
|
}
|
|
}
|
|
|
|
function rotateMatrix(matrix) {
|
|
return matrix[0].map((_, index) => matrix.map((row) => row[index]).reverse());
|
|
}
|
|
|
|
function rotatePiece() {
|
|
if (!canPlay()) return;
|
|
const rotated = rotateMatrix(state.activePiece.matrix);
|
|
const kicks = state.activePiece.type === 'O' ? [[0, 0]] : genericKicks;
|
|
for (const [x, y] of kicks) {
|
|
if (!collides(state.activePiece, x, y, rotated)) {
|
|
state.activePiece.matrix = rotated;
|
|
state.activePiece.x += x;
|
|
state.activePiece.y += y;
|
|
audio.play('rotate');
|
|
renderBoard();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function softDrop() {
|
|
if (!canPlay()) return;
|
|
if (!collides(state.activePiece, 0, 1)) {
|
|
state.activePiece.y += 1;
|
|
state.score += 1;
|
|
updateScoreDisplays();
|
|
renderBoard();
|
|
} else {
|
|
stepDown();
|
|
}
|
|
}
|
|
|
|
function hardDrop() {
|
|
if (!canPlay()) return;
|
|
let dropDistance = 0;
|
|
while (!collides(state.activePiece, 0, 1)) {
|
|
state.activePiece.y += 1;
|
|
dropDistance += 1;
|
|
}
|
|
state.score += dropDistance * 2;
|
|
audio.play('drop');
|
|
stepDown();
|
|
updateScoreDisplays();
|
|
}
|
|
|
|
function holdPiece() {
|
|
if (!canPlay() || !state.canHold) return;
|
|
const currentType = state.activePiece.type;
|
|
if (state.holdType) {
|
|
const swapType = state.holdType;
|
|
state.holdType = currentType;
|
|
state.activePiece = createPiece(swapType);
|
|
if (collides(state.activePiece, 0, 0, state.activePiece.matrix)) {
|
|
endGame();
|
|
return;
|
|
}
|
|
} else {
|
|
state.holdType = currentType;
|
|
spawnPiece();
|
|
}
|
|
state.canHold = false;
|
|
renderPreview(holdCtx, state.holdType);
|
|
audio.play('hold');
|
|
renderBoard();
|
|
}
|
|
|
|
function canPlay() {
|
|
return state.running && !state.paused && !state.gameOver && state.activePiece;
|
|
}
|
|
|
|
function handleControl(control) {
|
|
switch (control) {
|
|
case 'left':
|
|
move(-1);
|
|
break;
|
|
case 'right':
|
|
move(1);
|
|
break;
|
|
case 'rotate':
|
|
rotatePiece();
|
|
break;
|
|
case 'softDrop':
|
|
softDrop();
|
|
break;
|
|
case 'hardDrop':
|
|
hardDrop();
|
|
break;
|
|
case 'hold':
|
|
holdPiece();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function togglePause() {
|
|
if (!state.running || state.gameOver) return;
|
|
state.paused = !state.paused;
|
|
if (pauseButton) {
|
|
pauseButton.textContent = state.paused ? 'Resume' : 'Pause';
|
|
}
|
|
}
|
|
|
|
function endGame() {
|
|
state.gameOver = true;
|
|
state.running = false;
|
|
const duration = getDurationSeconds();
|
|
state.lastResult = {
|
|
score: state.score,
|
|
lines: state.lines,
|
|
level: state.level,
|
|
duration
|
|
};
|
|
if (state.score > state.localBest) {
|
|
state.localBest = state.score;
|
|
window.localStorage.setItem(LOCAL_BEST_KEY, String(state.localBest));
|
|
updateBestDisplay();
|
|
}
|
|
updateScoreDisplays();
|
|
updateActionState();
|
|
if (submissionState) {
|
|
submissionState.textContent = 'Ready to save.';
|
|
}
|
|
audio.play('gameOver');
|
|
}
|
|
|
|
function getDurationSeconds() {
|
|
if (!state.durationStart) return state.lastResult.duration || 0;
|
|
return Math.max(0, Math.round((performance.now() - state.durationStart) / 1000));
|
|
}
|
|
|
|
function updateBestDisplay() {
|
|
const bestValue = document.getElementById('best-value');
|
|
if (bestValue) {
|
|
bestValue.textContent = Number(state.localBest).toLocaleString();
|
|
}
|
|
}
|
|
|
|
function updateActionState() {
|
|
if (pauseButton) {
|
|
pauseButton.disabled = !state.running || state.gameOver;
|
|
}
|
|
if (submitButton) {
|
|
submitButton.disabled = state.submitting || !state.gameOver || state.lastResult.score <= 0;
|
|
}
|
|
}
|
|
|
|
function updateScoreDisplays() {
|
|
const entries = {
|
|
'score-value': state.score,
|
|
'lines-value': state.lines,
|
|
'level-value': state.level
|
|
};
|
|
Object.entries(entries).forEach(([id, value]) => {
|
|
const element = document.getElementById(id);
|
|
if (element) {
|
|
element.textContent = typeof value === 'number' ? Number(value).toLocaleString() : value;
|
|
}
|
|
});
|
|
updateActionState();
|
|
}
|
|
|
|
function hexToRgba(hex, alpha = 1) {
|
|
let value = String(hex || '').replace('#', '');
|
|
if (value.length === 3) {
|
|
value = value.split('').map((part) => part + part).join('');
|
|
}
|
|
const parsed = Number.parseInt(value, 16);
|
|
if (Number.isNaN(parsed)) {
|
|
return `rgba(255, 255, 255, ${alpha})`;
|
|
}
|
|
const r = (parsed >> 16) & 255;
|
|
const g = (parsed >> 8) & 255;
|
|
const b = parsed & 255;
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
function randomBetween(min, max) {
|
|
return Math.random() * (max - min) + min;
|
|
}
|
|
|
|
function triggerLockEffect(cells) {
|
|
if (!Array.isArray(cells) || !cells.length) return;
|
|
cells.slice(0, 6).forEach((cell) => {
|
|
const centerX = cell.x * BLOCK + BLOCK / 2;
|
|
const centerY = cell.y * BLOCK + BLOCK / 2;
|
|
for (let index = 0; index < 3; index += 1) {
|
|
state.particles.push({
|
|
x: centerX + randomBetween(-4, 4),
|
|
y: centerY + randomBetween(-4, 4),
|
|
vx: randomBetween(-65, 65),
|
|
vy: randomBetween(-140, -30),
|
|
size: randomBetween(3, 6),
|
|
life: randomBetween(140, 240),
|
|
maxLife: 240,
|
|
color: cell.color
|
|
});
|
|
}
|
|
});
|
|
state.screenFlash = Math.max(state.screenFlash, 0.08);
|
|
}
|
|
|
|
function triggerLineClearEffect(rows) {
|
|
if (!Array.isArray(rows) || !rows.length) return;
|
|
rows.forEach((row) => {
|
|
state.lineBursts.push({ row, life: 260, maxLife: 260 });
|
|
for (let x = 0; x < COLS; x += 1) {
|
|
const centerX = x * BLOCK + BLOCK / 2;
|
|
const centerY = row * BLOCK + BLOCK / 2;
|
|
for (let index = 0; index < 2; index += 1) {
|
|
state.particles.push({
|
|
x: centerX + randomBetween(-6, 6),
|
|
y: centerY + randomBetween(-5, 5),
|
|
vx: randomBetween(-170, 170),
|
|
vy: randomBetween(-120, 45),
|
|
size: randomBetween(4, 8),
|
|
life: randomBetween(220, 360),
|
|
maxLife: 360,
|
|
color: x % 2 === 0 ? '#48e7ff' : '#ff4fd8'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
state.screenFlash = Math.max(state.screenFlash, Math.min(0.26, 0.12 + rows.length * 0.04));
|
|
}
|
|
|
|
function updateEffects(delta) {
|
|
const safeDelta = Math.max(0, Math.min(delta || 0, 48));
|
|
const seconds = safeDelta / 1000;
|
|
|
|
if (state.screenFlash > 0) {
|
|
state.screenFlash = Math.max(0, state.screenFlash - seconds * 1.8);
|
|
}
|
|
|
|
state.lineBursts = state.lineBursts.filter((burst) => {
|
|
burst.life -= safeDelta;
|
|
return burst.life > 0;
|
|
});
|
|
|
|
state.particles = state.particles.filter((particle) => {
|
|
particle.life -= safeDelta;
|
|
if (particle.life <= 0) {
|
|
return false;
|
|
}
|
|
particle.x += particle.vx * seconds;
|
|
particle.y += particle.vy * seconds;
|
|
particle.vy += 420 * seconds;
|
|
particle.vx *= 0.985;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function renderEffects() {
|
|
if (!state.lineBursts.length && !state.particles.length && state.screenFlash <= 0) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
|
|
state.lineBursts.forEach((burst) => {
|
|
const progress = Math.max(0, burst.life / burst.maxLife);
|
|
const glowY = burst.row * BLOCK;
|
|
ctx.fillStyle = `rgba(72, 231, 255, ${0.18 * progress})`;
|
|
ctx.fillRect(0, glowY + 2, boardCanvas.width, BLOCK - 4);
|
|
ctx.fillStyle = `rgba(255, 79, 216, ${0.78 * progress})`;
|
|
ctx.fillRect(0, glowY + Math.floor(BLOCK / 2) - 1, boardCanvas.width, 2);
|
|
});
|
|
|
|
state.particles.forEach((particle) => {
|
|
const alpha = Math.max(0, particle.life / particle.maxLife);
|
|
ctx.fillStyle = hexToRgba(particle.color, alpha);
|
|
ctx.fillRect(Math.round(particle.x), Math.round(particle.y), particle.size, particle.size);
|
|
ctx.fillStyle = `rgba(236, 253, 255, ${alpha * 0.68})`;
|
|
ctx.fillRect(Math.round(particle.x), Math.round(particle.y), Math.max(1, particle.size - 2), 1);
|
|
});
|
|
|
|
if (state.screenFlash > 0) {
|
|
ctx.fillStyle = `rgba(72, 231, 255, ${state.screenFlash * 0.2})`;
|
|
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawCell(context, x, y, color, size = BLOCK, padding = 1) {
|
|
const px = x * size;
|
|
const py = y * size;
|
|
const innerSize = size - padding * 2;
|
|
|
|
context.fillStyle = color;
|
|
context.fillRect(px + padding, py + padding, innerSize, innerSize);
|
|
|
|
context.fillStyle = hexToRgba('#ecfdff', 0.24);
|
|
context.fillRect(px + padding + 2, py + padding + 2, Math.max(4, innerSize - 6), Math.max(2, Math.floor(size * 0.14)));
|
|
|
|
context.fillStyle = hexToRgba('#03131c', 0.34);
|
|
context.fillRect(px + padding + 2, py + padding + innerSize - 5, Math.max(4, innerSize - 6), 3);
|
|
|
|
context.strokeStyle = 'rgba(6, 18, 30, 0.86)';
|
|
context.strokeRect(px + padding + 0.5, py + padding + 0.5, innerSize - 1, innerSize - 1);
|
|
}
|
|
|
|
function renderGrid() {
|
|
ctx.strokeStyle = 'rgba(72, 231, 255, 0.08)';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x <= COLS; x += 1) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * BLOCK, 0);
|
|
ctx.lineTo(x * BLOCK, ROWS * BLOCK);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= ROWS; y += 1) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * BLOCK);
|
|
ctx.lineTo(COLS * BLOCK, y * BLOCK);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function renderBoard() {
|
|
ctx.fillStyle = '#040914';
|
|
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
|
renderGrid();
|
|
|
|
state.board.forEach((row, y) => {
|
|
row.forEach((cell, x) => {
|
|
if (cell) {
|
|
drawCell(ctx, x, y, cell);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (state.activePiece) {
|
|
state.activePiece.matrix.forEach((row, y) => {
|
|
row.forEach((cell, x) => {
|
|
if (!cell) return;
|
|
const drawY = state.activePiece.y + y;
|
|
if (drawY >= 0) {
|
|
drawCell(ctx, state.activePiece.x + x, drawY, state.activePiece.color);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
renderEffects();
|
|
renderPreview(holdCtx, state.holdType);
|
|
renderPreview(nextCtx, state.queue[0]);
|
|
updateScoreDisplays();
|
|
}
|
|
|
|
function renderPreview(context, type) {
|
|
if (!context) return;
|
|
context.fillStyle = '#06101d';
|
|
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
|
context.strokeStyle = 'rgba(72, 231, 255, 0.12)';
|
|
context.strokeRect(0.5, 0.5, context.canvas.width - 1, context.canvas.height - 1);
|
|
|
|
if (!type || !shapes[type]) return;
|
|
const matrix = shapes[type];
|
|
const offsetX = Math.floor((context.canvas.width - matrix[0].length * PREVIEW_BLOCK) / 2 / PREVIEW_BLOCK);
|
|
const offsetY = Math.floor((context.canvas.height - matrix.length * PREVIEW_BLOCK) / 2 / PREVIEW_BLOCK);
|
|
matrix.forEach((row, y) => {
|
|
row.forEach((cell, x) => {
|
|
if (!cell) return;
|
|
drawCell(context, x + offsetX, y + offsetY, palette[type], PREVIEW_BLOCK, 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function submitScore(event) {
|
|
event.preventDefault();
|
|
if (state.submitting) return;
|
|
const playerName = (playerNameInput?.value || '').trim();
|
|
if (!state.gameOver || state.lastResult.score <= 0) {
|
|
return;
|
|
}
|
|
if (playerName.length < 2) return;
|
|
|
|
window.localStorage.setItem(PLAYER_NAME_KEY, playerName);
|
|
state.submitting = true;
|
|
updateActionState();
|
|
if (submissionState) {
|
|
submissionState.textContent = 'Saving...';
|
|
}
|
|
try {
|
|
const response = await fetch(apiUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
player_name: playerName,
|
|
score: state.lastResult.score,
|
|
lines_cleared: state.lastResult.lines,
|
|
level_reached: state.lastResult.level,
|
|
duration_seconds: state.lastResult.duration,
|
|
client_signature: getDeviceSignature()
|
|
})
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data.success) throw new Error(data.message);
|
|
renderLeaderboard(data.scores || []);
|
|
if (submissionState) {
|
|
submissionState.textContent = 'Saved.';
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
if (submissionState) {
|
|
submissionState.textContent = 'Save failed.';
|
|
}
|
|
} finally {
|
|
state.submitting = false;
|
|
updateActionState();
|
|
}
|
|
}
|
|
|
|
async function fetchLeaderboard() {
|
|
try {
|
|
const response = await fetch(`${apiUrl}?limit=12`, { headers: { Accept: 'application/json' } });
|
|
const data = await response.json();
|
|
if (response.ok && data.success) renderLeaderboard(data.scores || []);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
function renderLeaderboard(scores) {
|
|
if (!leaderboardList) return;
|
|
leaderboardList.innerHTML = scores.map((entry, index) => {
|
|
const id = Number(entry.id || 0);
|
|
const safeName = entry.player_name || 'Player';
|
|
const score = Number(entry.score || 0).toLocaleString();
|
|
return `
|
|
<a class="leaderboard-item" href="score.php?id=${id}">
|
|
<span class="leaderboard-rank">#${index + 1}</span>
|
|
<span class="leaderboard-player">${safeName}</span>
|
|
<span class="leaderboard-meta">${score}</span>
|
|
</a>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function getDeviceSignature() {
|
|
let deviceId = window.localStorage.getItem(DEVICE_KEY);
|
|
if (!deviceId) {
|
|
deviceId = `device-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
window.localStorage.setItem(DEVICE_KEY, deviceId);
|
|
}
|
|
return deviceId;
|
|
}
|
|
})(); |