39315-vm/assets/js/main.js
2026-03-25 15:17:29 +00:00

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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[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);
});