1063 lines
38 KiB
JavaScript
1063 lines
38 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const boardCanvas = document.getElementById('tetris-board');
|
|
const nextCanvas = document.getElementById('next-piece');
|
|
if (!boardCanvas || !nextCanvas) {
|
|
return;
|
|
}
|
|
|
|
const boardCtx = boardCanvas.getContext('2d');
|
|
const nextCtx = nextCanvas.getContext('2d');
|
|
|
|
const scoreValue = document.getElementById('score-value');
|
|
const levelValue = document.getElementById('level-value');
|
|
const linesValue = document.getElementById('lines-value');
|
|
const statusValue = document.getElementById('status-value');
|
|
const savePlaceholder = document.getElementById('save-placeholder');
|
|
const saveForm = document.getElementById('save-form');
|
|
const runSummary = document.getElementById('run-summary');
|
|
const finalScore = document.getElementById('final_score');
|
|
const finalLines = document.getElementById('final_lines');
|
|
const finalLevel = document.getElementById('final_level');
|
|
const finalDuration = document.getElementById('final_duration');
|
|
const scoreInput = document.getElementById('score-input');
|
|
const linesInput = document.getElementById('lines-input');
|
|
const levelInput = document.getElementById('level-input');
|
|
const durationInput = document.getElementById('duration-input');
|
|
const playerNameInput = document.getElementById('player_name');
|
|
const roomNicknameInput = document.getElementById('room_player_name');
|
|
const roomCodeInput = document.getElementById('room_code_input');
|
|
const createRoomButton = document.getElementById('create-room-btn');
|
|
const joinRoomButton = document.getElementById('join-room-btn');
|
|
const roomEmptyState = document.getElementById('room-empty-state');
|
|
const roomStateCard = document.getElementById('room-state-card');
|
|
const roomStatusBadge = document.getElementById('room-status-badge');
|
|
const currentRoomCodeLabel = document.getElementById('current-room-code');
|
|
const roomHostName = document.getElementById('room-host-name');
|
|
const roomGuestName = document.getElementById('room-guest-name');
|
|
const roomPlayerCount = document.getElementById('room-player-count');
|
|
const roomUpdatedAt = document.getElementById('room-updated-at');
|
|
const roomMessage = document.getElementById('room-message');
|
|
const refreshRoomStatusButton = document.getElementById('refresh-room-status-btn');
|
|
const copyRoomCodeButton = document.getElementById('copy-room-code-btn');
|
|
const leaderboardTableWrap = document.getElementById('leaderboard-table-wrap');
|
|
const leaderboardBody = document.getElementById('leaderboard-body');
|
|
const leaderboardEmptyState = document.getElementById('leaderboard-empty-state');
|
|
const recentRunsList = document.getElementById('recent-runs-list');
|
|
const recentEmptyState = document.getElementById('recent-empty-state');
|
|
const bestPlayerLabel = document.getElementById('best-player-label');
|
|
const leaderboardRefreshLabel = document.getElementById('leaderboard-refresh-label');
|
|
const restartButtons = [
|
|
document.getElementById('restart-top'),
|
|
document.getElementById('restart-inline'),
|
|
document.getElementById('restart-after-game')
|
|
].filter(Boolean);
|
|
const startButtons = [
|
|
document.getElementById('start-top'),
|
|
document.getElementById('start-game')
|
|
].filter(Boolean);
|
|
const startOverlay = document.getElementById('board-start-overlay');
|
|
const focusButton = document.getElementById('focus-game');
|
|
const controlButtons = Array.from(document.querySelectorAll('[data-action]'));
|
|
|
|
const pageConfig = window.TETRIS_PAGE || {};
|
|
const playerNameStorageKey = 'midnight-blocks-player-name';
|
|
const roomCodeStorageKey = 'midnight-blocks-room-code';
|
|
let currentRoomCode = '';
|
|
let roomPollHandle = null;
|
|
const boardScale = 30;
|
|
const nextScale = 30;
|
|
const rows = 20;
|
|
const cols = 10;
|
|
const lineScores = [0, 100, 300, 500, 800];
|
|
|
|
boardCtx.scale(boardScale, boardScale);
|
|
nextCtx.scale(nextScale, nextScale);
|
|
|
|
const colors = {
|
|
I: { light: '#c9fbff', base: '#5ee8ff', dark: '#0891b2', glow: 'rgba(94, 232, 255, 0.42)' },
|
|
J: { light: '#c7dbff', base: '#60a5fa', dark: '#1d4ed8', glow: 'rgba(96, 165, 250, 0.42)' },
|
|
L: { light: '#ffe1b8', base: '#fb923c', dark: '#c2410c', glow: 'rgba(251, 146, 60, 0.4)' },
|
|
O: { light: '#fff4b8', base: '#facc15', dark: '#ca8a04', glow: 'rgba(250, 204, 21, 0.36)' },
|
|
S: { light: '#d1ffdd', base: '#4ade80', dark: '#15803d', glow: 'rgba(74, 222, 128, 0.4)' },
|
|
T: { light: '#ffd4dd', base: '#fb7185', dark: '#be123c', glow: 'rgba(251, 113, 133, 0.4)' },
|
|
Z: { light: '#ffd0bf', base: '#f97316', dark: '#c2410c', glow: 'rgba(249, 115, 22, 0.38)' }
|
|
};
|
|
|
|
const pieces = {
|
|
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
|
|
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
|
|
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
|
|
O: [[1, 1], [1, 1]],
|
|
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
|
|
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
|
|
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]]
|
|
};
|
|
|
|
const state = {
|
|
board: createMatrix(cols, rows),
|
|
piece: null,
|
|
nextQueue: [],
|
|
dropCounter: 0,
|
|
dropInterval: 900,
|
|
lastTime: 0,
|
|
score: 0,
|
|
lines: 0,
|
|
level: 1,
|
|
isGameOver: false,
|
|
isStarted: false,
|
|
startedAt: null
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}[char]));
|
|
}
|
|
|
|
function sanitizePlayerName(value) {
|
|
return String(value ?? '')
|
|
.replace(/[^\p{L}\p{N} _.-]+/gu, '')
|
|
.trim()
|
|
.slice(0, 24);
|
|
}
|
|
|
|
function rememberPlayerName(value) {
|
|
const clean = sanitizePlayerName(value);
|
|
if (!clean) {
|
|
return '';
|
|
}
|
|
try {
|
|
window.localStorage.setItem(playerNameStorageKey, clean);
|
|
} catch (error) {
|
|
// Ignore storage errors silently.
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
function loadRememberedPlayerName() {
|
|
try {
|
|
return sanitizePlayerName(window.localStorage.getItem(playerNameStorageKey) || '');
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function sanitizeRoomCode(value) {
|
|
return String(value ?? '')
|
|
.replace(/[^A-Za-z0-9]+/g, '')
|
|
.toUpperCase()
|
|
.slice(0, 6);
|
|
}
|
|
|
|
function rememberRoomCode(value) {
|
|
const clean = sanitizeRoomCode(value);
|
|
try {
|
|
if (clean) {
|
|
window.localStorage.setItem(roomCodeStorageKey, clean);
|
|
} else {
|
|
window.localStorage.removeItem(roomCodeStorageKey);
|
|
}
|
|
} catch (error) {
|
|
// Ignore storage errors silently.
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
function loadRememberedRoomCode() {
|
|
try {
|
|
return sanitizeRoomCode(window.localStorage.getItem(roomCodeStorageKey) || '');
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function applyNicknameToInputs(value, sourceInput = null) {
|
|
const clean = sanitizePlayerName(value);
|
|
[playerNameInput, roomNicknameInput].forEach((input) => {
|
|
if (input && input !== sourceInput && input.value !== clean) {
|
|
input.value = clean;
|
|
}
|
|
});
|
|
return clean;
|
|
}
|
|
|
|
function syncNicknameFromInput(input) {
|
|
if (!input) {
|
|
return '';
|
|
}
|
|
const clean = sanitizePlayerName(input.value);
|
|
if (input.value !== clean) {
|
|
input.value = clean;
|
|
}
|
|
applyNicknameToInputs(clean, input);
|
|
rememberPlayerName(clean);
|
|
return clean;
|
|
}
|
|
|
|
function getActiveNickname() {
|
|
const value = sanitizePlayerName(roomNicknameInput?.value || playerNameInput?.value || loadRememberedPlayerName());
|
|
if (!value) {
|
|
showToast('Nickname needed', 'Enter your nickname before creating or joining a room.');
|
|
roomNicknameInput?.focus({ preventScroll: false });
|
|
playerNameInput?.focus({ preventScroll: false });
|
|
return '';
|
|
}
|
|
applyNicknameToInputs(value);
|
|
rememberPlayerName(value);
|
|
return value;
|
|
}
|
|
|
|
function formatRelativeTime(value) {
|
|
const timestamp = Date.parse(value || '');
|
|
if (!Number.isFinite(timestamp)) {
|
|
return 'Live sync';
|
|
}
|
|
const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
|
if (diffSeconds < 10) return 'Updated just now';
|
|
if (diffSeconds < 60) return `Updated ${diffSeconds}s ago`;
|
|
if (diffSeconds < 3600) return `Updated ${Math.floor(diffSeconds / 60)}m ago`;
|
|
return `Updated ${Math.floor(diffSeconds / 3600)}h ago`;
|
|
}
|
|
|
|
function formatRecentTime(value) {
|
|
const timestamp = Date.parse(value || '');
|
|
if (!Number.isFinite(timestamp)) {
|
|
return 'now';
|
|
}
|
|
const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
|
if (diffSeconds < 60) return 'now';
|
|
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m`;
|
|
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h`;
|
|
return `${Math.floor(diffSeconds / 86400)}d`;
|
|
}
|
|
|
|
function renderLeaderboard(leaderboard) {
|
|
if (!leaderboardBody || !leaderboardTableWrap || !leaderboardEmptyState) {
|
|
return;
|
|
}
|
|
if (!Array.isArray(leaderboard) || leaderboard.length === 0) {
|
|
leaderboardBody.innerHTML = '';
|
|
leaderboardTableWrap.classList.add('d-none');
|
|
leaderboardEmptyState.classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
leaderboardBody.innerHTML = leaderboard.map((run, index) => `
|
|
<tr>
|
|
<td>${index + 1}</td>
|
|
<td><a class="table-link" href="/score.php?id=${Number(run.id) || 0}">${escapeHtml(run.player_name || 'Anonymous')}</a></td>
|
|
<td class="text-end fw-semibold">${Number(run.score || 0).toLocaleString()}</td>
|
|
</tr>
|
|
`).join('');
|
|
leaderboardTableWrap.classList.remove('d-none');
|
|
leaderboardEmptyState.classList.add('d-none');
|
|
}
|
|
|
|
function renderRecentRuns(recentRuns) {
|
|
if (!recentRunsList || !recentEmptyState) {
|
|
return;
|
|
}
|
|
if (!Array.isArray(recentRuns) || recentRuns.length === 0) {
|
|
recentRunsList.innerHTML = '';
|
|
recentRunsList.classList.add('d-none');
|
|
recentEmptyState.classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
recentRunsList.innerHTML = recentRuns.map((run) => `
|
|
<a class="recent-item" href="/score.php?id=${Number(run.id) || 0}">
|
|
<div>
|
|
<strong>${escapeHtml(run.player_name || 'Anonymous')}</strong>
|
|
<div class="recent-item-meta">${Number(run.score || 0).toLocaleString()} pts · ${Number(run.lines_cleared || 0).toLocaleString()} lines</div>
|
|
</div>
|
|
<span class="tiny-muted">${formatRecentTime(run.created_at_iso)}</span>
|
|
</a>
|
|
`).join('');
|
|
recentRunsList.classList.remove('d-none');
|
|
recentEmptyState.classList.add('d-none');
|
|
}
|
|
|
|
async function syncLeaderboard() {
|
|
if (!pageConfig.leaderboardApi) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(`${pageConfig.leaderboardApi}?_=${Date.now()}`, {
|
|
headers: { Accept: 'application/json' },
|
|
cache: 'no-store'
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const payload = await response.json();
|
|
if (!payload || payload.success !== true) {
|
|
throw new Error('Invalid leaderboard payload');
|
|
}
|
|
renderLeaderboard(payload.leaderboard || []);
|
|
renderRecentRuns(payload.recent_runs || []);
|
|
|
|
if (bestPlayerLabel) {
|
|
if (payload.best_run && payload.best_run.player_name) {
|
|
bestPlayerLabel.textContent = `Best: ${payload.best_run.player_name}`;
|
|
} else {
|
|
bestPlayerLabel.textContent = 'Waiting for first score';
|
|
}
|
|
}
|
|
|
|
if (leaderboardRefreshLabel) {
|
|
leaderboardRefreshLabel.textContent = formatRelativeTime(payload.updated_at || '');
|
|
}
|
|
} catch (error) {
|
|
if (leaderboardRefreshLabel) {
|
|
leaderboardRefreshLabel.textContent = 'Sync paused';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderRoomState(room) {
|
|
if (!roomStateCard || !roomEmptyState || !roomStatusBadge || !currentRoomCodeLabel || !roomHostName || !roomGuestName || !roomPlayerCount || !roomUpdatedAt || !roomMessage) {
|
|
return;
|
|
}
|
|
|
|
currentRoomCode = sanitizeRoomCode(room?.room_code || '');
|
|
rememberRoomCode(currentRoomCode);
|
|
|
|
roomEmptyState.classList.add('d-none');
|
|
roomStateCard.classList.remove('d-none');
|
|
currentRoomCodeLabel.textContent = currentRoomCode || '------';
|
|
roomHostName.textContent = room?.host_name || '—';
|
|
roomGuestName.textContent = room?.guest_name || 'Waiting for friend…';
|
|
roomPlayerCount.textContent = `${Number(room?.player_count || (room?.guest_name ? 2 : 1))} / 2`;
|
|
roomUpdatedAt.textContent = formatRelativeTime(room?.updated_at_iso || '').replace(/^Updated\s+/i, '');
|
|
|
|
if (roomCodeInput && currentRoomCode) {
|
|
roomCodeInput.value = currentRoomCode;
|
|
}
|
|
|
|
const ready = room?.is_ready || room?.status === 'ready';
|
|
roomStatusBadge.textContent = ready ? 'Ready' : 'Waiting';
|
|
roomStatusBadge.className = `room-status-badge ${ready ? 'room-status-ready' : 'room-status-waiting'}`;
|
|
roomMessage.textContent = ready
|
|
? `${room?.guest_name || 'Your friend'} joined. The lobby is ready. Next step: sync the live head-to-head match.`
|
|
: `Share code ${currentRoomCode}. The lobby will switch to ready as soon as your friend joins.`;
|
|
}
|
|
|
|
function clearRoomState(message = 'No active room yet. Create one or paste a code from your friend.') {
|
|
currentRoomCode = '';
|
|
rememberRoomCode('');
|
|
if (roomPollHandle) {
|
|
window.clearInterval(roomPollHandle);
|
|
roomPollHandle = null;
|
|
}
|
|
if (roomStateCard) {
|
|
roomStateCard.classList.add('d-none');
|
|
}
|
|
if (roomEmptyState) {
|
|
roomEmptyState.classList.remove('d-none');
|
|
roomEmptyState.innerHTML = `<p class="mb-0 text-secondary">${escapeHtml(message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function startRoomPolling() {
|
|
if (roomPollHandle) {
|
|
window.clearInterval(roomPollHandle);
|
|
}
|
|
if (!currentRoomCode || !pageConfig.roomsApi) {
|
|
roomPollHandle = null;
|
|
return;
|
|
}
|
|
roomPollHandle = window.setInterval(() => {
|
|
syncRoomStatus(currentRoomCode, { silent: true });
|
|
}, 5000);
|
|
}
|
|
|
|
async function sendRoomRequest(action, payload = {}) {
|
|
if (!pageConfig.roomsApi) {
|
|
throw new Error('Rooms API is not available.');
|
|
}
|
|
|
|
const body = new URLSearchParams({ action, ...payload });
|
|
const response = await fetch(pageConfig.roomsApi, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
|
},
|
|
body: body.toString(),
|
|
cache: 'no-store'
|
|
});
|
|
|
|
const payloadJson = await response.json().catch(() => null);
|
|
if (!response.ok || !payloadJson || payloadJson.success !== true) {
|
|
throw new Error(payloadJson?.error || 'Unable to update the room.');
|
|
}
|
|
|
|
return payloadJson;
|
|
}
|
|
|
|
async function syncRoomStatus(roomCode, options = {}) {
|
|
const code = sanitizeRoomCode(roomCode);
|
|
if (!code || !pageConfig.roomsApi) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${pageConfig.roomsApi}?room_code=${encodeURIComponent(code)}&_=${Date.now()}`, {
|
|
headers: { Accept: 'application/json' },
|
|
cache: 'no-store'
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok || !payload || payload.success !== true || !payload.room) {
|
|
throw new Error(payload?.error || 'Room not found.');
|
|
}
|
|
renderRoomState(payload.room);
|
|
startRoomPolling();
|
|
return payload.room;
|
|
} catch (error) {
|
|
if (!options.silent) {
|
|
showToast('Room sync failed', error.message || 'Unable to refresh the room right now.');
|
|
}
|
|
if (!options.keepState) {
|
|
clearRoomState(error.message || 'Room not found.');
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createMatrix(width, height) {
|
|
return Array.from({ length: height }, () => Array(width).fill(0));
|
|
}
|
|
|
|
function cloneMatrix(matrix) {
|
|
return matrix.map((row) => row.slice());
|
|
}
|
|
|
|
function randomBag() {
|
|
const bag = Object.keys(pieces);
|
|
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 ensureQueue() {
|
|
while (state.nextQueue.length < 7) {
|
|
state.nextQueue.push(...randomBag());
|
|
}
|
|
}
|
|
|
|
function createPiece(type) {
|
|
return {
|
|
type,
|
|
matrix: cloneMatrix(pieces[type]),
|
|
pos: { x: 0, y: 0 }
|
|
};
|
|
}
|
|
|
|
function spawnPiece() {
|
|
ensureQueue();
|
|
const type = state.nextQueue.shift();
|
|
state.piece = createPiece(type);
|
|
state.piece.pos.y = 0;
|
|
state.piece.pos.x = Math.floor(cols / 2) - Math.ceil(state.piece.matrix[0].length / 2);
|
|
drawNextPiece();
|
|
|
|
if (collides(state.board, state.piece)) {
|
|
endGame();
|
|
}
|
|
}
|
|
|
|
function collides(board, player) {
|
|
const { matrix, pos } = player;
|
|
for (let y = 0; y < matrix.length; y += 1) {
|
|
for (let x = 0; x < matrix[y].length; x += 1) {
|
|
if (matrix[y][x] !== 0 && (board[y + pos.y] && board[y + pos.y][x + pos.x]) !== 0) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function merge(board, player) {
|
|
player.matrix.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value !== 0) {
|
|
board[y + player.pos.y][x + player.pos.x] = player.type;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function rotate(matrix, direction) {
|
|
const rotated = matrix.map((_, index) => matrix.map((row) => row[index]));
|
|
if (direction > 0) {
|
|
rotated.forEach((row) => row.reverse());
|
|
} else {
|
|
rotated.reverse();
|
|
}
|
|
return rotated;
|
|
}
|
|
|
|
function attemptRotate(direction = 1) {
|
|
if (state.isGameOver || !state.isStarted || !state.piece) {
|
|
return;
|
|
}
|
|
const originalX = state.piece.pos.x;
|
|
const rotated = rotate(state.piece.matrix, direction);
|
|
state.piece.matrix = rotated;
|
|
const kicks = [0, -1, 1, -2, 2];
|
|
for (const offset of kicks) {
|
|
state.piece.pos.x = originalX + offset;
|
|
if (!collides(state.board, state.piece)) {
|
|
announce('Piece rotated');
|
|
draw();
|
|
return;
|
|
}
|
|
}
|
|
state.piece.matrix = rotate(rotated, -direction);
|
|
state.piece.pos.x = originalX;
|
|
}
|
|
|
|
function movePlayer(direction) {
|
|
if (state.isGameOver || !state.isStarted || !state.piece) {
|
|
return;
|
|
}
|
|
state.piece.pos.x += direction;
|
|
if (collides(state.board, state.piece)) {
|
|
state.piece.pos.x -= direction;
|
|
return;
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function playerDrop(addSoftDropPoint = false) {
|
|
if (state.isGameOver || !state.piece) {
|
|
return false;
|
|
}
|
|
state.piece.pos.y += 1;
|
|
if (collides(state.board, state.piece)) {
|
|
state.piece.pos.y -= 1;
|
|
merge(state.board, state.piece);
|
|
clearLines();
|
|
spawnPiece();
|
|
updateStats();
|
|
state.dropCounter = 0;
|
|
return false;
|
|
}
|
|
if (addSoftDropPoint) {
|
|
state.score += 1;
|
|
}
|
|
state.dropCounter = 0;
|
|
updateStats();
|
|
draw();
|
|
return true;
|
|
}
|
|
|
|
function hardDrop() {
|
|
if (state.isGameOver || !state.piece) {
|
|
return;
|
|
}
|
|
let rowsDropped = 0;
|
|
while (playerDrop(false)) {
|
|
rowsDropped += 1;
|
|
}
|
|
if (rowsDropped > 0) {
|
|
state.score += rowsDropped * 2;
|
|
updateStats();
|
|
draw();
|
|
announce(`Hard drop +${rowsDropped * 2}`);
|
|
}
|
|
}
|
|
|
|
function clearLines() {
|
|
let linesCleared = 0;
|
|
outer: for (let y = state.board.length - 1; y >= 0; y -= 1) {
|
|
for (let x = 0; x < state.board[y].length; x += 1) {
|
|
if (state.board[y][x] === 0) {
|
|
continue outer;
|
|
}
|
|
}
|
|
const row = state.board.splice(y, 1)[0].fill(0);
|
|
state.board.unshift(row);
|
|
linesCleared += 1;
|
|
y += 1;
|
|
}
|
|
|
|
if (linesCleared > 0) {
|
|
state.lines += linesCleared;
|
|
state.score += lineScores[linesCleared] * state.level;
|
|
const newLevel = Math.floor(state.lines / 10) + 1;
|
|
if (newLevel !== state.level) {
|
|
state.level = newLevel;
|
|
state.dropInterval = Math.max(140, 900 - (state.level - 1) * 70);
|
|
announce(`Level ${state.level}`);
|
|
} else {
|
|
announce(`${linesCleared} line${linesCleared > 1 ? 's' : ''} cleared`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawCell(x, y, type, context, size = 1) {
|
|
const palette = colors[type] || { light: '#ffffff', base: '#dbeafe', dark: '#64748b', glow: 'rgba(255,255,255,0.28)' };
|
|
const inset = 0.06 * size;
|
|
const width = size - inset * 2;
|
|
const height = size - inset * 2;
|
|
const gradient = context.createLinearGradient(x, y, x + size, y + size);
|
|
gradient.addColorStop(0, palette.light);
|
|
gradient.addColorStop(0.42, palette.base);
|
|
gradient.addColorStop(1, palette.dark);
|
|
|
|
context.save();
|
|
context.shadowColor = palette.glow;
|
|
context.shadowBlur = 0.42 * size;
|
|
context.fillStyle = gradient;
|
|
context.fillRect(x + inset, y + inset, width, height);
|
|
|
|
context.shadowBlur = 0;
|
|
context.fillStyle = 'rgba(255,255,255,0.22)';
|
|
context.fillRect(x + inset + 0.07 * size, y + inset + 0.08 * size, width - 0.14 * size, 0.12 * size);
|
|
|
|
context.strokeStyle = 'rgba(255,255,255,0.28)';
|
|
context.lineWidth = 0.05 * size;
|
|
context.strokeRect(x + inset, y + inset, width, height);
|
|
|
|
context.strokeStyle = 'rgba(4, 10, 18, 0.42)';
|
|
context.lineWidth = 0.04 * size;
|
|
context.strokeRect(x + inset + 0.08 * size, y + inset + 0.08 * size, width - 0.16 * size, height - 0.16 * size);
|
|
context.restore();
|
|
}
|
|
|
|
function drawMatrix(matrix, offset, type, context) {
|
|
matrix.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value !== 0) {
|
|
drawCell(x + offset.x, y + offset.y, type, context);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function paintArenaBackground(context, width, height) {
|
|
context.save();
|
|
const gradient = context.createLinearGradient(0, 0, width, height);
|
|
gradient.addColorStop(0, '#08111f');
|
|
gradient.addColorStop(0.55, '#0b1730');
|
|
gradient.addColorStop(1, '#040913');
|
|
context.fillStyle = gradient;
|
|
context.fillRect(0, 0, width, height);
|
|
|
|
const glow = context.createRadialGradient(width / 2, height * 0.18, 0, width / 2, height * 0.18, width * 0.95);
|
|
glow.addColorStop(0, 'rgba(124, 246, 255, 0.09)');
|
|
glow.addColorStop(1, 'rgba(124, 246, 255, 0)');
|
|
context.fillStyle = glow;
|
|
context.fillRect(0, 0, width, height);
|
|
context.restore();
|
|
}
|
|
|
|
function drawGrid(context, width, height) {
|
|
context.save();
|
|
context.strokeStyle = 'rgba(159, 214, 255, 0.09)';
|
|
context.lineWidth = 0.04;
|
|
for (let x = 0; x <= width; x += 1) {
|
|
context.beginPath();
|
|
context.moveTo(x, 0);
|
|
context.lineTo(x, height);
|
|
context.stroke();
|
|
}
|
|
for (let y = 0; y <= height; y += 1) {
|
|
context.beginPath();
|
|
context.moveTo(0, y);
|
|
context.lineTo(width, y);
|
|
context.stroke();
|
|
}
|
|
context.restore();
|
|
}
|
|
|
|
function drawGhostPiece() {
|
|
if (!state.piece || state.isGameOver) {
|
|
return;
|
|
}
|
|
const ghost = {
|
|
matrix: state.piece.matrix,
|
|
pos: { x: state.piece.pos.x, y: state.piece.pos.y },
|
|
type: state.piece.type
|
|
};
|
|
while (!collides(state.board, ghost)) {
|
|
ghost.pos.y += 1;
|
|
}
|
|
ghost.pos.y -= 1;
|
|
boardCtx.save();
|
|
boardCtx.globalAlpha = 0.22;
|
|
drawMatrix(ghost.matrix, ghost.pos, ghost.type, boardCtx);
|
|
boardCtx.restore();
|
|
}
|
|
|
|
function drawBoard() {
|
|
paintArenaBackground(boardCtx, cols, rows);
|
|
drawGrid(boardCtx, cols, rows);
|
|
state.board.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value !== 0) {
|
|
drawCell(x, y, value, boardCtx);
|
|
}
|
|
});
|
|
});
|
|
drawGhostPiece();
|
|
if (state.piece) {
|
|
drawMatrix(state.piece.matrix, state.piece.pos, state.piece.type, boardCtx);
|
|
}
|
|
}
|
|
|
|
function drawNextPiece() {
|
|
nextCtx.clearRect(0, 0, 4, 4);
|
|
paintArenaBackground(nextCtx, 4, 4);
|
|
drawGrid(nextCtx, 4, 4);
|
|
const nextType = state.nextQueue[0];
|
|
if (!nextType) {
|
|
return;
|
|
}
|
|
const preview = pieces[nextType];
|
|
const width = preview[0].length;
|
|
const height = preview.length;
|
|
const offset = { x: (4 - width) / 2, y: (4 - height) / 2 };
|
|
drawMatrix(preview, offset, nextType, nextCtx);
|
|
}
|
|
|
|
function draw() {
|
|
drawBoard();
|
|
drawNextPiece();
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
function updateStats() {
|
|
scoreValue.textContent = state.score.toLocaleString();
|
|
levelValue.textContent = state.level.toLocaleString();
|
|
linesValue.textContent = state.lines.toLocaleString();
|
|
if (!state.isGameOver && state.isStarted && (statusValue.textContent === 'Ready' || statusValue.textContent === 'Press Start')) {
|
|
statusValue.textContent = 'Running';
|
|
}
|
|
}
|
|
|
|
function openSaveForm() {
|
|
const durationSeconds = Math.max(1, Math.round((Date.now() - state.startedAt) / 1000));
|
|
savePlaceholder?.classList.add('d-none');
|
|
saveForm?.classList.remove('d-none');
|
|
if (runSummary) {
|
|
runSummary.textContent = `Run complete · ${state.score.toLocaleString()} points, ${state.lines} lines, level ${state.level}, ${durationSeconds}s.`;
|
|
}
|
|
if (finalScore) finalScore.value = state.score.toLocaleString();
|
|
if (finalLines) finalLines.value = String(state.lines);
|
|
if (finalLevel) finalLevel.value = String(state.level);
|
|
if (finalDuration) finalDuration.value = formatDuration(durationSeconds);
|
|
if (scoreInput) scoreInput.value = String(state.score);
|
|
if (linesInput) linesInput.value = String(state.lines);
|
|
if (levelInput) levelInput.value = String(state.level);
|
|
if (durationInput) durationInput.value = String(durationSeconds);
|
|
if (playerNameInput) {
|
|
playerNameInput.value = sanitizePlayerName(playerNameInput.value || loadRememberedPlayerName());
|
|
playerNameInput.focus({ preventScroll: false });
|
|
}
|
|
}
|
|
|
|
function hideSaveForm() {
|
|
savePlaceholder?.classList.remove('d-none');
|
|
saveForm?.classList.add('d-none');
|
|
}
|
|
|
|
function endGame() {
|
|
state.isGameOver = true;
|
|
state.isStarted = false;
|
|
statusValue.textContent = 'Game over';
|
|
draw();
|
|
openSaveForm();
|
|
showToast('Game over', 'Save your run or restart immediately.');
|
|
}
|
|
|
|
function updateStartUi() {
|
|
if (startOverlay) {
|
|
startOverlay.classList.toggle('is-hidden', state.isStarted || state.isGameOver);
|
|
}
|
|
startButtons.forEach((button) => {
|
|
button.disabled = state.isStarted || state.isGameOver;
|
|
});
|
|
}
|
|
|
|
function startGame() {
|
|
if (state.isStarted || state.isGameOver) {
|
|
return;
|
|
}
|
|
state.isStarted = true;
|
|
state.startedAt = Date.now();
|
|
state.lastTime = performance.now();
|
|
state.dropCounter = 0;
|
|
statusValue.textContent = 'Running';
|
|
updateStartUi();
|
|
boardCanvas.focus?.();
|
|
showToast('Game started', 'Good luck.');
|
|
}
|
|
|
|
function resetGame(showNotice = true) {
|
|
state.board = createMatrix(cols, rows);
|
|
state.nextQueue = [];
|
|
state.score = 0;
|
|
state.lines = 0;
|
|
state.level = 1;
|
|
state.dropInterval = 900;
|
|
state.dropCounter = 0;
|
|
state.lastTime = 0;
|
|
state.isGameOver = false;
|
|
state.isStarted = false;
|
|
state.startedAt = null;
|
|
statusValue.textContent = 'Press Start';
|
|
hideSaveForm();
|
|
ensureQueue();
|
|
spawnPiece();
|
|
updateStats();
|
|
draw();
|
|
updateStartUi();
|
|
if (showNotice) {
|
|
showToast('Board reset', 'Press Start when you are ready.');
|
|
}
|
|
}
|
|
|
|
function announce(message) {
|
|
if (!statusValue || state.isGameOver || !state.isStarted) {
|
|
return;
|
|
}
|
|
statusValue.textContent = message;
|
|
clearTimeout(announce.timer);
|
|
announce.timer = setTimeout(() => {
|
|
if (!state.isGameOver && state.isStarted) {
|
|
statusValue.textContent = 'Running';
|
|
}
|
|
}, 900);
|
|
}
|
|
|
|
function showToast(title, message) {
|
|
const container = document.getElementById('toast-container');
|
|
if (!container || typeof bootstrap === 'undefined') {
|
|
return;
|
|
}
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'toast align-items-center text-bg-dark border-0';
|
|
wrapper.role = 'status';
|
|
wrapper.ariaLive = 'polite';
|
|
wrapper.ariaAtomic = 'true';
|
|
wrapper.innerHTML = `
|
|
<div class="toast-header">
|
|
<strong class="me-auto">${title}</strong>
|
|
<small class="text-secondary">now</small>
|
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>
|
|
<div class="toast-body">${message}</div>
|
|
`;
|
|
container.appendChild(wrapper);
|
|
const toast = new bootstrap.Toast(wrapper, { delay: 2400 });
|
|
wrapper.addEventListener('hidden.bs.toast', () => wrapper.remove());
|
|
toast.show();
|
|
}
|
|
|
|
function update(time = 0) {
|
|
const deltaTime = time - state.lastTime;
|
|
state.lastTime = time;
|
|
if (state.isStarted) {
|
|
state.dropCounter += deltaTime;
|
|
}
|
|
if (!state.isGameOver && state.isStarted && state.dropCounter > state.dropInterval) {
|
|
playerDrop(false);
|
|
}
|
|
draw();
|
|
requestAnimationFrame(update);
|
|
}
|
|
|
|
function handleAction(action) {
|
|
if (!state.isStarted || state.isGameOver) {
|
|
return;
|
|
}
|
|
switch (action) {
|
|
case 'left':
|
|
movePlayer(-1);
|
|
break;
|
|
case 'right':
|
|
movePlayer(1);
|
|
break;
|
|
case 'down':
|
|
playerDrop(true);
|
|
break;
|
|
case 'rotate':
|
|
attemptRotate(1);
|
|
break;
|
|
case 'drop':
|
|
hardDrop();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if ([37, 38, 39, 40, 32].includes(event.keyCode)) {
|
|
event.preventDefault();
|
|
}
|
|
if (event.key === 'ArrowLeft') handleAction('left');
|
|
if (event.key === 'ArrowRight') handleAction('right');
|
|
if (event.key === 'ArrowDown') handleAction('down');
|
|
if (event.key === 'ArrowUp') handleAction('rotate');
|
|
if (event.code === 'Space') handleAction('drop');
|
|
}, { passive: false });
|
|
|
|
controlButtons.forEach((button) => {
|
|
const runAction = (event) => {
|
|
event.preventDefault();
|
|
handleAction(button.dataset.action);
|
|
};
|
|
button.addEventListener('click', runAction);
|
|
button.addEventListener('touchstart', runAction, { passive: false });
|
|
});
|
|
|
|
restartButtons.forEach((button) => {
|
|
button.addEventListener('click', () => resetGame());
|
|
});
|
|
|
|
startButtons.forEach((button) => {
|
|
button.addEventListener('click', () => startGame());
|
|
});
|
|
|
|
focusButton?.addEventListener('click', () => {
|
|
boardCanvas.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
boardCanvas.focus?.();
|
|
});
|
|
|
|
[playerNameInput, roomNicknameInput].filter(Boolean).forEach((input) => {
|
|
input.addEventListener('input', () => {
|
|
syncNicknameFromInput(input);
|
|
});
|
|
});
|
|
|
|
roomCodeInput?.addEventListener('input', () => {
|
|
const clean = sanitizeRoomCode(roomCodeInput.value);
|
|
if (roomCodeInput.value !== clean) {
|
|
roomCodeInput.value = clean;
|
|
}
|
|
});
|
|
|
|
createRoomButton?.addEventListener('click', async () => {
|
|
const playerName = getActiveNickname();
|
|
if (!playerName) {
|
|
return;
|
|
}
|
|
|
|
createRoomButton.disabled = true;
|
|
joinRoomButton && (joinRoomButton.disabled = true);
|
|
try {
|
|
const payload = await sendRoomRequest('create', { player_name: playerName });
|
|
renderRoomState(payload.room);
|
|
startRoomPolling();
|
|
showToast('Room created', `Share code ${payload.room.room_code} with your friend.`);
|
|
} catch (error) {
|
|
showToast('Room error', error.message || 'Unable to create a room right now.');
|
|
} finally {
|
|
createRoomButton.disabled = false;
|
|
joinRoomButton && (joinRoomButton.disabled = false);
|
|
}
|
|
});
|
|
|
|
joinRoomButton?.addEventListener('click', async () => {
|
|
const playerName = getActiveNickname();
|
|
const roomCode = sanitizeRoomCode(roomCodeInput?.value || '');
|
|
if (!playerName) {
|
|
return;
|
|
}
|
|
if (!roomCode || roomCode.length !== 6) {
|
|
showToast('Room code needed', 'Enter the 6-character room code from your friend.');
|
|
roomCodeInput?.focus({ preventScroll: false });
|
|
return;
|
|
}
|
|
|
|
createRoomButton && (createRoomButton.disabled = true);
|
|
joinRoomButton.disabled = true;
|
|
try {
|
|
const payload = await sendRoomRequest('join', { player_name: playerName, room_code: roomCode });
|
|
renderRoomState(payload.room);
|
|
startRoomPolling();
|
|
showToast('Room joined', `You joined room ${payload.room.room_code}.`);
|
|
} catch (error) {
|
|
showToast('Join failed', error.message || 'Unable to join the room right now.');
|
|
} finally {
|
|
createRoomButton && (createRoomButton.disabled = false);
|
|
joinRoomButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
refreshRoomStatusButton?.addEventListener('click', () => {
|
|
if (!currentRoomCode) {
|
|
showToast('No room yet', 'Create a room or join one first.');
|
|
return;
|
|
}
|
|
syncRoomStatus(currentRoomCode);
|
|
});
|
|
|
|
copyRoomCodeButton?.addEventListener('click', async () => {
|
|
if (!currentRoomCode) {
|
|
showToast('No room yet', 'Create a room first to copy its code.');
|
|
return;
|
|
}
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(currentRoomCode);
|
|
showToast('Code copied', `${currentRoomCode} copied to clipboard.`);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
// Fallback below.
|
|
}
|
|
if (roomCodeInput) {
|
|
roomCodeInput.value = currentRoomCode;
|
|
roomCodeInput.focus({ preventScroll: false });
|
|
roomCodeInput.select();
|
|
}
|
|
showToast('Copy ready', `Room code ${currentRoomCode} is selected.`);
|
|
});
|
|
|
|
const rememberedName = loadRememberedPlayerName();
|
|
if (rememberedName) {
|
|
applyNicknameToInputs(rememberedName);
|
|
}
|
|
|
|
const rememberedRoomCode = loadRememberedRoomCode();
|
|
if (roomCodeInput && rememberedRoomCode && !roomCodeInput.value.trim()) {
|
|
roomCodeInput.value = rememberedRoomCode;
|
|
}
|
|
|
|
saveForm?.addEventListener('submit', () => {
|
|
if (playerNameInput) {
|
|
playerNameInput.value = rememberPlayerName(playerNameInput.value);
|
|
}
|
|
});
|
|
|
|
if (pageConfig.saved) {
|
|
showToast('Score saved', pageConfig.scoreId ? `Your run was added to the leaderboard. #${pageConfig.scoreId}` : 'Your run was added to the leaderboard.');
|
|
}
|
|
if (pageConfig.saveError) {
|
|
showToast('Save failed', pageConfig.saveError);
|
|
}
|
|
|
|
resetGame(false);
|
|
syncLeaderboard();
|
|
window.setInterval(syncLeaderboard, 15000);
|
|
if (rememberedRoomCode) {
|
|
currentRoomCode = rememberedRoomCode;
|
|
syncRoomStatus(rememberedRoomCode, { silent: true });
|
|
}
|
|
requestAnimationFrame(update);
|
|
});
|