| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Throw Tomatoes at Lay</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Nunito:wght@400;700;900&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"> |
| <style> |
| :root { |
| --bg: #0d1a0d; |
| --fg: #e8f0e8; |
| --muted: #6b7a6b; |
| --accent: #ff3d00; |
| --card: rgba(18, 38, 18, 0.88); |
| --border: rgba(255, 61, 0, 0.25); |
| } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: 'Nunito', sans-serif; |
| background: var(--bg); |
| color: var(--fg); |
| overflow: hidden; |
| height: 100vh; |
| cursor: crosshair; |
| } |
| .bangers { font-family: 'Bangers', cursive; letter-spacing: 0.06em; } |
| .screen { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 50; transition: opacity 0.5s, visibility 0.5s; } |
| .screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; } |
| #loginScreen { |
| background: |
| radial-gradient(ellipse at 20% 80%, rgba(255,61,0,0.12) 0%, transparent 50%), |
| radial-gradient(ellipse at 80% 20%, rgba(45,106,30,0.15) 0%, transparent 50%), |
| linear-gradient(160deg, #0a140a 0%, #0d1a0d 40%, #12100a 100%); |
| overflow-y: auto; |
| } |
| #gameCanvas { position: fixed; inset: 0; z-index: 0; } |
| .glass { background: var(--card); border: 1px solid var(--border); backdrop-filter: blur(16px); border-radius: 16px; } |
| .input-field { |
| width: 100%; padding: 12px 16px; border-radius: 10px; |
| border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04); |
| color: var(--fg); font-size: 16px; font-family: 'Nunito', sans-serif; |
| outline: none; transition: border-color 0.3s, box-shadow 0.3s; |
| } |
| .input-field:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(255,61,0,0.15); } |
| .input-field::placeholder { color: var(--muted); } |
| .btn-primary { |
| padding: 12px 28px; border-radius: 10px; border: none; |
| background: linear-gradient(135deg, #ff3d00, #d32f2f); color: #fff; |
| font-weight: 900; font-size: 16px; font-family: 'Nunito', sans-serif; |
| cursor: pointer; transition: transform 0.15s, box-shadow 0.3s; |
| box-shadow: 0 4px 20px rgba(255,61,0,0.3); |
| } |
| .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 28px rgba(255,61,0,0.45); } |
| .btn-primary:active { transform: translateY(0); } |
| .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } |
| .btn-secondary { |
| padding: 12px 28px; border-radius: 10px; border: 1px solid var(--border); |
| background: transparent; color: var(--fg); |
| font-weight: 900; font-size: 16px; font-family: 'Nunito', sans-serif; |
| cursor: pointer; transition: background 0.2s, border-color 0.2s; |
| } |
| .btn-secondary:hover { background: rgba(255,61,0,0.1); border-color: var(--accent); } |
| .btn-ghost { |
| padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); |
| background: transparent; color: var(--fg); font-weight: 700; font-size: 13px; |
| font-family: 'Nunito', sans-serif; cursor: pointer; transition: background 0.2s, border-color 0.2s; |
| } |
| .btn-ghost:hover { background: rgba(255,61,0,0.1); border-color: var(--accent); } |
| #hud { |
| position: fixed; top: 0; left: 0; right: 0; z-index: 30; |
| padding: 12px 20px; display: flex; align-items: center; gap: 16px; |
| background: linear-gradient(180deg, rgba(0,0,0,0.6) 0%, transparent 100%); |
| pointer-events: none; |
| } |
| #hud > * { pointer-events: auto; } |
| #leaderboard { |
| position: fixed; top: 0; right: 0; bottom: 0; width: 340px; max-width: 90vw; z-index: 40; |
| transform: translateX(100%); transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); |
| background: rgba(10,20,10,0.95); backdrop-filter: blur(20px); |
| border-left: 1px solid var(--border); overflow-y: auto; padding: 24px; |
| } |
| #leaderboard.open { transform: translateX(0); } |
| .lb-entry { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 10px; margin-bottom: 6px; transition: background 0.2s; } |
| .lb-entry:hover { background: rgba(255,255,255,0.04); } |
| .lb-rank { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 13px; flex-shrink: 0; } |
| .lb-rank.gold { background: linear-gradient(135deg, #ffd700, #f0a500); color: #1a1a00; } |
| .lb-rank.silver { background: linear-gradient(135deg, #c0c0c0, #8a8a8a); color: #1a1a1a; } |
| .lb-rank.bronze { background: linear-gradient(135deg, #cd7f32, #a0522d); color: #fff; } |
| .lb-rank.normal { background: rgba(255,255,255,0.06); color: var(--muted); } |
| #toastContainer { position: fixed; bottom: 24px; right: 24px; z-index: 100; display: flex; flex-direction: column; gap: 8px; } |
| .toast { padding: 12px 20px; border-radius: 10px; font-size: 14px; font-weight: 700; animation: toastIn 0.35s ease-out, toastOut 0.35s 2.5s ease-in forwards; box-shadow: 0 8px 30px rgba(0,0,0,0.4); } |
| .toast.success { background: linear-gradient(135deg, #1b5e20, #2e7d32); color: #a5d6a7; border: 1px solid rgba(76,175,80,0.3); } |
| .toast.error { background: linear-gradient(135deg, #b71c1c, #c62828); color: #ef9a9a; border: 1px solid rgba(244,67,54,0.3); } |
| .toast.info { background: linear-gradient(135deg, #e65100, #f57c00); color: #ffe0b2; border: 1px solid rgba(255,152,0,0.3); } |
| @keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } } |
| @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } } |
| .count-pop { animation: countPop 0.3s ease-out; } |
| @keyframes countPop { 0% { transform: scale(1.5); } 100% { transform: scale(1); } } |
| .float-tomato { position: absolute; font-size: 40px; opacity: 0.1; animation: floatUp linear infinite; pointer-events: none; } |
| @keyframes floatUp { 0% { transform: translateY(100vh) rotate(0deg); opacity: 0; } 10% { opacity: 0.1; } 90% { opacity: 0.1; } 100% { transform: translateY(-100px) rotate(360deg); opacity: 0; } } |
| #lbOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 35; opacity: 0; visibility: hidden; transition: opacity 0.3s, visibility 0.3s; } |
| #lbOverlay.open { opacity: 1; visibility: visible; } |
| .cloud-status { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; padding: 8px 14px; border-radius: 8px; margin-top: 20px; } |
| .cloud-status.off { background: rgba(255,255,255,0.03); color: var(--muted); } |
| .cloud-status.on { background: rgba(76,175,80,0.1); color: #4caf50; } |
| .cloud-status.loading { background: rgba(255,215,0,0.1); color: #ffd700; } |
| .cloud-dot { width: 8px; height: 8px; border-radius: 50%; } |
| .cloud-dot.off { background: #666; } |
| .cloud-dot.on { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,0.6); } |
| .cloud-dot.loading { background: #ffd700; animation: pulse 1s infinite; } |
| @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } |
| .form-switch { display: flex; gap: 0; margin-bottom: 24px; border-radius: 10px; overflow: hidden; border: 1px solid var(--border); } |
| .form-switch button { flex: 1; padding: 10px; border: none; background: transparent; color: var(--muted); font-weight: 900; font-size: 14px; cursor: pointer; transition: all 0.2s; font-family: 'Nunito', sans-serif; } |
| .form-switch button.active { background: var(--accent); color: #fff; } |
| .high-score-badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; background: rgba(255,215,0,0.1); border: 1px solid rgba(255,215,0,0.2); color: #ffd700; font-size: 13px; font-weight: 700; } |
| ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } |
| @media (max-width: 640px) { #leaderboard { width: 100%; } .login-card { margin: 16px; padding: 24px !important; } } |
| @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } |
| </style> |
| </head> |
| <body> |
| <div id="loginScreen" class="screen"> |
| <div id="floatingTomatoes" aria-hidden="true"></div> |
| <div class="login-card glass" style="padding: 40px; max-width: 420px; width: 100%; position: relative; z-index: 2;"> |
| <div style="text-align: center; margin-bottom: 32px;"> |
| <div class="bangers" style="font-size: 52px; line-height: 1; color: var(--accent); text-shadow: 0 4px 20px rgba(255,61,0,0.3);">THROW TOMATOES</div> |
| <div class="bangers" style="font-size: 32px; color: var(--fg); margin-top: 4px;">at Lay</div> |
| <div style="margin-top: 12px; color: var(--muted); font-size: 14px;">Create an account to save your scores</div> |
| </div> |
| |
| <div class="form-switch"> |
| <button id="loginTabBtn" class="active" onclick="switchForm('login')">LOGIN</button> |
| <button id="registerTabBtn" onclick="switchForm('register')">REGISTER</button> |
| </div> |
| |
| <form id="loginForm" autocomplete="off"> |
| <div style="margin-bottom: 16px;"> |
| <label style="font-size: 13px; font-weight: 700; color: var(--muted); display: block; margin-bottom: 6px;"><i class="fas fa-user" style="margin-right: 4px;"></i> USERNAME</label> |
| <input type="text" id="usernameInput" class="input-field" placeholder="Enter your name..." maxlength="20" required aria-label="Username"> |
| </div> |
| <div style="margin-bottom: 24px;"> |
| <label style="font-size: 13px; font-weight: 700; color: var(--muted); display: block; margin-bottom: 6px;"><i class="fas fa-key" style="margin-right: 4px;"></i> PASSWORD</label> |
| <input type="password" id="passwordInput" class="input-field" placeholder="Enter your password..." maxlength="30" required aria-label="Password"> |
| </div> |
| <div id="loginHighScore" style="display: none; margin-bottom: 16px; text-align: center;"></div> |
| <button type="submit" id="loginBtn" class="btn-primary" style="width: 100%; font-size: 18px; padding: 14px;"><i class="fas fa-right-to-bracket" style="margin-right: 8px;"></i> LOGIN</button> |
| </form> |
| |
| <form id="registerForm" style="display: none;" autocomplete="off"> |
| <div style="margin-bottom: 16px;"> |
| <label style="font-size: 13px; font-weight: 700; color: var(--muted); display: block; margin-bottom: 6px;"><i class="fas fa-user" style="margin-right: 4px;"></i> USERNAME</label> |
| <input type="text" id="regUsernameInput" class="input-field" placeholder="Choose a name (2-20 chars)..." maxlength="20" required aria-label="Username"> |
| </div> |
| <div style="margin-bottom: 16px;"> |
| <label style="font-size: 13px; font-weight: 700; color: var(--muted); display: block; margin-bottom: 6px;"><i class="fas fa-key" style="margin-right: 4px;"></i> PASSWORD</label> |
| <input type="password" id="regPasswordInput" class="input-field" placeholder="Choose a password (min 4 chars)..." maxlength="30" required aria-label="Password"> |
| </div> |
| <div style="margin-bottom: 24px;"> |
| <label style="font-size: 13px; font-weight: 700; color: var(--muted); display: block; margin-bottom: 6px;"><i class="fas fa-key" style="margin-right: 4px;"></i> CONFIRM PASSWORD</label> |
| <input type="password" id="regConfirmInput" class="input-field" placeholder="Confirm your password..." maxlength="30" required aria-label="Confirm Password"> |
| </div> |
| <button type="submit" id="registerBtn" class="btn-primary" style="width: 100%; font-size: 18px; padding: 14px;"><i class="fas fa-user-plus" style="margin-right: 8px;"></i> CREATE ACCOUNT</button> |
| </form> |
| |
| <div id="cloudStatus" class="cloud-status loading" style="justify-content: center;"> |
| <span id="cloudDot" class="cloud-dot loading"></span> |
| <span id="cloudText">Connecting to cloud...</span> |
| </div> |
| </div> |
| </div> |
|
|
| <canvas id="gameCanvas"></canvas> |
|
|
| <div id="hud" style="display: none;"> |
| <div class="glass" style="padding: 8px 18px; display: flex; align-items: center; gap: 10px; border-radius: 12px;"><i class="fas fa-user" style="color: var(--accent); font-size: 13px;"></i><span id="hudUsername" style="font-weight: 900; font-size: 14px;"></span></div> |
| <div class="glass" style="padding: 8px 18px; display: flex; align-items: center; gap: 8px; border-radius: 12px;"><span style="font-size: 20px;">๐
</span><span id="hudCount" class="bangers" style="font-size: 22px; color: var(--accent);">0</span></div> |
| <div id="hudHighScore" class="glass" style="padding: 8px 14px; display: none; align-items: center; gap: 6px; border-radius: 12px;"><span style="font-size: 14px;">๐</span><span id="hudHighScoreVal" class="bangers" style="font-size: 16px; color: #ffd700;">0</span></div> |
| <div style="flex: 1;"></div> |
| <button id="lbBtn" class="btn-ghost" aria-label="Open leaderboard"><i class="fas fa-trophy" style="margin-right: 6px; color: #ffd700;"></i> Leaderboard</button> |
| <button id="logoutBtn" class="btn-ghost" aria-label="Logout"><i class="fas fa-right-from-bracket"></i></button> |
| </div> |
|
|
| <div id="instructions" style="display: none; position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); z-index: 30; pointer-events: none;"> |
| <div class="glass" style="padding: 12px 24px; text-align: center;"><span style="font-weight: 700; color: var(--muted); font-size: 14px;"><i class="fas fa-hand-pointer" style="color: var(--accent); margin-right: 6px;"></i>Click anywhere to throw tomatoes at Lay</span></div> |
| </div> |
|
|
| <div id="lbOverlay"></div> |
| <div id="leaderboard" role="dialog" aria-label="Leaderboard"> |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> |
| <div class="bangers" style="font-size: 24px; color: #ffd700;"><i class="fas fa-trophy" style="margin-right: 8px;"></i> TOP THROWERS</div> |
| <button id="closeLbBtn" style="background: none; border: none; color: var(--muted); cursor: pointer; font-size: 20px; padding: 4px;" aria-label="Close leaderboard"><i class="fas fa-xmark"></i></button> |
| </div> |
| <div id="lbContent"></div> |
| <div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.06);"><button id="refreshLbBtn" class="btn-ghost" style="width: 100%;"><i class="fas fa-arrows-rotate" style="margin-right: 6px;"></i> Refresh</button></div> |
| </div> |
|
|
| <div id="toastContainer" aria-live="polite"></div> |
|
|
| <script> |
| const $ = id => document.getElementById(id); |
| |
| const API_URL = 'https://tomato.m-calculator.workers.dev'; |
| let sessionToken = null; |
| let currentUser = null; |
| let currentHighScore = 0; |
| let cloudConnected = false; |
| |
| function showToast(msg, type = 'info') { |
| const t = document.createElement('div'); |
| t.className = 'toast ' + type; |
| t.textContent = msg; |
| $('toastContainer').appendChild(t); |
| setTimeout(() => t.remove(), 3200); |
| } |
| |
| async function apiRequest(endpoint, options = {}) { |
| try { |
| const url = API_URL.replace(/\/+$/, '') + endpoint; |
| const headers = { 'Content-Type': 'application/json' }; |
| if (sessionToken) headers['Authorization'] = 'Bearer ' + sessionToken; |
| const res = await fetch(url, { ...options, headers }); |
| const data = await res.json(); |
| if (!res.ok) { |
| showToast(data.error || 'Request failed', 'error'); |
| return null; |
| } |
| return data; |
| } catch (e) { |
| showToast('Network error', 'error'); |
| return null; |
| } |
| } |
| |
| function switchForm(mode) { |
| if (mode === 'login') { |
| $('loginForm').style.display = 'block'; |
| $('registerForm').style.display = 'none'; |
| $('loginTabBtn').classList.add('active'); |
| $('registerTabBtn').classList.remove('active'); |
| $('loginHighScore').style.display = 'none'; |
| } else { |
| $('loginForm').style.display = 'none'; |
| $('registerForm').style.display = 'block'; |
| $('loginTabBtn').classList.remove('active'); |
| $('registerTabBtn').classList.add('active'); |
| } |
| } |
| |
| async function checkCloud() { |
| try { |
| const res = await fetch(API_URL.replace(/\/+$/, '') + '/health'); |
| const data = await res.json(); |
| if (data.status === 'ok' && data.kv_connected === true) { |
| $('cloudStatus').className = 'cloud-status on'; |
| $('cloudDot').className = 'cloud-dot on'; |
| $('cloudText').textContent = 'Cloud connected - Accounts saved'; |
| cloudConnected = true; |
| return true; |
| } else { |
| $('cloudStatus').className = 'cloud-status off'; |
| $('cloudDot').className = 'cloud-dot off'; |
| $('cloudText').textContent = 'Cloud error: KV database missing'; |
| return false; |
| } |
| } catch (e) { |
| $('cloudStatus').className = 'cloud-status off'; |
| $('cloudDot').className = 'cloud-dot off'; |
| $('cloudText').textContent = 'Offline mode - Create account to save'; |
| return false; |
| } |
| } |
| |
| |
| $('usernameInput').addEventListener('blur', async () => { |
| const username = $('usernameInput').value.trim(); |
| if (username.length < 2) return; |
| |
| const res = await apiRequest('/login', { |
| method: 'POST', |
| body: JSON.stringify({ username, password: 'check' }) |
| }); |
| |
| if (res === null) return; |
| |
| |
| |
| $('loginHighScore').style.display = 'none'; |
| }); |
| |
| $('registerForm').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const username = $('regUsernameInput').value.trim(); |
| const password = $('regPasswordInput').value; |
| const confirm = $('regConfirmInput').value; |
| |
| if (!username || username.length < 2) { |
| showToast('Username needs at least 2 characters', 'error'); |
| return; |
| } |
| if (!password || password.length < 4) { |
| showToast('Password needs at least 4 characters', 'error'); |
| return; |
| } |
| if (password !== confirm) { |
| showToast('Passwords do not match', 'error'); |
| return; |
| } |
| |
| const btn = $('registerBtn'); |
| btn.disabled = true; |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right:8px;"></i> CREATING...'; |
| |
| const res = await apiRequest('/register', { method: 'POST', body: JSON.stringify({ username, password }) }); |
| |
| if (res && res.token) { |
| sessionToken = res.token; |
| currentUser = res.username; |
| currentHighScore = 0; |
| showToast('Account created! Welcome, ' + currentUser + '!', 'success'); |
| startGame(); |
| } |
| |
| btn.disabled = false; |
| btn.innerHTML = '<i class="fas fa-user-plus" style="margin-right:8px;"></i> CREATE ACCOUNT'; |
| }); |
| |
| $('loginForm').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const username = $('usernameInput').value.trim(); |
| const password = $('passwordInput').value; |
| |
| if (!username || username.length < 2) { |
| showToast('Username needs at least 2 characters', 'error'); |
| return; |
| } |
| if (!password) { |
| showToast('Enter your password', 'error'); |
| return; |
| } |
| |
| const btn = $('loginBtn'); |
| btn.disabled = true; |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right:8px;"></i> LOGGING IN...'; |
| |
| const res = await apiRequest('/login', { method: 'POST', body: JSON.stringify({ username, password }) }); |
| |
| if (res && res.token) { |
| sessionToken = res.token; |
| currentUser = res.username; |
| currentHighScore = res.highScore || 0; |
| showToast('Welcome back, ' + currentUser + '!', 'success'); |
| startGame(); |
| } |
| |
| btn.disabled = false; |
| btn.innerHTML = '<i class="fas fa-right-to-bracket" style="margin-right:8px;"></i> LOGIN'; |
| }); |
| |
| function logout() { |
| if (gameRunning && currentUser) persistScore(); |
| currentUser = null; |
| sessionToken = null; |
| currentHighScore = 0; |
| gameRunning = false; |
| $('hud').style.display = 'none'; |
| $('instructions').style.display = 'none'; |
| $('loginScreen').classList.remove('hidden'); |
| $('usernameInput').value = ''; |
| $('passwordInput').value = ''; |
| $('regUsernameInput').value = ''; |
| $('regPasswordInput').value = ''; |
| $('regConfirmInput').value = ''; |
| $('loginHighScore').style.display = 'none'; |
| switchForm('login'); |
| setTimeout(() => $('usernameInput').focus(), 100); |
| } |
| |
| function toggleLeaderboard() { |
| const lb = $('leaderboard'), ov = $('lbOverlay'); |
| if (lb.classList.contains('open')) { lb.classList.remove('open'); ov.classList.remove('open'); } |
| else { lb.classList.add('open'); ov.classList.add('open'); refreshLeaderboard(); } |
| } |
| |
| async function refreshLeaderboard() { |
| const c = $('lbContent'); |
| c.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted);"><i class="fas fa-spinner fa-spin"></i></div>'; |
| |
| let entries = null; |
| if (cloudConnected) { |
| entries = await apiRequest('/leaderboard'); |
| } |
| |
| if (!entries) { |
| c.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted);font-size:14px;">Unable to load leaderboard</div>'; |
| return; |
| } |
| |
| const list = entries.entries || []; |
| if (list.length === 0) { |
| c.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted);font-size:14px;">No scores yet. Start throwing!</div>'; |
| return; |
| } |
| |
| c.innerHTML = list.map((e, i) => { |
| const rc = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'normal'; |
| const me = e.username === currentUser; |
| return `<div class="lb-entry" style="${me ? 'background:rgba(255,61,0,0.08);border:1px solid rgba(255,61,0,0.15);' : ''}"><div class="lb-rank ${rc}">${i+1}</div><div style="flex:1;min-width:0;"><div style="font-weight:900;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${e.username}${me ? ' <span style="color:var(--accent);font-size:11px;">(YOU)</span>' : ''}</div></div><div style="display:flex;align-items:center;gap:6px;"><span style="font-size:14px;">๐
</span><span class="bangers" style="font-size:18px;color:${i<3?'#ffd700':'var(--fg)'};">${e.score}</span></div></div>`; |
| }).join(''); |
| } |
| |
| |
| const canvas = $('gameCanvas'); |
| const ctx = canvas.getContext('2d'); |
| let W, H, gameRunning = false, tomatoCount = 0, lastSaveTime = 0; |
| const SAVE_INTERVAL = 3000; |
| let tomatoes = [], splats = [], particles = [], floatingTexts = [], bgParticles = []; |
| let lay = { x: 0, y: 0, width: 120, height: 220, hitScale: 1, shakeX: 0, shakeY: 0, expression: 'normal', expressionTimer: 0 }; |
| let audioCtx = null; |
| function getAudio() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); return audioCtx; } |
| |
| function playSplatSound() { try { const c=getAudio(),b=c.createBuffer(1,c.sampleRate*0.12,c.sampleRate),d=b.getChannelData(0); for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*Math.exp(-i/c.sampleRate*25)*0.4; const s=c.createBufferSource();s.buffer=b;const f=c.createBiquadFilter();f.type='lowpass';f.frequency.value=800;s.connect(f);f.connect(c.destination);s.start();}catch(e){} } |
| function playThrowSound() { try { const c=getAudio(),o=c.createOscillator(),g=c.createGain();o.type='sine';o.frequency.setValueAtTime(300,c.currentTime);o.frequency.exponentialRampToValueAtTime(150,c.currentTime+0.15);g.gain.setValueAtTime(0.08,c.currentTime);g.gain.exponentialRampToValueAtTime(0.001,c.currentTime+0.15);o.connect(g);g.connect(c.destination);o.start();o.stop(c.currentTime+0.15);}catch(e){} } |
| |
| function resize() { W=canvas.width=window.innerWidth;H=canvas.height=window.innerHeight;lay.x=W/2;lay.y=H/2+20;lay.width=Math.min(120,W*0.15);lay.height=lay.width*1.83; } |
| window.addEventListener('resize', resize); |
| |
| function initBgParticles() { bgParticles=[]; for(let i=0;i<40;i++) bgParticles.push({x:Math.random()*W,y:Math.random()*H,r:Math.random()*2+0.5,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,alpha:Math.random()*0.15+0.03}); } |
| |
| function throwTomato(tx, ty) { |
| const sx=tx+(Math.random()-0.5)*100,sy=H+40,sx2=sx+(Math.random()-0.5)*60; |
| tomatoes.push({sx,sy,sx2,sy2:sy-(H*0.6+Math.random()*100),ex:tx+(Math.random()-0.5)*20,ey:ty+(Math.random()-0.5)*20,x:sx,y:sy,t:0,duration:0.5+Math.random()*0.2,rotation:0,rotSpeed:(Math.random()-0.5)*15,size:18+Math.random()*8,shadow:1}); |
| playThrowSound(); |
| } |
| |
| function createSplat(x,y,size) { |
| const drops=[],num=8+Math.floor(Math.random()*6); |
| for(let i=0;i<num;i++){const a=(Math.PI*2/num)*i+(Math.random()-0.5)*0.5,d=size*(0.8+Math.random()*1.2);drops.push({dx:Math.cos(a)*d,dy:Math.sin(a)*d*0.6,r:size*(0.15+Math.random()*0.25)});} |
| splats.push({x,y,drops,alpha:1,size}); |
| for(let i=0;i<12;i++){const a=Math.random()*Math.PI*2,s=2+Math.random()*5;particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-2,r:2+Math.random()*4,alpha:1,color:Math.random()>0.3?'#e53935':'#c62828',gravity:0.15});} |
| for(let i=0;i<4;i++){const a=Math.random()*Math.PI*2,s=1+Math.random()*3;particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-1,r:1.5,alpha:1,color:'#5d4037',gravity:0.12});} |
| } |
| |
| function addFloatingText(x,y,text) { floatingTexts.push({x,y,text,vy:-2,alpha:1,scale:1.5}); } |
| |
| function drawTomato(x,y,size,rot) { |
| ctx.save();ctx.translate(x,y);ctx.rotate(rot); |
| ctx.beginPath();ctx.ellipse(2,size*0.1,size*0.8,size*0.3,0,0,Math.PI*2);ctx.fillStyle='rgba(0,0,0,0.2)';ctx.fill(); |
| ctx.beginPath();ctx.arc(0,0,size,0,Math.PI*2); |
| const g=ctx.createRadialGradient(-size*0.3,-size*0.3,size*0.1,0,0,size);g.addColorStop(0,'#ff6659');g.addColorStop(0.5,'#e53935');g.addColorStop(1,'#b71c1c');ctx.fillStyle=g;ctx.fill(); |
| ctx.beginPath();ctx.ellipse(-size*0.25,-size*0.3,size*0.3,size*0.2,-0.4,0,Math.PI*2);ctx.fillStyle='rgba(255,255,255,0.35)';ctx.fill(); |
| ctx.beginPath();ctx.moveTo(-3,-size+2);ctx.quadraticCurveTo(0,-size-8,3,-size+2);ctx.strokeStyle='#2e7d32';ctx.lineWidth=2.5;ctx.lineCap='round';ctx.stroke(); |
| ctx.beginPath();ctx.ellipse(6,-size+1,7,3,0.5,0,Math.PI*2);ctx.fillStyle='#43a047';ctx.fill(); |
| ctx.beginPath();ctx.ellipse(-5,-size+2,6,2.5,-0.5,0,Math.PI*2);ctx.fillStyle='#388e3c';ctx.fill(); |
| ctx.restore(); |
| } |
| |
| function drawLay() { |
| const x=lay.x+lay.shakeX,y=lay.y+lay.shakeY,w=lay.width*lay.hitScale,h=lay.height*lay.hitScale; |
| ctx.save();ctx.translate(x,y); |
| ctx.beginPath();ctx.ellipse(0,h*0.42,w*0.5,12,0,0,Math.PI*2);ctx.fillStyle='rgba(0,0,0,0.2)';ctx.fill(); |
| ctx.fillStyle='#2c3e50';ctx.beginPath();ctx.roundRect(-w*0.22,h*0.18,w*0.16,h*0.25,4);ctx.fill();ctx.beginPath();ctx.roundRect(w*0.06,h*0.18,w*0.16,h*0.25,4);ctx.fill(); |
| ctx.fillStyle='#1a1a2e';ctx.beginPath();ctx.ellipse(-w*0.14,h*0.43,w*0.12,6,0,0,Math.PI*2);ctx.fill();ctx.beginPath();ctx.ellipse(w*0.14,h*0.43,w*0.12,6,0,0,Math.PI*2);ctx.fill(); |
| const sg=ctx.createLinearGradient(-w*0.3,-h*0.15,w*0.3,h*0.2);sg.addColorStop(0,'#f5f5f5');sg.addColorStop(1,'#e0e0e0');ctx.fillStyle=sg;ctx.beginPath();ctx.roundRect(-w*0.32,-h*0.15,w*0.64,h*0.37,8);ctx.fill(); |
| ctx.beginPath();ctx.moveTo(-w*0.08,-h*0.15);ctx.lineTo(0,-h*0.08);ctx.lineTo(w*0.08,-h*0.15);ctx.strokeStyle='#ccc';ctx.lineWidth=1.5;ctx.stroke(); |
| ctx.fillStyle='#f5f5f5'; |
| ctx.save();ctx.translate(-w*0.32,-h*0.05);ctx.rotate(lay.expression==='scared'?-0.5:-0.15);ctx.beginPath();ctx.roundRect(-w*0.06,-w*0.04,w*0.08,h*0.22,4);ctx.fill();ctx.beginPath();ctx.arc(-w*0.02,h*0.18,w*0.06,0,Math.PI*2);ctx.fillStyle='#ffcc80';ctx.fill();ctx.restore(); |
| ctx.save();ctx.translate(w*0.32,-h*0.05);ctx.rotate(lay.expression==='scared'?0.5:0.15);ctx.fillStyle='#f5f5f5';ctx.beginPath();ctx.roundRect(-w*0.02,-w*0.04,w*0.08,h*0.22,4);ctx.fill();ctx.beginPath();ctx.arc(w*0.02,h*0.18,w*0.06,0,Math.PI*2);ctx.fillStyle='#ffcc80';ctx.fill();ctx.restore(); |
| const hr=w*0.35;ctx.beginPath();ctx.arc(0,-h*0.32,hr,0,Math.PI*2);const sk=ctx.createRadialGradient(-hr*0.2,-h*0.32-hr*0.2,2,0,-h*0.32,hr);sk.addColorStop(0,'#ffe0b2');sk.addColorStop(1,'#ffcc80');ctx.fillStyle=sk;ctx.fill(); |
| ctx.beginPath();ctx.ellipse(0,-h*0.32-hr*0.6,hr*1.05,hr*0.55,0,Math.PI,Math.PI*2);ctx.fillStyle='#2c2c2c';ctx.fill(); |
| ctx.beginPath();ctx.ellipse(-hr*0.9,-h*0.32,hr*0.2,hr*0.5,0.1,0,Math.PI*2);ctx.fill();ctx.beginPath();ctx.ellipse(hr*0.9,-h*0.32,hr*0.2,hr*0.5,-0.1,0,Math.PI*2);ctx.fill(); |
| const eY=-h*0.33,eS=hr*0.35; |
| if(lay.expression==='hit'||lay.expression==='scared'){const xs=hr*0.12;ctx.strokeStyle='#333';ctx.lineWidth=2.5;ctx.lineCap='round';[-1,1].forEach(s=>{const ex=s*eS;ctx.beginPath();ctx.moveTo(ex-xs,eY-xs);ctx.lineTo(ex+xs,eY+xs);ctx.stroke();ctx.beginPath();ctx.moveTo(ex+xs,eY-xs);ctx.lineTo(ex-xs,eY+xs);ctx.stroke();});} |
| else{[-1,1].forEach(s=>{const ex=s*eS;ctx.beginPath();ctx.ellipse(ex,eY,hr*0.15,hr*0.12,0,0,Math.PI*2);ctx.fillStyle='#fff';ctx.fill();ctx.beginPath();ctx.arc(ex+s*1,eY,hr*0.07,0,Math.PI*2);ctx.fillStyle='#333';ctx.fill();ctx.beginPath();ctx.arc(ex+s*1+2,eY-2,hr*0.025,0,Math.PI*2);ctx.fillStyle='#fff';ctx.fill();});if(lay.expression==='annoyed'){ctx.strokeStyle='#333';ctx.lineWidth=2.5;ctx.lineCap='round';ctx.beginPath();ctx.moveTo(-eS-hr*0.15,eY-hr*0.18);ctx.lineTo(-eS+hr*0.12,eY-hr*0.22);ctx.stroke();ctx.beginPath();ctx.moveTo(eS+hr*0.15,eY-hr*0.18);ctx.lineTo(eS-hr*0.12,eY-hr*0.22);ctx.stroke();}} |
| ctx.beginPath();ctx.moveTo(0,eY+hr*0.15);ctx.lineTo(-3,eY+hr*0.25);ctx.lineTo(3,eY+hr*0.25);ctx.strokeStyle='#e0a060';ctx.lineWidth=1.5;ctx.stroke(); |
| const mY=eY+hr*0.4; |
| if(lay.expression==='hit'){ctx.beginPath();ctx.moveTo(-hr*0.2,mY);ctx.quadraticCurveTo(-hr*0.1,mY-4,0,mY);ctx.quadraticCurveTo(hr*0.1,mY+4,hr*0.2,mY);ctx.strokeStyle='#333';ctx.lineWidth=2;ctx.stroke();} |
| else if(lay.expression==='annoyed'){ctx.beginPath();ctx.arc(0,mY+hr*0.15,hr*0.18,Math.PI+0.3,-0.3);ctx.strokeStyle='#333';ctx.lineWidth=2;ctx.stroke();} |
| else if(lay.expression==='scared'){ctx.beginPath();ctx.ellipse(0,mY+3,hr*0.1,hr*0.14,0,0,Math.PI*2);ctx.fillStyle='#333';ctx.fill();ctx.beginPath();ctx.ellipse(0,mY+3,hr*0.07,hr*0.1,0,0,Math.PI*2);ctx.fillStyle='#c62828';ctx.fill();} |
| else{ctx.beginPath();ctx.arc(0,mY-2,hr*0.15,0.2,Math.PI-0.2);ctx.strokeStyle='#333';ctx.lineWidth=2;ctx.stroke();} |
| const st=[{x:-w*0.15,y:-h*0.02,r:w*0.08},{x:w*0.2,y:h*0.05,r:w*0.06},{x:-w*0.05,y:h*0.1,r:w*0.07},{x:w*0.1,y:-h*0.08,r:w*0.05},{x:-w*0.25,y:h*0.15,r:w*0.06}]; |
| st.forEach((s,i)=>{if(i<Math.min(splats.length,5)){ctx.beginPath();ctx.arc(s.x,s.y,s.r,0,Math.PI*2);ctx.fillStyle=`rgba(183,28,28,${0.5+Math.min(splats.length*0.03,0.3)})`;ctx.fill();}}); |
| ctx.fillStyle='rgba(0,0,0,0.5)';ctx.beginPath();ctx.roundRect(-w*0.4,h*0.48,w*0.8,22,6);ctx.fill();ctx.fillStyle='#fff';ctx.font=`900 ${Math.max(12,w*0.14)}px Nunito`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText('LAY',0,h*0.49+11); |
| ctx.restore(); |
| } |
| |
| function drawBackground() { |
| const bg=ctx.createLinearGradient(0,0,0,H);bg.addColorStop(0,'#0a140a');bg.addColorStop(0.5,'#0d1a0d');bg.addColorStop(1,'#10150a');ctx.fillStyle=bg;ctx.fillRect(0,0,W,H); |
| const sp=ctx.createRadialGradient(lay.x,lay.y-50,10,lay.x,lay.y-50,Math.max(W,H)*0.5);sp.addColorStop(0,'rgba(255,200,100,0.06)');sp.addColorStop(0.5,'rgba(255,150,50,0.02)');sp.addColorStop(1,'transparent');ctx.fillStyle=sp;ctx.fillRect(0,0,W,H); |
| const flY=lay.y+lay.height*0.45;ctx.fillStyle='rgba(255,255,255,0.02)';ctx.fillRect(0,flY,W,H-flY);ctx.strokeStyle='rgba(255,255,255,0.05)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,flY);ctx.lineTo(W,flY);ctx.stroke(); |
| bgParticles.forEach(p=>{ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle=`rgba(255,200,100,${p.alpha})`;ctx.fill();}); |
| } |
| |
| function drawSplats() { splats.forEach(s=>{ctx.globalAlpha=s.alpha;ctx.beginPath();ctx.arc(s.x,s.y,s.size*0.6,0,Math.PI*2);ctx.fillStyle='#c62828';ctx.fill();s.drops.forEach(d=>{ctx.beginPath();ctx.arc(s.x+d.dx,s.y+d.dy,Math.max(1,d.r),0,Math.PI*2);ctx.fillStyle='#d32f2f';ctx.fill();});ctx.beginPath();ctx.arc(s.x,s.y,s.size*0.3,0,Math.PI*2);ctx.fillStyle='#e53935';ctx.fill();ctx.globalAlpha=1;}); } |
| function drawParticles() { particles.forEach(p=>{ctx.globalAlpha=p.alpha;ctx.beginPath();ctx.arc(p.x,p.y,Math.max(0.5,p.r),0,Math.PI*2);ctx.fillStyle=p.color;ctx.fill();ctx.globalAlpha=1;}); } |
| function drawFloatingTexts() { floatingTexts.forEach(ft=>{ctx.globalAlpha=ft.alpha;ctx.save();ctx.translate(ft.x,ft.y);ctx.scale(ft.scale,ft.scale);ctx.font='900 24px Bangers';ctx.textAlign='center';ctx.strokeStyle='rgba(0,0,0,0.5)';ctx.lineWidth=3;ctx.strokeText(ft.text,0,0);ctx.fillStyle='#ff6659';ctx.fillText(ft.text,0,0);ctx.restore();ctx.globalAlpha=1;}); } |
| |
| function update(dt) { |
| bgParticles.forEach(p=>{p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=W;if(p.x>W)p.x=0;if(p.y<0)p.y=H;if(p.y>H)p.y=0;}); |
| lay.shakeX*=0.85;lay.shakeY*=0.85;lay.hitScale+=(1-lay.hitScale)*0.15; |
| if(lay.expressionTimer>0){lay.expressionTimer-=dt;if(lay.expressionTimer<=0){lay.expression=splats.length>15?'scared':splats.length>5?'annoyed':'normal';if(lay.expression!=='normal')lay.expressionTimer=lay.expression==='scared'?2:3;}} |
| tomatoes.forEach(t=>{t.t+=dt/t.duration;if(t.t>1)t.t=1;const p=t.t,o=1-p;t.x=o*o*t.sx+2*o*p*t.sx2+p*p*t.ex;t.y=o*o*t.sy+2*o*p*t.sy2+p*p*t.ey;t.rotation+=t.rotSpeed*dt;t.shadow=Math.max(0.1,1-((lay.y+lay.height*0.45)-t.y)/H);}); |
| const done=tomatoes.filter(t=>t.t>=1); |
| done.forEach(t=>{if(Math.abs(t.ex-lay.x)<lay.width*0.8&&Math.abs(t.ey-lay.y)<lay.height*0.7){createSplat(t.ex,t.ey,t.size);playSplatSound();tomatoCount++;updateHUD();lay.shakeX=(Math.random()-0.5)*12;lay.shakeY=(Math.random()-0.5)*8;lay.hitScale=0.92;lay.expression='hit';lay.expressionTimer=0.4;if(tomatoCount%10===0)addFloatingText(t.ex,t.ey-30,tomatoCount+' TOMATOES!');else if(tomatoCount%5===0)addFloatingText(t.ex,t.ey-20,'+5 COMBO!');const now=Date.now();if(now-lastSaveTime>SAVE_INTERVAL){lastSaveTime=now;persistScore();}}}); |
| tomatoes=tomatoes.filter(t=>t.t<1); |
| splats.forEach(s=>{s.alpha-=dt*0.008;});splats=splats.filter(s=>s.alpha>0.05);if(splats.length>30)splats=splats.slice(-30); |
| particles.forEach(p=>{p.x+=p.vx;p.y+=p.vy;p.vy+=p.gravity;p.alpha-=dt*1.5;p.r*=0.99;});particles=particles.filter(p=>p.alpha>0.01); |
| floatingTexts.forEach(ft=>{ft.y+=ft.vy;ft.alpha-=dt*0.8;ft.scale+=(1-ft.scale)*0.1;});floatingTexts=floatingTexts.filter(ft=>ft.alpha>0.01); |
| } |
| |
| function render() { |
| ctx.clearRect(0,0,W,H);drawBackground();drawSplats(); |
| const flY=lay.y+lay.height*0.45;tomatoes.forEach(t=>{ctx.globalAlpha=t.shadow*0.3;ctx.beginPath();ctx.ellipse(t.x,flY,t.size*0.5,t.size*0.15,0,0,Math.PI*2);ctx.fillStyle='#000';ctx.fill();ctx.globalAlpha=1;}); |
| drawLay();tomatoes.forEach(t=>{drawTomato(t.x,t.y,t.size,t.rotation);});drawParticles();drawFloatingTexts(); |
| } |
| |
| let lastTime=0; |
| function gameLoop(ts){if(!gameRunning)return;const dt=Math.min((ts-lastTime)/1000,0.05);lastTime=ts;update(dt);render();requestAnimationFrame(gameLoop);} |
| |
| function updateHUD(){ |
| const el=$('hudCount'); |
| el.textContent=tomatoCount; |
| el.classList.remove('count-pop'); |
| void el.offsetWidth; |
| el.classList.add('count-pop'); |
| |
| |
| if(tomatoCount > currentHighScore) { |
| $('hudHighScoreVal').textContent = tomatoCount; |
| } |
| } |
| |
| async function persistScore(){ |
| if(!cloudConnected || !sessionToken) return; |
| |
| const res = await apiRequest('/score', {method:'POST', body:JSON.stringify({score:tomatoCount})}); |
| if(res && res.isNewHighScore) { |
| currentHighScore = res.score; |
| $('hudHighScoreVal').textContent = currentHighScore; |
| showToast('New High Score: ' + currentHighScore + ' ๐
', 'success'); |
| } |
| } |
| |
| function startGame() { |
| $('loginScreen').classList.add('hidden'); |
| $('hud').style.display='flex'; |
| $('hudUsername').textContent=currentUser; |
| |
| |
| if(currentHighScore > 0) { |
| $('hudHighScore').style.display = 'flex'; |
| $('hudHighScoreVal').textContent = currentHighScore; |
| } else { |
| $('hudHighScore').style.display = 'none'; |
| } |
| |
| tomatoCount=0;tomatoes=[];splats=[];particles=[];floatingTexts=[];lay.hitScale=1;lay.shakeX=0;lay.shakeY=0;lay.expression='normal';lay.expressionTimer=0; |
| resize();initBgParticles();updateHUD();gameRunning=true;lastTime=performance.now();requestAnimationFrame(gameLoop); |
| $('instructions').style.display='block'; |
| setTimeout(()=>{$('instructions').style.display='none';},4000); |
| } |
| |
| canvas.addEventListener('click',e=>{if(gameRunning)throwTomato(e.clientX,e.clientY);}); |
| canvas.addEventListener('touchstart',e=>{if(!gameRunning)return;e.preventDefault();throwTomato(e.touches[0].clientX,e.touches[0].clientY);},{passive:false}); |
| window.addEventListener('beforeunload',()=>{if(gameRunning&¤tUser)persistScore();}); |
| |
| $('lbBtn').addEventListener('click', toggleLeaderboard); |
| $('logoutBtn').addEventListener('click', logout); |
| $('closeLbBtn').addEventListener('click', toggleLeaderboard); |
| $('lbOverlay').addEventListener('click', toggleLeaderboard); |
| $('refreshLbBtn').addEventListener('click', refreshLeaderboard); |
| |
| (function(){const c=$('floatingTomatoes');for(let i=0;i<12;i++){const el=document.createElement('div');el.className='float-tomato';el.textContent='๐
';el.style.left=Math.random()*100+'%';el.style.fontSize=(24+Math.random()*30)+'px';el.style.animationDuration=(8+Math.random()*10)+'s';el.style.animationDelay=Math.random()*10+'s';c.appendChild(el);}})(); |
| |
| resize(); |
| checkCloud(); |
| setTimeout(()=>$('usernameInput').focus(),300); |
| </script> |
| </body> |
| </html> |