|
|
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> |
| <title>DarkOnBalls</title> |
| <script type="module"> |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js"; |
| import { |
| getAuth, initializeAuth, indexedDBLocalPersistence, signInAnonymously, onAuthStateChanged, |
| browserLocalPersistence, |
| createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut, |
| EmailAuthProvider, reauthenticateWithCredential, deleteUser |
| } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js"; |
| import { getDatabase, ref, set, get, onValue, push, remove, update, serverTimestamp, onDisconnect } |
| from "https://www.gstatic.com/firebasejs/10.12.0/firebase-database.js"; |
| import { getAnalytics } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-analytics.js"; |
| |
| const firebaseConfig = { |
| apiKey: "AIzaSyCd0qjg9NI_v73xOxP76yHbsNoo8ZEEpBs", |
| authDomain: "darkonballs-restore.firebaseapp.com", |
| databaseURL: "https://darkonballs-restore-default-rtdb.europe-west1.firebasedatabase.app", |
| projectId: "darkonballs-restore", |
| storageBucket: "darkonballs-restore.firebasestorage.app", |
| messagingSenderId: "173230979680", |
| appId: "1:173230979680:web:3509ea0af8443c2b7c6105", |
| measurementId: "G-L91F1RRP8Y" |
| }; |
| |
| |
| |
| |
| |
| |
| let _app = null, _db = null, _auth = null; |
| let _fbReady = false; |
| let _authReady = false; |
| let _myUid = null; |
| |
| try { |
| _app = initializeApp(firebaseConfig); |
| _db = getDatabase(_app); |
| |
| |
| |
| try { |
| _auth = initializeAuth(_app, { persistence: [indexedDBLocalPersistence, browserLocalPersistence] }); |
| } catch(e) { _auth = getAuth(_app); } |
| try { getAnalytics(_app); } catch(e) {} |
| |
| |
| |
| |
| |
| _fbReady = true; |
| console.log("Firebase connected!"); |
| } catch(e) { |
| console.warn("Firebase init failed", e); |
| } |
| |
| |
| function dbRef(path) { return ref(_db, path); } |
| async function dbGet(path) { return (await get(dbRef(path))).val(); } |
| async function dbSet(path, v) { return set(dbRef(path), v); } |
| async function dbUpdate(path,v) { return update(dbRef(path), v); } |
| async function dbRemove(path) { return remove(dbRef(path)); } |
| function dbOn(path, cb) { return onValue(dbRef(path), snap => cb(snap.val())); } |
| function dbPush(path) { return push(dbRef(path)); } |
| function nowServer() { return serverTimestamp(); } |
| function dbOnDisconnect(path) { return onDisconnect(dbRef(path)); } |
| |
| |
| |
| |
| |
| let _authReadyResolvers = []; |
| function whenAuthReady() { |
| if (_authReady && _myUid) return Promise.resolve(_myUid); |
| return new Promise(resolve => _authReadyResolvers.push(resolve)); |
| } |
| |
| if (_fbReady) { |
| onAuthStateChanged(_auth, async (user) => { |
| if (user) { |
| _myUid = user.uid; |
| _authReady = true; |
| window._dobReady = true; |
| window.dispatchEvent(new Event("dobAuthReady")); |
| _authReadyResolvers.forEach(r => r(_myUid)); |
| _authReadyResolvers = []; |
| } else { |
| |
| |
| |
| _myUid = null; |
| window._dobReady = false; |
| |
| |
| try { await signInAnonymously(_auth); } |
| catch(e) { console.warn("Anonymous sign-in failed", e); } |
| } |
| }); |
| } else { |
| console.warn("Firebase not initialized; online features disabled."); |
| } |
| |
| |
| |
| |
| |
| window.dobIsReady = () => _fbReady && _authReady && !!_myUid; |
| window.dobMyUid = () => _myUid; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function usernameToEmail(username) { |
| const clean = String(username).trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""); |
| return clean + "@darkonballs2.local"; |
| } |
| |
| async function dobRegisterWithPassword(username, password) { |
| if (!_fbReady) throw new Error("Firebase not ready."); |
| const email = usernameToEmail(username); |
| const cred = await createUserWithEmailAndPassword(_auth, email, password); |
| return cred.user.uid; |
| } |
| |
| async function dobLoginWithPassword(username, password) { |
| if (!_fbReady) throw new Error("Firebase not ready."); |
| const email = usernameToEmail(username); |
| const cred = await signInWithEmailAndPassword(_auth, email, password); |
| return cred.user.uid; |
| } |
| |
| async function dobLogout() { |
| if (!_fbReady) return; |
| await signOut(_auth); |
| } |
| |
| |
| |
| async function dobReauth(password) { |
| const user = _auth && _auth.currentUser; |
| if (!user || user.isAnonymous || !user.email) throw new Error("No password account."); |
| const cred = EmailAuthProvider.credential(user.email, password); |
| await reauthenticateWithCredential(user, cred); |
| } |
| async function dobDeleteAuthUser() { |
| const user = _auth && _auth.currentUser; |
| if (!user) throw new Error("Not signed in."); |
| await deleteUser(user); |
| } |
| |
| function dobIsPasswordAccount() { |
| return !!(_auth && _auth.currentUser && !_auth.currentUser.isAnonymous); |
| } |
| |
| |
| |
| window._dobAuth = { |
| register: dobRegisterWithPassword, |
| login: dobLoginWithPassword, |
| logout: dobLogout, |
| reauth: dobReauth, |
| deleteAuthUser: dobDeleteAuthUser, |
| isPasswordAccount: dobIsPasswordAccount |
| }; |
| </script> |
|
|
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700;800&family=Sora:wght@400;600;800&family=Orbitron:wght@500;700;900&display=swap" rel="stylesheet"> |
| <style> |
| :root{ |
| --neon-green:#00ff88; |
| --neon-cyan:#00c8ff; |
| --neon-red:#ff4444; |
| --neon-gold:#ffd700; |
| --ink:#05050a; |
| --panel:#12121a; |
| --panel-hi:rgba(255,255,255,0.05); |
| --line:rgba(255,255,255,0.10); |
| --text:#eaf2fb; |
| --text-dim:#8b93a6; |
| --font-display:'Rajdhani',sans-serif; |
| --font-body:'Sora',Arial,sans-serif; |
| --font-hud:'Orbitron','Rajdhani',sans-serif; |
| } |
| |
| *{margin:0;padding:0;box-sizing:border-box;} |
| html,body{overflow:hidden;background:var(--ink);font-family:var(--font-body);width:100%;height:100%;touch-action:pan-y;} |
| |
| body{ |
| position:relative; |
| background: |
| radial-gradient(circle at 15% 15%, rgba(0,255,136,0.10), transparent 45%), |
| radial-gradient(circle at 85% 85%, rgba(255,68,68,0.10), transparent 45%), |
| radial-gradient(circle at 50% 50%, #11111c, #05050a 80%); |
| } |
| |
| |
| |
| body::before{ |
| content:"";position:fixed;inset:-10%;z-index:0;pointer-events:none; |
| background: |
| radial-gradient(circle at 20% 30%, rgba(0,200,255,0.07), transparent 32%), |
| radial-gradient(circle at 80% 70%, rgba(0,255,136,0.06), transparent 32%); |
| animation:driftGlow 22s ease-in-out infinite alternate; |
| mix-blend-mode:screen; |
| } |
| body::after{ |
| content:"";position:fixed;inset:0;z-index:1;pointer-events:none; |
| background:repeating-linear-gradient(to bottom, rgba(255,255,255,0.025) 0 1px, transparent 1px 3px); |
| opacity:.35; |
| box-shadow:inset 0 0 180px rgba(0,0,0,0.75); |
| } |
| @keyframes driftGlow{ |
| 0% {transform:translate(0,0) scale(1);} |
| 100%{transform:translate(2%,-2%) scale(1.06);} |
| } |
| |
| #stage{ |
| position:fixed;top:0;left:0;width:100%;height:100%; |
| background:#0a0a10; |
| } |
| #gameWrap{ |
| position:absolute;inset:0;background:#0a0a10;overflow:hidden; |
| } |
| canvas{ |
| display:block;touch-action:none;-webkit-user-select:none;user-select:none; |
| width:100%;height:100%; |
| } |
| |
| #ui{ |
| position:absolute;top:20px;left:20px;color:var(--text);font-family:var(--font-display); |
| font-size:19px;font-weight:600;pointer-events:none;z-index:10;letter-spacing:.5px; |
| background:linear-gradient(160deg, rgba(16,18,26,0.75), rgba(8,9,14,0.75)); |
| padding:12px 18px 12px 16px;border-radius:10px; |
| border:1px solid var(--line);border-left:3px solid var(--neon-green); |
| backdrop-filter:blur(8px); |
| box-shadow:0 8px 22px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.05); |
| text-shadow:0 0 12px rgba(0,255,136,0.2); |
| } |
| #ui span{font-family:var(--font-hud);font-weight:700;color:#fff;letter-spacing:.5px;} |
| |
| button{ |
| position:relative;overflow:hidden;isolation:isolate; |
| padding:15px 34px;font-size:19px;font-weight:600;font-family:var(--font-display); |
| letter-spacing:1px;text-transform:uppercase;border:1px solid var(--line);border-radius:10px; |
| cursor:pointer;margin:9px;color:#fff; |
| background:linear-gradient(150deg,#1a1a26,#100f16); |
| box-shadow:0 4px 16px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.06); |
| transition:transform .15s ease, box-shadow .2s ease, border-color .2s ease, background .2s ease; |
| } |
| button::before{ |
| content:"";position:absolute;inset:0;z-index:-1;opacity:0; |
| background:linear-gradient(120deg, transparent 30%, rgba(255,255,255,0.14) 50%, transparent 70%); |
| transform:translateX(-120%); |
| transition:opacity .2s ease; |
| } |
| @media (hover:hover) and (pointer:fine){ |
| button:hover{ |
| background:linear-gradient(150deg,#242436,#161620); |
| border-color:rgba(0,255,136,0.4); |
| box-shadow:0 8px 26px rgba(0,255,136,0.18), inset 0 1px 0 rgba(255,255,255,0.08); |
| transform:translateY(-2px); |
| } |
| button:hover::before{opacity:1;animation:btnSheen .9s ease forwards;} |
| } |
| button:active{transform:translateY(0) scale(.98);} |
| @keyframes btnSheen{ |
| 0%{transform:translateX(-120%);} |
| 100%{transform:translateX(120%);} |
| } |
| |
| #homeBtn{ |
| position:absolute;top:14px;left:50%;transform:translateX(-50%); |
| background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.18); |
| color:#ccc;font-size:12px;padding:7px 18px;border-radius:20px; |
| cursor:pointer;z-index:200;display:none;font-family:var(--font-display);touch-action:manipulation; |
| letter-spacing:1px;text-transform:uppercase;backdrop-filter:blur(6px);margin:0; |
| box-shadow:0 2px 10px rgba(0,0,0,0.3); |
| transition:background .15s, color .15s, border-color .15s, transform .15s; |
| } |
| @media (hover:hover) and (pointer:fine){#homeBtn:hover{background:rgba(255,255,255,0.16);color:#fff;border-color:rgba(0,255,136,0.45);transform:translateX(-50%) translateY(-1px);}} |
| |
| #menu,#settings,#offlineSettings,#accountScreen,#leaderboardScreen,#leagueScreen,#onlineWait,#matchResult,#vsScreen,#winnerOverlay,#gameOver{ |
| position:absolute;inset:0;display:none;flex-direction:column; |
| justify-content:center;align-items:center; |
| background:radial-gradient(circle at 50% 20%, #15151f, #08080c 75%); |
| color:#f0f0f5;z-index:30; |
| animation:screenFade .35s ease; |
| } |
| @keyframes screenFade{from{opacity:0;}to{opacity:1;}} |
| |
| #menu{ |
| display:flex; |
| flex-direction:row; |
| flex-wrap:wrap; |
| justify-content:safe center; |
| align-items:safe center; |
| gap:50px; |
| overflow-y:auto; |
| padding:60px 20px; |
| -webkit-overflow-scrolling:touch; |
| touch-action:pan-y; |
| } |
| |
| #settings,#offlineSettings,#leagueScreen{ |
| overflow-y:auto; |
| -webkit-overflow-scrolling:touch; |
| touch-action:pan-y; |
| justify-content:safe center; |
| padding:60px 20px; |
| } |
| |
| |
| #settings > *,#offlineSettings > *,#leagueScreen > *,#accountScreen > *,#leaderboardScreen > *,#leagueContent > *,.menuPage > *{flex-shrink:0;} |
| |
| h1{ |
| font-size:58px;margin-bottom:26px;font-family:var(--font-display);font-weight:800; |
| letter-spacing:1.5px;background:linear-gradient(90deg,#00ff88,#00c8ff 55%,#ff4444); |
| -webkit-background-clip:text;background-clip:text;color:transparent; |
| text-shadow:0 0 40px rgba(0,255,136,0.25); |
| filter:drop-shadow(0 0 18px rgba(0,200,255,0.18)); |
| animation:titlePulse 4.5s ease-in-out infinite; |
| } |
| @keyframes titlePulse{ |
| 0%,100%{filter:drop-shadow(0 0 18px rgba(0,200,255,0.18));} |
| 50%{filter:drop-shadow(0 0 30px rgba(0,255,136,0.30));} |
| } |
| h2{ |
| font-size:30px;margin-bottom:20px;font-family:var(--font-display);font-weight:700;letter-spacing:.5px; |
| position:relative;padding-bottom:10px; |
| } |
| h2::after{ |
| content:"";display:block;width:56px;height:3px;margin:10px auto 0; |
| background:linear-gradient(90deg,var(--neon-green),var(--neon-cyan)); |
| border-radius:3px; |
| } |
| #gameOver{background:rgba(5,5,8,0.92);z-index:40;backdrop-filter:blur(6px);} |
| |
| #eloResult{ |
| position:absolute;top:50%;left:50%; |
| transform:translate(-50%,-50%) scale(0); |
| font-size:80px;font-weight:800;text-align:center;font-family:var(--font-hud); |
| pointer-events:none;opacity:0; |
| text-shadow:0 0 30px currentColor;z-index:100; |
| } |
| @keyframes eloPopIn{ |
| 0% {transform:translate(-50%,-50%) scale(0);opacity:0;} |
| 30% {transform:translate(-50%,-50%) scale(1.3);opacity:1;} |
| 60% {transform:translate(-50%,-50%) scale(1);opacity:1;} |
| 80% {transform:translate(-50%,-60%) scale(1);opacity:1;} |
| 100%{transform:translate(-50%,-80%) scale(0.8);opacity:0;} |
| } |
| |
| #vsScreen{background:radial-gradient(circle at 50% 50%, #12121a, #050507 80%);z-index:60;} |
| .vs-name{font-size:44px;font-weight:700;text-align:center;font-family:var(--font-display);} |
| .vs-elo{font-size:22px;color:var(--text-dim);margin-top:6px;font-family:var(--font-hud);} |
| .vs-divider{ |
| font-size:60px;font-weight:900;color:var(--neon-red);margin:0 34px; |
| text-shadow:0 0 30px var(--neon-red);font-family:var(--font-display); |
| animation:vsShake 1.1s ease-in-out infinite; |
| } |
| @keyframes vsShake{0%,100%{transform:scale(1);}50%{transform:scale(1.08);}} |
| #vsCountdown{font-size:36px;margin-top:40px;color:var(--neon-gold);font-family:var(--font-hud);font-weight:700;text-shadow:0 0 20px rgba(255,215,0,0.4);} |
| #vsContainer{display:flex;align-items:center;justify-content:center;} |
| |
| #winnerOverlay{background:rgba(5,5,8,0.93);z-index:55;backdrop-filter:blur(6px);} |
| #winnerOverlay h2{font-size:54px;font-weight:900;text-shadow:0 0 40px gold;font-family:var(--font-display);} |
| #winnerOverlay h2::after{display:none;} |
| #winnerOverlay p{font-size:26px;color:#aaa;margin-top:10px;font-family:var(--font-hud);} |
| |
| #eloTable{ |
| position:absolute;top:20px;right:20px; |
| background:linear-gradient(160deg, rgba(16,18,26,0.75), rgba(8,9,14,0.8)); |
| padding:12px 14px; |
| color:white;width:190px;max-height:60vh;overflow-y:auto; |
| border-radius:12px;display:none;font-size:12px;line-height:1.4;z-index:20; |
| border:1px solid var(--line);border-top:2px solid var(--neon-gold);backdrop-filter:blur(10px); |
| box-shadow:0 10px 26px rgba(0,0,0,0.4); |
| } |
| #eloTable b{font-family:var(--font-display);letter-spacing:1px;color:var(--neon-gold);} |
| |
| #profileModal{display:none;position:absolute;inset:0;background:rgba(3,3,6,0.85);z-index:200;justify-content:center;align-items:center;backdrop-filter:blur(6px);} |
| #profileCard{ |
| background:linear-gradient(160deg,#1b1b29,#111119); |
| border:1px solid var(--line);border-radius:18px;padding:30px 38px; |
| color:white;min-width:300px;max-width:380px;position:relative; |
| max-height:88vh;max-height:88dvh;display:flex;flex-direction:column;box-sizing:border-box; |
| box-shadow:0 24px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(0,255,136,0.06); |
| } |
| #profileCard h2{margin-bottom:18px;font-size:26px;} |
| #profileStats{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;touch-action:pan-y;padding-right:6px;} |
| #profileStats::-webkit-scrollbar{width:6px;} |
| #profileStats::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.25);border-radius:3px;} |
| #profileCard h2::after{margin-left:0;} |
| .prof-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.08);} |
| .prof-label{color:var(--text-dim);font-size:15px;font-family:var(--font-display);letter-spacing:.4px;} |
| .prof-val{font-size:15px;font-weight:bold;color:var(--neon-green);font-family:var(--font-hud);} |
| #profileClose{position:absolute;top:14px;right:18px;cursor:pointer;font-size:20px;color:var(--text-dim);transition:color .15s, transform .15s;} |
| @media (hover:hover) and (pointer:fine){#profileClose:hover{color:#fff;transform:rotate(90deg);}} |
| |
| #coinHUD{ |
| display:none;position:absolute;top:20px;left:50%;transform:translateX(-50%); |
| color:var(--neon-gold);font-size:26px;font-weight:700;font-family:var(--font-hud); |
| background:linear-gradient(160deg, rgba(16,18,26,0.75), rgba(8,9,14,0.75)); |
| padding:10px 26px;border-radius:14px; |
| text-align:center;z-index:20;pointer-events:none; |
| border:1px solid rgba(255,215,0,0.28);backdrop-filter:blur(8px); |
| text-shadow:0 0 14px rgba(255,215,0,0.45); |
| letter-spacing:1px; |
| } |
| |
| #onlineStatus{font-size:18px;color:var(--text-dim);margin-bottom:20px;font-family:var(--font-display);} |
| |
| #lbTable{width:420px;max-height:60vh;overflow-y:auto;margin-top:14px;border-radius:12px;overflow-x:hidden;border:1px solid var(--line);} |
| .lb-row{ |
| display:flex;justify-content:space-between;padding:11px 16px; |
| border-bottom:1px solid rgba(255,255,255,0.06);font-size:16px; |
| font-family:var(--font-hud);transition:background .15s; |
| } |
| @media (hover:hover) and (pointer:fine){.lb-row:hover{background:rgba(255,255,255,0.04);}} |
| .lb-row:first-child{ |
| font-weight:bold;color:var(--neon-gold);background:rgba(255,215,0,0.08); |
| font-family:var(--font-display);letter-spacing:.5px; |
| } |
| @media (hover:hover) and (pointer:fine){.lb-row:first-child:hover{background:rgba(255,215,0,0.08);}} |
| .lb-me{background:rgba(0,255,136,0.14);} |
| @media (hover:hover) and (pointer:fine){.lb-me:hover{background:rgba(0,255,136,0.14);}} |
| |
| input[type=text],input[type=number],input[type=password],select{ |
| padding:12px 18px;font-size:18px;border-radius:10px; |
| border:1px solid var(--line);background:#15151d;color:white; |
| width:260px;margin:8px 0;font-family:var(--font-body); |
| transition:border-color .15s, box-shadow .15s; |
| } |
| input[type=text]:focus,input[type=number]:focus,input[type=password]:focus,select:focus{ |
| outline:none;border-color:rgba(0,255,136,0.5); |
| box-shadow:0 0 0 3px rgba(0,255,136,0.12); |
| } |
| label{font-family:var(--font-display);letter-spacing:.4px;color:var(--text-dim);} |
| #accountInfo{font-size:16px;color:var(--text-dim);margin-top:8px;} |
| |
| |
| #ballColorRow{ |
| display:flex;align-items:center;justify-content:center;gap:10px; |
| flex-wrap:wrap;max-width:300px;margin:10px 0 4px; |
| } |
| .color-swatch{ |
| width:34px;height:34px;border-radius:50%;cursor:pointer; |
| border:2px solid rgba(255,255,255,0.15); |
| box-shadow:0 2px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15); |
| transition:transform .15s ease, border-color .15s ease, box-shadow .15s ease; |
| padding:0;margin:0; |
| } |
| @media (hover:hover) and (pointer:fine){.color-swatch:hover{transform:scale(1.12);}} |
| .color-swatch.active{ |
| border-color:#fff; |
| transform:scale(1.15); |
| box-shadow:0 0 0 3px rgba(255,255,255,0.18), 0 2px 12px rgba(0,0,0,0.5); |
| } |
| #ballColorCustom{ |
| width:34px;height:34px;padding:0;border-radius:50%;border:2px dashed rgba(255,255,255,0.35); |
| background:transparent;cursor:pointer;overflow:hidden; |
| } |
| #ballColorCustom::-webkit-color-swatch-wrapper{padding:0;border-radius:50%;} |
| #ballColorCustom::-webkit-color-swatch{border:none;border-radius:50%;} |
| #ballPreviewWrap{ |
| display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:6px; |
| } |
| #ballPreviewCanvas{ |
| border-radius:14px;background:#0a0a10;border:1px solid var(--line); |
| } |
| |
| |
| .switch{position:relative;display:inline-block;width:52px;height:28px;vertical-align:middle;margin:8px 0;} |
| .switch input{opacity:0;width:0;height:0;} |
| .switch .slider{ |
| position:absolute;cursor:pointer;inset:0;border-radius:28px; |
| background:#20202c;border:1px solid var(--line); |
| transition:background .2s ease, border-color .2s ease; |
| } |
| .switch .slider::before{ |
| content:"";position:absolute;height:20px;width:20px;left:3px;top:3px; |
| border-radius:50%;background:#8b93a6; |
| transition:transform .2s ease, background .2s ease; |
| } |
| .switch input:checked + .slider{background:rgba(0,255,136,0.18);border-color:rgba(0,255,136,0.5);} |
| .switch input:checked + .slider::before{transform:translateX(24px);background:var(--neon-green);box-shadow:0 0 8px rgba(0,255,136,0.7);} |
| |
| #opponentInfo,#spectatorInfo{ |
| position:absolute;bottom:20px;left:20px;color:white;font-size:18px; |
| background:linear-gradient(160deg, rgba(16,18,26,0.78), rgba(8,9,14,0.78)); |
| padding:16px 22px;border-radius:14px; |
| display:none;line-height:1.6;border:1px solid var(--line);border-left:3px solid var(--neon-cyan);z-index:20; |
| backdrop-filter:blur(8px);box-shadow:0 8px 22px rgba(0,0,0,0.4); |
| } |
| |
| #pingDisplay{ |
| position:absolute;top:20px;right:210px;color:var(--text-dim);font-size:12px; |
| background:rgba(10,12,20,0.6);padding:6px 14px;border-radius:10px; |
| display:none;z-index:20;pointer-events:none;border:1px solid var(--line); |
| font-family:var(--font-hud);letter-spacing:.5px; |
| } |
| |
| #onlineHUD{ |
| position:absolute;top:20px;left:50%;transform:translateX(-50%); |
| display:none;flex-direction:column;align-items:center; |
| background:linear-gradient(160deg, rgba(16,18,26,0.78), rgba(8,9,14,0.78)); |
| padding:12px 28px;border-radius:14px; |
| color:white;font-size:16px;z-index:20;pointer-events:none; |
| border:1px solid var(--line);border-bottom:2px solid var(--neon-cyan);backdrop-filter:blur(10px); |
| box-shadow:0 10px 26px rgba(0,0,0,0.4); |
| } |
| .online-names{display:flex;align-items:center;gap:20px;font-size:20px;font-weight:bold;font-family:var(--font-display);} |
| |
| ::-webkit-scrollbar{width:8px;height:8px;} |
| ::-webkit-scrollbar-track{background:transparent;} |
| ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15);border-radius:8px;} |
| ::-webkit-scrollbar-thumb:hover{background:rgba(0,255,136,0.35);} |
| |
| @media (max-width:720px){ |
| #menu{gap:24px;padding:40px 14px;} |
| h1{font-size:40px;} |
| button{padding:13px 24px;font-size:16px;} |
| } |
| |
| |
| #ui,#onlineHUD,#coinHUD,#pingDisplay,#opponentInfo,#spectatorInfo{transition:opacity .2s ease;} |
| #ui{top:10px;left:10px;font-size:13px;padding:6px 10px 6px 10px;border-radius:8px;} |
| #onlineHUD{top:8px;padding:5px 14px;border-radius:10px;font-size:12px;} |
| .online-names{font-size:14px;gap:12px;} |
| #onlineScoreHUD{font-size:15px!important;margin-top:3px!important;} |
| #onlineStatusHUD{font-size:10px!important;margin-top:2px!important;} |
| #onlineRankMe,#onlineRankOpp{font-size:10px!important;} |
| #coinHUD{top:10px;font-size:16px;padding:5px 14px;border-radius:10px;} |
| #pingDisplay{top:10px;right:10px;font-size:10px;padding:4px 9px;} |
| #opponentInfo,#spectatorInfo{bottom:10px;left:10px;font-size:12px;padding:8px 12px;border-radius:10px;line-height:1.4;} |
| #eloTable{top:10px;right:10px;width:150px;font-size:11px;padding:8px 10px;} |
| |
| |
| #menu{flex-wrap:nowrap;gap:24px;} |
| #socialBar,#socialBarMain{position:static;transform:none;display:flex;gap:10px;flex-wrap:wrap;justify-content:center;margin-top:16px;z-index:6;} |
| .socialLink{display:flex;align-items:center;gap:8px;padding:6px 14px 6px 8px;border-radius:999px;color:#f0f0f5;text-decoration:none; |
| font-family:var(--font-display);font-size:13px;background:rgba(16,18,26,0.75);border:1px solid rgba(255,255,255,0.12); |
| backdrop-filter:blur(8px);transition:transform .15s ease,border-color .15s ease;} |
| @media (hover:hover) and (pointer:fine){.socialLink:hover{transform:translateY(-2px);border-color:rgba(255,255,255,0.35);}} |
| |
| |
| #chatMessages b.nm-t1,#chatMessages b.nm-t2,#chatMessages b.nm-t3{position:relative;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent!important;font-weight:800;letter-spacing:.2px} |
| #chatMessages b.nm-t1{background-image:linear-gradient(100deg,#8f9bb0 0%,#f4f8ff 22%,#aab6c9 40%,#ffffff 52%,#8f9bb0 70%,#dfe8f5 100%);animation:nmShine 4.5s linear infinite;filter:drop-shadow(0 0 3px rgba(200,215,235,.35))} |
| #chatMessages b.nm-t2{background-image:linear-gradient(100deg,#b8860b 0%,#ffe766 20%,#ffb300 38%,#fff6b0 50%,#e6a800 68%,#ffd700 100%);animation:nmShine 3.2s linear infinite;filter:drop-shadow(0 0 5px rgba(255,200,0,.55))} |
| #chatMessages b.nm-t3{background-image:linear-gradient(100deg,#00e5ff 0%,#7cffe9 18%,#b388ff 36%,#ffffff 48%,#4fe0d0 62%,#4aa8ff 80%,#00e5ff 100%);background-size:300% 100%;animation:nmShine 2.6s linear infinite,nmPulse 2.2s ease-in-out infinite;filter:drop-shadow(0 0 6px rgba(79,224,208,.8))} |
| #chatMessages b.nm-t3::after{content:"✦";position:absolute;right:-12px;top:-5px;font-size:9px;-webkit-text-fill-color:#aef;color:#aef;animation:nmSpark 1.8s ease-in-out infinite} |
| #chatMessages b.nm-t2::after{content:"✦";position:absolute;right:-11px;top:-4px;font-size:8px;-webkit-text-fill-color:#ffe98a;color:#ffe98a;animation:nmSpark 2.6s ease-in-out infinite} |
| #chatMessages b.nm-t3,#chatMessages b.nm-t2{margin-right:12px} |
| @keyframes nmShine{0%{background-position:150% 0}100%{background-position:-100% 0}} |
| @keyframes nmPulse{0%,100%{filter:drop-shadow(0 0 4px rgba(79,224,208,.6))}50%{filter:drop-shadow(0 0 10px rgba(124,255,233,1))}} |
| @keyframes nmSpark{0%,100%{opacity:.15;transform:scale(.6) rotate(0)}50%{opacity:1;transform:scale(1.2) rotate(45deg)}} |
| @media (prefers-reduced-motion:reduce){#chatMessages b.nm-t1,#chatMessages b.nm-t2,#chatMessages b.nm-t3,#chatMessages b.nm-t2::after,#chatMessages b.nm-t3::after{animation:none}} |
| #chatPanel,#onlinePlayersPanel{width:260px!important;} |
| #onlinePlayersPanel{height:400px;max-height:400px;} |
| @media (max-width:980px){#menu{flex-wrap:wrap;gap:20px;}#chatPanel,#onlinePlayersPanel{width:320px!important;}} |
| |
| @media (max-width:980px){ |
| #socialBar{margin-top:12px;} |
| #menu{padding-bottom:calc(40px + env(safe-area-inset-bottom,0px));} |
| } |
| |
| body.ingame::before,body.ingame::after{display:none;} |
| body.mode-online #homeBtn{display:none!important;} |
| body.mode-tv #homeBtn{left:10px;top:calc(10px + env(safe-area-inset-top,0px));transform:none;padding:5px 12px;font-size:11px;} |
| body.mode-tv #homeBtn:hover{transform:none;} |
| #streakOverlay{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);z-index:900;text-align:center;pointer-events:none;font-family:var(--font-display,sans-serif);color:#fff;text-shadow:0 4px 24px rgba(0,0,0,.7);} |
| #streakOverlay .sk-title{font-size:clamp(34px,7vw,64px);font-weight:800;letter-spacing:3px;} |
| #streakOverlay .sk-sub{margin-top:8px;font-size:clamp(16px,3vw,24px);color:#ffd7a0;} |
| #streakOverlay .sk-btns{margin-top:22px;display:flex;gap:12px;justify-content:center;pointer-events:auto;} |
| #streakOverlay.sk-over{pointer-events:auto;background:rgba(10,12,18,.82);padding:26px 38px;border-radius:18px;border:1px solid rgba(255,157,0,.55);box-shadow:0 18px 60px rgba(0,0,0,.6);} |
| #streakOverlay .sk-new{margin-top:6px;color:#00e676;font-weight:700;letter-spacing:2px;} |
| |
| .ch-btn{margin-left:8px;border:1px solid rgba(255,215,0,.55);background:rgba(255,215,0,.12);color:#ffd700;border-radius:8px;padding:1px 8px;font-size:13px;line-height:20px;cursor:pointer;flex:none;} |
| @media (hover:hover) and (pointer:fine){.ch-btn:hover{background:rgba(255,215,0,.28);}} |
| .ch-btn.ch-busy,.ch-btn:disabled{opacity:.35;cursor:not-allowed;filter:grayscale(1);} |
| #challengeBox{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(120px + env(safe-area-inset-bottom,0px));z-index:950;min-width:260px;max-width:92vw;padding:14px 18px 10px;border-radius:16px;background:rgba(14,16,24,.96);border:1px solid #ffd700;box-shadow:0 12px 40px rgba(0,0,0,.6),0 0 24px rgba(255,215,0,.18);color:#fff;font-family:var(--font-display,sans-serif);animation:chPop .25s ease;} |
| @keyframes chPop{from{opacity:0;transform:translateX(-50%) translateY(14px) scale(.96);}to{opacity:1;transform:translateX(-50%) translateY(0) scale(1);}} |
| #challengeBox .ch-title{font-size:11px;letter-spacing:2px;color:#ffd700;margin-bottom:6px;} |
| #challengeBox .ch-row{display:flex;align-items:center;gap:14px;} |
| #challengeBox .ch-info{flex:1;min-width:0;} |
| #challengeBox .ch-name{font-size:20px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} |
| #challengeBox .ch-elo{font-size:14px;color:#cfd3e0;margin-top:2px;} |
| #challengeBox .ch-elo b{color:#ffd700;} #challengeBox .ch-elo i{font-style:normal;color:#00e0ff;margin-left:8px;} |
| #challengeBox .ch-yes,#challengeBox .ch-no{width:48px;height:48px;border-radius:50%;border:2px solid;font-size:24px;line-height:1;cursor:pointer;background:transparent;flex:none;} |
| #challengeBox .ch-yes{border-color:#00e676;color:#00e676;} #challengeBox .ch-yes:hover{background:rgba(0,230,118,.2);} |
| #challengeBox .ch-no{border-color:#ff4455;color:#ff4455;} #challengeBox .ch-no:hover{background:rgba(255,68,85,.2);} |
| #challengeBox .ch-bar{height:3px;margin-top:10px;border-radius:2px;background:rgba(255,255,255,.1);overflow:hidden;} |
| #challengeBox .ch-bar i{display:block;height:100%;background:#ffd700;width:100%;} |
| .menuPage{display:flex;flex-direction:column;align-items:center;} |
| #accountBtn{position:absolute;top:14px;right:16px;margin:0;padding:8px 16px;font-size:14px;z-index:5;} |
| #newsBtn{position:absolute;top:62px;right:16px;margin:0;padding:8px 16px;font-size:14px;z-index:5;} |
| #newsBtn .newsDot{display:none;width:9px;height:9px;border-radius:50%;background:#ff4d4d;margin-left:8px;vertical-align:middle;box-shadow:0 0 8px #ff4d4d;} |
| #newsOverlay{position:fixed;inset:0;z-index:10000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);} |
| #newsBox{width:min(560px,92vw);max-height:84vh;display:flex;flex-direction:column;background:linear-gradient(160deg,rgba(16,18,26,0.98),rgba(8,9,14,0.98));border:1px solid rgba(255,255,255,0.12);border-top:3px solid var(--neon-cyan);border-radius:14px;padding:16px;text-align:left;color:#e6e9f2;box-shadow:0 20px 50px rgba(0,0,0,0.6);} |
| #newsList{overflow-y:auto;display:flex;flex-direction:column;gap:10px;margin:10px 0;} |
| .newsItem{border-bottom:1px solid rgba(255,255,255,0.08);padding-bottom:10px;} |
| .newsItem .nTitle{font-weight:700;font-size:15px;color:#ffd700;} |
| .newsItem .nDate{font-size:11px;color:#8b93a6;margin-bottom:4px;} |
| .newsItem .nText{font-size:14px;white-space:pre-wrap;word-break:break-word;} |
| .newsReact{display:flex;gap:8px;margin-top:8px;} |
| .newsReact button{margin:0;padding:4px 12px;font-size:13px;min-width:62px;opacity:.75;} |
| .newsReact button.on{opacity:1;} |
| .newsReact button.up.on{border-color:#00ff88;box-shadow:0 0 10px rgba(0,255,136,.45);background:rgba(0,255,136,.14);} |
| .newsReact button.down.on{border-color:#ff4d4d;box-shadow:0 0 10px rgba(255,77,77,.45);background:rgba(255,77,77,.14);} |
| |
| |
| |
| #leagueContent{width:460px;max-width:94vw;gap:14px;} |
| .lg-hero{width:100%;text-align:center;padding:22px 16px 18px;border-radius:18px;border:1px solid color-mix(in srgb,var(--lc) 45%,transparent); |
| background:radial-gradient(circle at 50% 0%,color-mix(in srgb,var(--lc) 22%,transparent),rgba(255,255,255,0.02) 70%); |
| box-shadow:0 0 40px color-mix(in srgb,var(--lc) 18%,transparent) inset,0 8px 30px rgba(0,0,0,.35);} |
| .lg-hero-icon{font-size:58px;line-height:1;filter:drop-shadow(0 0 18px var(--lc));animation:lgFloat 3.5s ease-in-out infinite;} |
| @keyframes lgFloat{0%,100%{transform:translateY(0);}50%{transform:translateY(-5px);}} |
| .lg-hero-name{font-family:var(--font-display);font-weight:800;font-size:34px;letter-spacing:1px;color:var(--lc);margin-top:6px;} |
| .lg-hero-sub{font-size:13px;color:var(--text-dim);margin-top:2px;} |
| .lg-time{margin-top:14px;text-align:left;} |
| .lg-time-top{display:flex;justify-content:space-between;font-size:12px;color:var(--text-dim);margin-bottom:6px;font-family:var(--font-body);} |
| .lg-time-top b{color:var(--text);font-family:var(--font-hud);font-weight:600;} |
| .lg-bar{height:8px;border-radius:8px;background:rgba(255,255,255,.08);overflow:hidden;} |
| .lg-bar>i{display:block;height:100%;border-radius:8px;background:linear-gradient(90deg,var(--lc),#fff8);transition:width .6s ease;} |
| .lg-ladder{width:100%;display:flex;align-items:center;justify-content:space-between;gap:4px;} |
| .lg-step{flex:1;text-align:center;padding:10px 4px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.03);opacity:.5;font-size:12px;font-family:var(--font-display);font-weight:700;letter-spacing:.4px;} |
| .lg-step span{display:block;font-size:22px;margin-bottom:2px;} |
| .lg-step{cursor:pointer;} |
| .lg-step.sel{opacity:1;outline:2px solid var(--c);outline-offset:2px;} |
| .lg-groups{width:100%;display:flex;flex-wrap:wrap;gap:6px;justify-content:center;} |
| .lg-chip{margin:0;padding:6px 12px;font-size:12px;border-radius:999px;} |
| .lg-chip.on{border-color:#00ff88;color:#00ff88;} |
| .lg-chip small{opacity:.7;margin-left:4px;} |
| .lg-step.done{opacity:.8;} |
| .lg-step.now{opacity:1;color:var(--c);border-color:var(--c);background:color-mix(in srgb,var(--c) 14%,transparent);box-shadow:0 0 18px color-mix(in srgb,var(--c) 35%,transparent);transform:scale(1.06);} |
| .lg-arrow{color:var(--text-dim);font-size:14px;} |
| .lg-card{width:100%;border-radius:14px;padding:12px 16px;border:1px solid var(--line);background:rgba(255,255,255,.04);font-size:14px;line-height:1.5;} |
| .lg-card.up{border-color:rgba(0,255,136,.5);background:rgba(0,255,136,.08);} |
| .lg-card.down{border-color:rgba(255,68,68,.5);background:rgba(255,68,68,.08);} |
| .lg-card.safe{border-color:rgba(0,200,255,.4);background:rgba(0,200,255,.07);} |
| .lg-card h4{margin:0 0 3px;font-family:var(--font-display);font-size:16px;letter-spacing:.4px;} |
| .lg-stats{display:flex;gap:10px;width:100%;} |
| .lg-stat{flex:1;text-align:center;padding:10px 4px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.04);} |
| .lg-stat b{display:block;font-family:var(--font-hud);font-size:22px;color:var(--neon-green);} |
| .lg-stat small{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px;} |
| .lg-table{width:100%;max-height:44vh;overflow-y:auto;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.02);} |
| .lg-zone{padding:6px 14px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;font-family:var(--font-display);} |
| .lg-zone.up{background:rgba(0,255,136,.14);color:var(--neon-green);} |
| .lg-zone.down{background:rgba(255,68,68,.14);color:var(--neon-red);} |
| .lg-zone.mid{background:rgba(255,255,255,.05);color:var(--text-dim);} |
| .lg-row{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid rgba(255,255,255,.05);cursor:pointer;transition:background .15s;} |
| @media (hover:hover) and (pointer:fine){.lg-row:hover{background:rgba(255,255,255,.06);}} |
| .lg-row.me{background:rgba(0,255,136,.12);box-shadow:inset 3px 0 0 var(--neon-green);} |
| .lg-rank{width:30px;font-family:var(--font-hud);font-size:14px;color:var(--text-dim);text-align:center;} |
| .lg-av{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-family:var(--font-display);color:#0a0a10;background:var(--lc);flex:none;} |
| .lg-name{flex:1;min-width:0;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} |
| .lg-name small{display:block;font-size:10px;color:var(--text-dim);} |
| .lg-wins{font-family:var(--font-hud);font-size:15px;font-weight:700;min-width:34px;text-align:right;} |
| .lg-how{width:100%;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.03);overflow:hidden;} |
| .lg-how summary{cursor:pointer;padding:12px 16px;font-family:var(--font-display);font-weight:700;font-size:17px;list-style:none;display:flex;justify-content:space-between;align-items:center;} |
| .lg-how summary::-webkit-details-marker{display:none;} |
| .lg-how summary::after{content:"▾";color:var(--text-dim);transition:transform .2s;} |
| .lg-how[open] summary::after{transform:rotate(180deg);} |
| .lg-steps{padding:2px 16px 14px;display:flex;flex-direction:column;gap:10px;} |
| .lg-s{display:flex;gap:12px;align-items:flex-start;font-size:13.5px;line-height:1.5;color:#cfd6e4;} |
| .lg-s>b{flex:none;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--neon-green),var(--neon-cyan));color:#06100c;font-family:var(--font-hud);font-size:13px;} |
| .lg-s strong{color:#fff;} |
| .lg-foot{font-size:12px;color:var(--text-dim);text-align:center;line-height:1.5;} |
| |
| .lg-prog{width:100%;} |
| .lg-prog-top{display:flex;justify-content:space-between;font-size:12px;color:var(--text-dim);margin-bottom:6px;} |
| .lg-prog-top b{color:var(--text);font-family:var(--font-hud);font-weight:600;} |
| .lg-bar.ok>i{background:linear-gradient(90deg,var(--neon-green),var(--neon-cyan));} |
| .lg-celebrate{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:rgba(5,5,10,.82);backdrop-filter:blur(6px);overflow:hidden;animation:screenFade .3s ease;cursor:pointer;} |
| .lg-celebrate.out{opacity:0;transition:opacity .3s;} |
| .lg-celebrate>i{position:absolute;top:-20px;width:9px;height:14px;border-radius:2px;opacity:.9;animation:lgFall linear forwards;} |
| @keyframes lgFall{to{transform:translate(var(--dx),110vh) rotate(720deg);}} |
| .lg-cel-box{text-align:center;padding:28px 34px;border-radius:22px;border:1px solid color-mix(in srgb,var(--lc) 55%,transparent);background:radial-gradient(circle at 50% 0%,color-mix(in srgb,var(--lc) 24%,transparent),#0c0c14 75%);box-shadow:0 0 60px color-mix(in srgb,var(--lc) 30%,transparent);max-width:86vw;animation:lgPop .5s cubic-bezier(.2,1.4,.4,1);} |
| @keyframes lgPop{from{transform:scale(.6);opacity:0;}to{transform:scale(1);opacity:1;}} |
| .lg-cel-icon{font-size:80px;line-height:1;filter:drop-shadow(0 0 22px var(--lc));animation:lgFloat 2.5s ease-in-out infinite;} |
| .lg-cel-title{font-family:var(--font-display);font-weight:800;font-size:40px;letter-spacing:1px;margin-top:8px;color:var(--lc);} |
| .lg-celebrate.down .lg-cel-title{color:var(--neon-red);} |
| .lg-cel-sub{font-family:var(--font-display);font-size:22px;font-weight:700;margin-bottom:10px;} |
| .lg-cel-meta{font-size:13px;color:var(--text-dim);margin-top:4px;} |
| .lg-cel-box button{margin-top:16px;} |
| @media (hover:hover) and (pointer:fine){.pr-row:hover{background:rgba(255,255,255,0.08);}} |
| .pr-row,.lb-row,.lg-row,#chatMessages b[data-uid]{-webkit-tap-highlight-color:transparent;touch-action:manipulation;} |
| </style> |
| </head> |
| <body> |
|
|
| <div id="stage"> |
| <div id="gameWrap"> |
| <button id="homeBtn" onclick="confirmHome()">🏠 Home</button> |
|
|
| <div id="ui"> |
| Time: <span id="time">0</span>s<br> |
| Record: <span id="record">0</span>s<br> |
| Balls: <span id="balls">0</span><br> |
| Mode: <span id="mode">-</span> |
| </div> |
|
|
|
|
| <div id="eloTable"> |
| <b>ELO LIVE</b> |
| <div id="eloList"></div> |
| <div style="margin-top:10px;display:flex;flex-direction:column;gap:8px;"> |
| <button style="font-size:13px;padding:6px 10px;" onclick="setTVMode('best')">BEST TV</button> |
| <button style="font-size:13px;padding:6px 10px;" onclick="setTVMode('random')">RANDOM TV</button> |
| <button style="font-size:13px;padding:6px 10px;" onclick="setTVMode('target')">TARGET TV</button> |
| <button style="font-size:13px;padding:6px 10px;" onclick="resumePlay()">▶ RESUME</button> |
| </div> |
| </div> |
|
|
| <div id="menu"> |
| <button id="accountBtn" onclick="openAccountScreen()">👤 Log in / Sign up</button> |
| <button id="newsBtn" onclick="openNews()">📰 News<span class="newsDot" id="newsDot"></span></button> |
| |
| <div id="chatPanel" style="width:320px;height:400px;display:flex;flex-direction:column;background:linear-gradient(160deg, rgba(16,18,26,0.75), rgba(8,9,14,0.8));border:1px solid rgba(255,255,255,0.1);border-left:3px solid var(--neon-cyan);padding:12px;border-radius:12px;font-size:13px;text-align:left;flex-shrink:0;box-shadow:0 10px 26px rgba(0,0,0,0.35);backdrop-filter:blur(8px);"> |
| <b>💬 Global Chat</b> |
| <div id="chatMessages" style="flex:1;overflow-y:auto;margin:8px 0;display:flex;flex-direction:column;gap:6px;color:#8b93a6;">Connecting…</div> |
| <div id="chatLockNote" style="display:none;font-size:12px;color:#ffd700;margin-bottom:6px;cursor:pointer;" onclick="openAccountScreen()">🔒 Create an account to be able to write in the chat. <u>Create account</u></div> |
| <div style="display:flex;gap:6px;"> |
| <input type="text" id="chatInput" maxlength="200" placeholder="Message…" style="flex:1;width:auto;min-width:0;margin:0;padding:8px 10px;font-size:13px;"> |
| <button id="chatSendBtn" onclick="sendChat()" style="margin:0;padding:8px 12px;font-size:13px;">Send</button> |
| </div> |
| </div> |
|
|
| |
|
|
| |
| <div style="display: flex; flex-direction: column; align-items: center; flex-shrink: 0;"> |
| <h1>DarkOnBalls</h1> |
| <div id="menuMain" class="menuPage"> |
| <button onclick="showMenuPage('menuOnline')">🌐 Online</button> |
| <button onclick="showMenuPage('menuOffline')" style="display:none" id="btnOfflineMenu">🎮 Offline</button> |
| <button onclick="openSettings()">⚙️ Settings</button> |
| <button onclick="inviteFriends()">🔗 Invite friends</button> |
| <div id="socialBarMain"> |
| <a id="linkWhatsAppMain" class="socialLink" href="https://chat.whatsapp.com/HA0w5ZkjAt9I5xlHOcFLLh?mode=gi_t" target="_blank" rel="noopener noreferrer" title="Join our WhatsApp group" aria-label="WhatsApp"> |
| <svg viewBox="0 0 32 32" width="30" height="30" aria-hidden="true"> |
| <circle cx="16" cy="16" r="16" fill="#25D366"/> |
| <path d="M16 6.2a9.7 9.7 0 0 0-8.3 14.7L6.3 25.7l5-1.3A9.7 9.7 0 1 0 16 6.2z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/> |
| <path d="M12.6 11.3c.3-.5.7-.5 1-.4.2.5.7 1.7.8 1.9.1.2 0 .4-.1.6l-.5.6c-.1.2-.2.3 0 .6.7 1.2 1.7 2.1 3 2.7.3.1.5.1.6-.1l.7-.9c.2-.2.4-.2.6-.1l1.8.9c.2.1.3.2.3.4 0 .6-.4 1.3-1 1.6-.6.4-1.5.4-2.8-.1-2.4-.9-4.2-2.8-5.2-5-.4-1-.4-1.9.1-2.6z" fill="#fff"/> |
| </svg> |
| <span>WhatsApp</span> |
| </a> |
| <a id="linkDiscordMain" class="socialLink" href="#" target="_blank" rel="noopener noreferrer" title="Join our Discord server" aria-label="Discord" style="display:none"> |
| <svg viewBox="0 0 32 32" width="30" height="30" aria-hidden="true"> |
| <circle cx="16" cy="16" r="16" fill="#5865F2"/> |
| <path d="M22.4 10.3a14 14 0 0 0-3.4-1l-.5 1a13 13 0 0 0-5 0l-.5-1a14 14 0 0 0-3.4 1c-2.2 3.2-2.8 6.4-2.5 9.5a14 14 0 0 0 4.2 2.1l.9-1.5c-.5-.2-1-.4-1.4-.7l.3-.3a10 10 0 0 0 8.8 0l.3.3c-.4.3-.9.5-1.4.7l.9 1.5a14 14 0 0 0 4.2-2.1c.4-3.600-.6-6.800-2.500-9.500z" fill="#fff"/> |
| <ellipse cx="12.700" cy="16.300" rx="1.600" ry="1.800" fill="#5865F2"/> |
| <ellipse cx="19.300" cy="16.300" rx="1.600" ry="1.800" fill="#5865F2"/> |
| </svg> |
| <span>Discord</span> |
| </a> |
| </div> |
| </div> |
| <div id="menuOnline" class="menuPage" style="display:none"> |
| <button onclick="showScreen('onlineFlow')">⚔️ Online 1v1</button> |
| <button onclick="startSurvival()">🏝 Survival</button> |
| <button id="dailyBtn" onclick="startDaily()">📅 Daily Challenge</button> |
| <button onclick="startStreak()">🔥 Streaks</button> |
| <button onclick="startOnlineTV()">📺 Best Online TV</button> |
| <button onclick="showLeagueScreen()">🏅 Weekly League</button> |
| <button onclick="showScreen('leaderboardScreen');lbShowTab('elo')">🏆 Leaderboard</button> |
| <button onclick="showMenuPage('menuMain')">← Back</button> |
| <div id="socialBar"> |
| <a id="linkWhatsApp" class="socialLink" href="https://chat.whatsapp.com/HA0w5ZkjAt9I5xlHOcFLLh?mode=gi_t" target="_blank" rel="noopener noreferrer" title="Join our WhatsApp group" aria-label="WhatsApp"> |
| <svg viewBox="0 0 32 32" width="30" height="30" aria-hidden="true"> |
| <circle cx="16" cy="16" r="16" fill="#25D366"/> |
| <path d="M16 6.2a9.7 9.7 0 0 0-8.3 14.7L6.3 25.7l5-1.3A9.7 9.7 0 1 0 16 6.2z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/> |
| <path d="M12.6 11.3c.3-.5.7-.5 1-.4.2.5.7 1.7.8 1.9.1.2 0 .4-.1.6l-.5.6c-.1.2-.2.3 0 .6.7 1.2 1.7 2.1 3 2.7.3.1.5.1.6-.1l.7-.9c.2-.2.4-.2.6-.1l1.8.9c.2.1.3.2.3.4 0 .6-.4 1.3-1 1.6-.6.4-1.5.4-2.8-.1-2.4-.9-4.2-2.8-5.2-5-.4-1-.4-1.9.1-2.6z" fill="#fff"/> |
| </svg> |
| <span>WhatsApp</span> |
| </a> |
| <a id="linkDiscord" class="socialLink" href="#" target="_blank" rel="noopener noreferrer" title="Join our Discord server" aria-label="Discord" style="display:none"> |
| <svg viewBox="0 0 32 32" width="30" height="30" aria-hidden="true"> |
| <circle cx="16" cy="16" r="16" fill="#5865F2"/> |
| <path d="M22.4 10.3a14 14 0 0 0-3.4-1l-.5 1a13 13 0 0 0-5 0l-.5-1a14 14 0 0 0-3.4 1c-2.2 3.2-2.8 6.4-2.5 9.5a14 14 0 0 0 4.2 2.1l.9-1.5c-.5-.2-1-.4-1.4-.7l.3-.3a10 10 0 0 0 8.8 0l.3.3c-.4.3-.9.5-1.4.7l.9 1.5a14 14 0 0 0 4.2-2.1c.4-3.600-.6-6.800-2.500-9.500z" fill="#fff"/> |
| <ellipse cx="12.700" cy="16.300" rx="1.600" ry="1.800" fill="#5865F2"/> |
| <ellipse cx="19.300" cy="16.300" rx="1.600" ry="1.800" fill="#5865F2"/> |
| </svg> |
| <span>Discord</span> |
| </a> |
| </div> |
| </div> |
| <div id="menuOffline" class="menuPage" style="display:none"> |
| <button onclick="startGame('single')">Singleplayer</button> |
| <button onclick="startGame('multi')">Multiplayer (local)</button> |
| <button onclick="startGame('ai')">AI Spectate</button> |
| <button onclick="startGame('tournament')">1vs1 Tournament</button> |
| <button onclick="startGame('coin')">🪙 Coin Hunt</button> |
| <button onclick="openOfflineSettings()">⚙️ Offline Settings</button> |
| <button onclick="showMenuPage('menuMain')">← Back</button> |
| </div> |
| </div> |
|
|
| |
| <div id="onlinePlayersPanel" style="width: 320px; background: linear-gradient(160deg, rgba(16,18,26,0.75), rgba(8,9,14,0.8)); border: 1px solid rgba(255,255,255,0.1); border-left: 3px solid var(--neon-green); padding: 16px; border-radius: 12px; font-size: 14px; text-align: left; max-height: 400px; overflow-y: auto; flex-shrink: 0; font-family: var(--font-body); box-shadow: 0 10px 26px rgba(0,0,0,0.35); backdrop-filter: blur(8px);"> |
| <b>🟢 Active Players:</b><br> |
| <span style="color: #888;">Connecting presence service...</span> |
| </div> |
| </div> |
|
|
|
|
| <div id="settings"> |
| <h2>⚙️ Settings</h2> |
| <p style="opacity:.6;font-size:13px;margin:-6px 0 18px;">General – applies to all modes</p> |
|
|
| <label>Your Ball Color</label> |
| <div id="ballPreviewWrap"> |
| <canvas id="ballPreviewCanvas" width="90" height="90"></canvas> |
| </div> |
| <div id="ballColorRow"></div> |
| <br> |
|
|
| <label>Ball Trail Effect ✨</label><br> |
| <label class="switch"> |
| <input type="checkbox" id="ballTrailToggle" onchange="setBallTrail(this.checked)"> |
| <span class="slider"></span> |
| </label><br><br> |
|
|
| <label>Language / Sprache 🌍</label><br> |
| <select id="langSelect" onchange="setLanguage(this.value)"> |
| <option value="en">English</option> |
| <option value="de">Deutsch</option> |
| </select><br><br> |
|
|
| <button onclick="showScreen('menu'); showMenuPage('menuMain')">← Back</button> |
| </div> |
|
|
| <div id="offlineSettings"> |
| <h2>🎮 Offline Settings</h2> |
| <p style="opacity:.6;font-size:13px;margin:-6px 0 18px;">Only applies to offline modes (online uses fixed rules)</p> |
|
|
| <label>Start Balls</label> |
| <input id="startBalls" type="number" value="3" min="1" max="20"><br><br> |
| <label>Spawn Time (s)</label> |
| <input id="spawnTime" type="number" value="5" min="1" max="30"><br><br> |
| <label>Ball Speed</label> |
| <select id="ballSpeed"> |
| <option value="0.3">Slow</option> |
| <option value="0.5" selected>Normal</option> |
| <option value="0.8">Fast</option> |
| <option value="1">Extreme</option> |
| </select><br><br> |
| <label>AI Bots</label> |
| <input id="botCount" type="number" value="10" min="2"><br><br> |
| <label>Ranking System</label> |
| <select id="rankingMode"> |
| <option value="points" selected>Points</option> |
| <option value="elo">ELO</option> |
| </select><br><br> |
|
|
| <button onclick="saveSettings()">Save</button> |
| <button onclick="showScreen('menu'); showMenuPage('menuOffline')">← Back</button> |
| </div> |
|
|
| <div id="accountScreen"> |
| <h2>🌐 Online Account</h2> |
| <div id="accountContent"></div> |
| </div> |
|
|
| <div id="leagueScreen"> |
| <h2>🏅 Weekly League</h2> |
| <div id="leagueContent" style="display:flex;flex-direction:column;align-items:center;"></div> |
| <br><button onclick="showScreen('menu'); showMenuPage('menuOnline')">← Back</button> |
| </div> |
|
|
| <div id="leaderboardScreen"> |
| <h2>🏆 Online Leaderboard</h2> |
| <div style="display:flex;gap:8px;margin-bottom:10px;"> |
| <button id="lbTabElo" onclick="lbShowTab('elo')" style="margin:0;padding:8px 18px;font-size:14px;">ELO</button> |
| <button id="lbTabSurv" onclick="lbShowTab('surv')" style="margin:0;padding:8px 18px;font-size:14px;">🏝 Survival</button> |
| <button id="lbTabStreak" onclick="lbShowTab('streak')" style="margin:0;padding:8px 18px;font-size:14px;">🔥 Streaks</button> |
| <button id="lbTabDaily" onclick="lbShowTab('daily')" style="margin:0;padding:8px 18px;font-size:14px;">📅 Daily</button> |
| <button id="lbTabDailyWins" onclick="lbShowTab('dwins')" style="margin:0;padding:8px 18px;font-size:14px;">👑 Daily Wins</button> |
| </div> |
| <div id="lbTable"> |
| <div class="lb-row"><span>Rank</span><span>Name</span><span>ELO</span><span>W/L</span></div> |
| </div> |
| <br><button onclick="showScreen('menu')">← Back</button> |
| </div> |
|
|
| <div id="onlineWait"> |
| <h2>🌐 Online 1v1</h2> |
| <div id="onlineStatus">Searching for opponent…</div> |
|
|
| <div style="font-size:13px;opacity:0.8;margin-top:6px;"> |
| Matchmaking may take up to 30 seconds. If no opponent is available, you'll automatically be paired with our Online Bot. |
| </div> |
| <div id="onlineSearchAnim" style="font-size:40px;margin:10px 0;">⏳</div> |
| <button id="playBotBtn" onclick="playWithBotNow()">🤖 Play with Online Bot</button> |
| <button onclick="cancelOnline()">Cancel</button> |
| </div> |
|
|
| <div id="vsScreen"> |
| <div id="vsContainer"> |
| <div style="text-align:center;"> |
| <div class="vs-name" id="vsName1">-</div> |
| <div class="vs-elo" id="vsElo1">ELO -</div> |
| <div id="vsRank1" style="font-size:18px;color:#ffd700;margin-top:8px;">Rank: -</div> |
| </div> |
| <div class="vs-divider">VS</div> |
| <div style="text-align:center;"> |
| <div class="vs-name" id="vsName2">-</div> |
| <div class="vs-elo" id="vsElo2">ELO -</div> |
| <div id="vsRank2" style="font-size:18px;color:#ffd700;margin-top:8px;">Rank: -</div> |
| </div> |
| </div> |
| <div id="vsCountdown">Starting in 3…</div> |
| </div> |
|
|
|
|
| <div id="winnerOverlay"> |
| <h2 id="winnerText">🏆 Winner!</h2> |
| <p id="winnerEloChange"></p> |
| <div id="winnerLeague" style="margin-top:10px;font-size:22px;font-family:var(--font-display);font-weight:700;"></div> |
| <div id="postChat" style="display:none;flex-direction:column;width:340px;max-width:90vw;margin-top:18px;background:rgba(16,18,26,0.8);border:1px solid var(--line);border-left:3px solid var(--neon-cyan);border-radius:12px;padding:10px;font-size:13px;"> |
| <b>💬 Chat with opponent <span style="color:#8b93a6;font-weight:400;">(not saved)</span></b> |
| <div id="postChatMsgs" style="height:110px;overflow-y:auto;margin:6px 0;display:flex;flex-direction:column;gap:4px;"></div> |
| <div style="display:flex;gap:6px;"> |
| <input type="text" id="postChatInput" maxlength="200" placeholder="Message…" style="flex:1;width:auto;min-width:0;margin:0;padding:7px 9px;font-size:13px;"> |
| <button onclick="sendPostChat()" style="margin:0;padding:7px 12px;font-size:13px;">Send</button> |
| </div> |
| </div> |
| <div style="display:flex;gap:16px;margin-top:30px;" id="winnerButtons"></div> |
| </div> |
|
|
| <div id="gameOver"> |
| <h2>GAME OVER</h2> |
| <p>Survived: <span id="finalTime">0</span>s</p> |
| <button onclick="restartGame()">Play Again</button> |
| <button onclick="exitToMenu()">🏠 Home</button> |
| <div id="ranking" style="position:absolute;top:20px;right:20px;color:white;font-size:18px;text-align:left;max-height:90vh;overflow:auto;"></div> |
| </div> |
|
|
| <div id="eloResult"></div> |
| <canvas id="game"></canvas> |
| <div id="coinHUD">🪙 <span id="coinScore">0</span> | ⏱ <span id="coinTime">60</span>s</div> |
| <div id="pingDisplay">📶 -- ms</div> |
| <div id="onlineHUD"> |
| <div class="online-names"> |
| <div style="display:flex;align-items:center;gap:6px;"> |
| <span id="onlineNameMe" style="color:#00ff88">Me</span> |
| <span id="onlineRankMe" style="color:#ffd700;font-size:13px;">(#-)</span> |
| </div> |
| <span style="color:#aaa">VS</span> |
| <div style="display:flex;align-items:center;gap:6px;"> |
| <span id="onlineNameOpp" style="color:#ff8844">Opp</span> |
| <span id="onlineRankOpp" style="color:#ffd700;font-size:13px;">(#-)</span> |
| </div> |
| </div> |
| |
| <div id="onlineScoreHUD" style="font-family: var(--font-hud); font-size: 22px; font-weight: bold; margin-top: 8px; color: #ffd700; text-align: center; letter-spacing: 1px; text-shadow: 0 0 14px rgba(255,215,0,0.4);">0 - 0</div> |
| <div id="onlineStatusHUD" style="color:#9aa0aa;font-size:12px;margin-top:4px;font-family: var(--font-display); letter-spacing: .5px;">Connecting…</div> |
| </div> |
|
|
|
|
|
|
| <div id="opponentInfo"><div id="opponentText"></div></div> |
| <div id="spectatorInfo"><b>👁 Spectator Mode</b><br><div id="spectatorText"></div></div> |
|
|
| <div id="profileModal"> |
| <div id="profileCard"> |
| <span id="profileClose" onclick="closeProfile()">✕</span> |
| <h2 id="profileName"></h2> |
| <div id="profileStats"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| |
| |
| |
| function screenAspect() { return 16 / 9; } |
| function setOnlineAspect() {} |
| function isRunning() { try { return !!running; } catch (e) { return false; } } |
| |
| const V_W = 1280; |
| const V_H = 720; |
| |
| const stage = document.getElementById("stage"); |
| const gameWrap= document.getElementById("gameWrap"); |
| const canvas = document.getElementById("game"); |
| const ctx = canvas.getContext("2d"); |
| |
| let scale = 1; |
| |
| function W() { return V_W; } |
| function H() { return V_H; } |
| |
| function resize() { |
| const dpr = window.devicePixelRatio || 1; |
| gameWrap.style.width = "100%"; |
| gameWrap.style.height = "100%"; |
| canvas.style.width = "100%"; |
| canvas.style.height = "100%"; |
| canvas.width = Math.round(V_W * dpr); |
| canvas.height = Math.round(V_H * dpr); |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| } |
| |
| function screenToVirtual(clientX, clientY) { |
| const rect = canvas.getBoundingClientRect(); |
| return { |
| x: (clientX - rect.left) * (V_W / rect.width), |
| y: (clientY - rect.top) * (V_H / rect.height) |
| }; |
| } |
| |
| window.addEventListener("resize", resize); |
| resize(); |
| |
| |
| |
| |
| if (!CanvasRenderingContext2D.prototype.roundRect) { |
| CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { |
| this.beginPath(); |
| this.moveTo(x + r, y); |
| this.lineTo(x + w - r, y); |
| this.quadraticCurveTo(x + w, y, x + w, y + r); |
| this.lineTo(x + w, y + h - r); |
| this.quadraticCurveTo(x + w, y + h, x + w - r, y + h); |
| this.lineTo(x + r, y + h); |
| this.quadraticCurveTo(x, y + h, x, y + h - r); |
| this.lineTo(x, y + r); |
| this.quadraticCurveTo(x, y, x + r, y); |
| this.closePath(); |
| }; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| </script> |
|
|
| <script type="module"> |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { getDatabase, ref, set, get, onValue, push, remove, update, serverTimestamp, onDisconnect } |
| from "https://www.gstatic.com/firebasejs/10.12.0/firebase-database.js"; |
| import { query, limitToLast, orderByKey, endBefore } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-database.js"; |
| import { getApp } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js"; |
| import { getAuth } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js"; |
| |
| let db, auth; |
| try { |
| const app = getApp(); |
| db = getDatabase(app); |
| auth = getAuth(app); |
| } catch(e) { |
| console.warn("RPC bridge: Firebase app not available yet", e); |
| } |
| |
| function myUid() { return auth && auth.currentUser ? auth.currentUser.uid : null; } |
| function requireUid() { |
| const uid = myUid(); |
| if (!uid) throw new Error("Not authenticated yet — cannot perform this operation."); |
| return uid; |
| } |
| |
| |
| |
| |
| async function rpcGet(path) { return (await get(ref(db, path))).val(); } |
| |
| function onValueRobust(r, cb) { |
| let got = false, stopped = false, iv = null; |
| const stopPoll = () => { if (iv) { clearInterval(iv); iv = null; } }; |
| const unsub = onValue(r, snap => { got = true; stopPoll(); cb(snap.val()); }, err => console.warn("listener error", err && err.code, err && err.message)); |
| const t = setTimeout(() => { |
| if (got || stopped) return; |
| iv = setInterval(async () => { if (stopped) return; try { const sn = await get(r); if (!stopped) cb(sn.val()); } catch (e) {} }, 3000); |
| }, 6000); |
| return () => { stopped = true; clearTimeout(t); stopPoll(); try { unsub(); } catch (e) {} }; |
| } |
| function rpcOn(path, cb) { return onValueRobust(ref(db, path), cb); } |
| |
| |
| |
| async function rpcSetOwnPlayer(data) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| return set(ref(db, "players/" + uid), data); |
| } |
| async function rpcUpdateOwnPlayer(data) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| return update(ref(db, "players/" + uid), data); |
| } |
| async function rpcRemoveOwnPlayer() { |
| const uid = requireUid(); |
| return remove(ref(db, "players/" + uid)); |
| } |
| async function rpcSetUsernameIndex(name, uid) { |
| |
| const myId = requireUid(); |
| if (uid !== myId) throw new Error("Cannot claim a username for another uid."); |
| return set(ref(db, "usernames/" + name.toLowerCase()), uid); |
| } |
| async function rpcRemoveUsernameIndex(name) { |
| requireUid(); |
| return remove(ref(db, "usernames/" + name.toLowerCase())); |
| } |
| |
| |
| |
| |
| |
| async function rpcSetOwnQueueEntry(data) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| return set(ref(db, "queue/" + uid), data); |
| } |
| async function rpcRemoveOwnQueueEntry() { |
| const uid = requireUid(); |
| return remove(ref(db, "queue/" + uid)); |
| } |
| async function rpcSendInvite(targetUid, invitePayload) { |
| requireUid(); requireNotBanned(); |
| return set(ref(db, "queue/" + targetUid + "_invite"), invitePayload); |
| } |
| async function rpcRemoveOwnInvite() { |
| const uid = requireUid(); |
| return remove(ref(db, "queue/" + uid + "_invite")); |
| } |
| |
| |
| |
| |
| |
| |
| |
| async function rpcCreateMatch(matchId, matchData) { |
| const uid = requireUid(); |
| if (matchData.p1uid !== uid && matchData.p2uid !== uid) { |
| throw new Error("Cannot create a match you are not part of."); |
| } |
| return set(ref(db, "onlineMatches/" + matchId), matchData); |
| } |
| async function rpcUpdateMatch(matchId, data) { |
| requireUid(); |
| return update(ref(db, "onlineMatches/" + matchId), data); |
| } |
| async function rpcSetMatchPath(matchId, subPath, data) { |
| requireUid(); |
| return set(ref(db, "onlineMatches/" + matchId + "/" + subPath), data); |
| } |
| async function rpcRemoveMatch(matchId) { |
| requireUid(); |
| return remove(ref(db, "onlineMatches/" + matchId)); |
| } |
| function rpcOnMatch(matchId, cb) { |
| return onValueRobust(ref(db, "onlineMatches/" + matchId), cb); |
| } |
| async function rpcGetMatch(matchId) { return rpcGet("onlineMatches/" + matchId); } |
| |
| |
| |
| |
| |
| async function rpcSendChallenge(targetUid, payload) { |
| const uid = requireUid(); |
| return set(ref(db, "challenges/" + targetUid + "/" + uid), payload); |
| } |
| async function rpcRespondChallenge(fromUid, payload) { |
| const uid = requireUid(); |
| return set(ref(db, "challenges/" + uid + "/" + fromUid), payload); |
| } |
| async function rpcRemoveChallenge(targetUid, fromUid) { |
| requireUid(); |
| return remove(ref(db, "challenges/" + targetUid + "/" + fromUid)); |
| } |
| async function rpcSetStreakBoard(payload) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| return set(ref(db, "streakBoard/" + uid), { name: String(payload.name), level: Number(payload.level), ts: serverTimestamp() }); |
| } |
| async function rpcSetHeadToHead(uidA, uidB, data) { |
| const uid = requireUid(); |
| if (uid !== uidA && uid !== uidB) throw new Error("Not a participant in this head-to-head record."); |
| const key = [uidA, uidB].sort().join("__VS__"); |
| return set(ref(db, "headToHead/" + key), data); |
| } |
| async function rpcGetHeadToHead(uidA, uidB) { |
| const key = [uidA, uidB].sort().join("__VS__"); |
| return rpcGet("headToHead/" + key); |
| } |
| |
| |
| function rpcOnDisconnectSetOwnStatus(value) { |
| const uid = requireUid(); |
| return onDisconnect(ref(db, "players/" + uid)).update(value); |
| } |
| |
| |
| |
| function rpcArmMatchDisconnect(matchId, payload) { |
| requireUid(); |
| const od = onDisconnect(ref(db, "onlineMatches/" + matchId)); |
| od.update(payload).catch(() => {}); |
| return od; |
| } |
| |
| function rpcServerTimestamp() { return serverTimestamp(); } |
| |
| |
| |
| |
| |
| |
| |
| |
| async function rpcSendChat(name, text) { |
| const uid = requireUid(); |
| return push(ref(db, "chat"), { uid, name, text: String(text).slice(0, 200), ts: Date.now() }); |
| } |
| function rpcOnChat(cb) { |
| return onValue(query(ref(db, "chat"), limitToLast(50)), snap => cb(snap.val())); |
| } |
| |
| async function rpcSetLeagueMember(week, tier, group, data) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(week) || !/^\d$/.test(String(tier)) || !/^[A-Za-z0-9_-]{1,40}$/.test(group)) throw new Error("Bad league path."); |
| return set(ref(db, "weeklyLeague/" + week + "/" + tier + "/" + group + "/" + uid), data); |
| } |
| |
| async function rpcRenameLeagueEntry(week, tier, group, uid, newName) { |
| requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(week) || !/^\d$/.test(String(tier)) || !/^[A-Za-z0-9_-]{1,40}$/.test(group)) return; |
| const r = ref(db, "weeklyLeague/" + week + "/" + tier + "/" + group + "/" + uid); |
| if (!(await get(r)).exists()) return; |
| return update(r, { name: newName }); |
| } |
| |
| async function rpcRenameChatMessages(uid, newName) { |
| requireUid(); |
| const v = (await get(query(ref(db, "chat"), limitToLast(200)))).val() || {}, up = {}; |
| Object.entries(v).forEach(([id, m]) => { if (m && m.uid === uid && m.name !== newName) up["chat/" + id + "/name"] = newName; }); |
| if (Object.keys(up).length) return update(ref(db), up); |
| } |
| |
| async function rpcSaveSurvival(time, replayStr, name) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| return update(ref(db), { |
| ["survival/" + uid]: { name: String(name), time: Number(time), ts: Date.now() }, |
| ["survivalReplays/" + uid]: String(replayStr) |
| }); |
| } |
| |
| async function rpcSaveDaily(day, time, name) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day))) throw new Error("Bad day."); |
| return set(ref(db, "daily/" + day + "/" + uid), { name: String(name), time: Number(time), ts: Date.now() }); |
| } |
| |
| async function rpcSaveDailyReplay(day, str) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day))) throw new Error("Bad day."); |
| return set(ref(db, "dailyReplays/" + uid + "/" + day), String(str)); |
| } |
| async function rpcRemoveDailyReplay(day) { |
| const uid = requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day))) throw new Error("Bad day."); |
| return remove(ref(db, "dailyReplays/" + uid + "/" + day)); |
| } |
| |
| async function rpcClaimDailyWin(day) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day))) throw new Error("Bad day."); |
| return set(ref(db, "dailyWins/" + uid + "/" + day), true); |
| } |
| |
| |
| async function rpcModSetDailyPick(day, idx) { |
| requireAdmin(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day))) throw new Error("Bad day."); |
| if (idx === null) return remove(ref(db, "dailyPick/" + day)); |
| idx = Number(idx); if (!Number.isInteger(idx) || idx < 0 || idx > 500) throw new Error("Bad mode."); |
| return set(ref(db, "dailyPick/" + day), idx); |
| } |
| async function rpcModDeleteAllDaily() { requireAdmin(); return update(ref(db), { daily: null, dailyReplays: null }); } |
| |
| function _tvKey(k) { if (!/^[A-Za-z0-9_-]{1,80}$/.test(String(k))) throw new Error("Bad viewer key."); return String(k); } |
| async function rpcSetTvViewing(key) { const uid = requireUid(); const r = ref(db, "tvViewers/" + _tvKey(key) + "/" + uid); onDisconnect(r).remove(); return set(r, true); } |
| async function rpcClearTvViewing(key) { const uid = requireUid(); return remove(ref(db, "tvViewers/" + _tvKey(key) + "/" + uid)); } |
| |
| async function rpcSetSurvivalLive(data) { requireNotBanned(); const uid = requireUid(); return set(ref(db, "survivalLive/" + uid), data); } |
| function rpcArmSurvivalLiveDisconnect() { const uid = requireUid(); return onDisconnect(ref(db, "survivalLive/" + uid)).remove(); } |
| async function rpcModRemoveLive(uid) { requireAdmin(); if (!/^[A-Za-z0-9_-]{6,128}$/.test(uid)) throw new Error("Bad uid"); return remove(ref(db, "survivalLive/" + uid)); } |
| async function rpcModSetIdle(uid) { requireAdmin(); if (!/^[A-Za-z0-9_-]{6,128}$/.test(uid)) throw new Error("Bad uid"); return update(ref(db, "players/" + uid), { status: "Idle in Menu" }); } |
| async function rpcRemoveSurvivalLive() { const uid = requireUid(); return remove(ref(db, "survivalLive/" + uid)); } |
| |
| async function rpcRemoveLeagueMember(week, tier, group) { |
| const uid = requireUid(); |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(week) || !/^\d$/.test(String(tier)) || !/^[A-Za-z0-9_-]{1,40}$/.test(group)) throw new Error("Bad league path."); |
| return remove(ref(db, "weeklyLeague/" + week + "/" + tier + "/" + group + "/" + uid)); |
| } |
| |
| async function rpcGetChatBefore(beforeKey, n) { |
| const snap = await get(query(ref(db, "chat"), orderByKey(), endBefore(beforeKey), limitToLast(n || 50))); |
| return snap.val(); |
| } |
| |
| async function rpcNewsPush(data) { requireUid(); return push(ref(db, "news"), data); } |
| async function rpcNewsRemove(id) { |
| requireUid(); |
| await remove(ref(db, "news/" + id)); |
| try { await remove(ref(db, "newsReactions/" + id)); } catch (e) {} |
| } |
| |
| |
| async function rpcNewsReact(newsId, value) { |
| requireNotBanned(); |
| const uid = requireUid(); |
| if (!/^[A-Za-z0-9_-]{1,40}$/.test(String(newsId))) throw new Error("Bad news id."); |
| const r = ref(db, "newsReactions/" + newsId + "/" + uid); |
| if (value === "up" || value === "down") return set(r, value); |
| return remove(r); |
| } |
| |
| const MOD_ADMIN_UID_RPC = "6O1c9OKTRlbwzDlPuBG2ceMRdz63"; |
| function requireNotBanned() { if (window.__dobBanned) throw new Error("Account banned."); } |
| function requireAdmin() { const u = requireUid(); if (u !== MOD_ADMIN_UID_RPC) throw new Error("Admin only."); return u; } |
| async function rpcModSetSanction(uid, data) { requireAdmin(); return update(ref(db, "sanctions/" + uid), data); } |
| async function rpcModClearSanction(uid) { requireAdmin(); return remove(ref(db, "sanctions/" + uid)); } |
| async function rpcModDeleteChat(id) { requireAdmin(); return remove(ref(db, "chat/" + id)); } |
| async function rpcModRename(uid, oldName, newName) { |
| requireAdmin(); |
| const up = {}; |
| up["players/" + uid + "/name"] = newName; |
| if (oldName) up["usernames/" + String(oldName).toLowerCase()] = null; |
| up["usernames/" + String(newName).toLowerCase()] = uid; |
| return update(ref(db), up); |
| } |
| |
| async function rpcModPurgeUser(uid, name) { |
| requireAdmin(); |
| uid = String(uid || ""); |
| if (!/^[A-Za-z0-9_-]{6,128}$/.test(uid) || uid === MOD_ADMIN_UID_RPC) throw new Error("Bad uid."); |
| const paths = new Set(["players/" + uid, "survival/" + uid, "survivalReplays/" + uid, "streakBoard/" + uid, "survivalLive/" + uid, |
| "queue/" + uid, "queue/" + uid + "_invite", "challenges/" + uid]); |
| const safe = async (fn) => { try { return await fn(); } catch (e) { return null; } }; |
| const [un, wl, ch, h2h, chat] = await Promise.all([ |
| safe(async () => (await get(ref(db, "usernames"))).val()), |
| safe(async () => (await get(ref(db, "weeklyLeague"))).val()), |
| safe(async () => (await get(ref(db, "challenges"))).val()), |
| safe(async () => (await get(ref(db, "headToHead"))).val()), |
| safe(async () => (await get(query(ref(db, "chat"), limitToLast(1000)))).val()) |
| ]); |
| if (name) paths.add("usernames/" + String(name).toLowerCase()); |
| Object.entries(un || {}).forEach(([k, v]) => { if (v === uid) paths.add("usernames/" + k); }); |
| Object.entries(wl || {}).forEach(([w, tiers]) => Object.entries(tiers || {}).forEach(([t, groups]) => Object.entries(groups || {}).forEach(([g, members]) => { if (members && members[uid]) paths.add("weeklyLeague/" + w + "/" + t + "/" + g + "/" + uid); }))); |
| Object.entries(ch || {}).forEach(([target, from]) => { if (from && from[uid]) paths.add("challenges/" + target + "/" + uid); }); |
| Object.keys(h2h || {}).forEach(k => { if (k.indexOf(uid) !== -1) paths.add("headToHead/" + k); }); |
| Object.entries(chat || {}).forEach(([id, m]) => { if (m && m.uid === uid) paths.add("chat/" + id); }); |
| const list = [...paths], failed = []; |
| const res = await Promise.allSettled(list.map(pth => remove(ref(db, pth)))); |
| res.forEach((r, i) => { if (r.status === "rejected") failed.push(list[i]); }); |
| return { total: list.length, failed }; |
| } |
| async function rpcModApplyLeague(updates) { |
| requireAdmin(); |
| const keys = Object.keys(updates || {}); |
| if (!keys.length || keys.length > 3000) throw new Error("Bad league update."); |
| for (const k of keys) if (!/^players\/[A-Za-z0-9_-]{6,128}\/(leagueTier|leagueBest|leagueWeek|leagueGroup|leagueLastResult)$/.test(k)) throw new Error("Bad path: " + k); |
| return update(ref(db), updates); |
| } |
| async function rpcModRestoreUser(uid, backup) { |
| requireAdmin(); |
| uid = String(uid || ""); |
| if (!/^[A-Za-z0-9_-]{6,128}$/.test(uid) || !backup) throw new Error("Nothing to restore."); |
| const up = {}; |
| if (backup.player) { up["players/" + uid] = backup.player; if (backup.player.name) up["usernames/" + String(backup.player.name).toLowerCase()] = uid; } |
| if (backup.survival) up["survival/" + uid] = backup.survival; |
| if (backup.streak) up["streakBoard/" + uid] = backup.streak; |
| return update(ref(db), up); |
| } |
| window._dobRPC = { |
| saveSurvival: rpcSaveSurvival, saveDaily: rpcSaveDaily, modDeleteAllDaily: rpcModDeleteAllDaily, modSetDailyPick: rpcModSetDailyPick, claimDailyWin: rpcClaimDailyWin, saveDailyReplay: rpcSaveDailyReplay, removeDailyReplay: rpcRemoveDailyReplay, setTvViewing: rpcSetTvViewing, clearTvViewing: rpcClearTvViewing, setSurvivalLive: rpcSetSurvivalLive, armSurvivalLiveDisconnect: rpcArmSurvivalLiveDisconnect, removeSurvivalLive: rpcRemoveSurvivalLive, renameLeagueEntry: rpcRenameLeagueEntry, renameChatMessages: rpcRenameChatMessages, |
| modSetSanction: rpcModSetSanction, modClearSanction: rpcModClearSanction, modDeleteChat: rpcModDeleteChat, modRename: rpcModRename, modPurgeUser: rpcModPurgeUser, modApplyLeague: rpcModApplyLeague, modRemoveLive: rpcModRemoveLive, modSetIdle: rpcModSetIdle, modRestoreUser: rpcModRestoreUser, |
| newsPush: rpcNewsPush, newsRemove: rpcNewsRemove, newsReact: rpcNewsReact, |
| myUid, requireUid, |
| get: rpcGet, on: rpcOn, |
| setOwnPlayer: rpcSetOwnPlayer, updateOwnPlayer: rpcUpdateOwnPlayer, removeOwnPlayer: rpcRemoveOwnPlayer, |
| setUsernameIndex: rpcSetUsernameIndex, removeUsernameIndex: rpcRemoveUsernameIndex, |
| setOwnQueueEntry: rpcSetOwnQueueEntry, removeOwnQueueEntry: rpcRemoveOwnQueueEntry, |
| sendInvite: rpcSendInvite, removeOwnInvite: rpcRemoveOwnInvite, |
| createMatch: rpcCreateMatch, updateMatch: rpcUpdateMatch, setMatchPath: rpcSetMatchPath, |
| removeMatch: rpcRemoveMatch, onMatch: rpcOnMatch, getMatch: rpcGetMatch, |
| setHeadToHead: rpcSetHeadToHead, getHeadToHead: rpcGetHeadToHead, |
| setStreakBoard: rpcSetStreakBoard, sendChallenge: rpcSendChallenge, respondChallenge: rpcRespondChallenge, removeChallenge: rpcRemoveChallenge, |
| onDisconnectSetOwnStatus: rpcOnDisconnectSetOwnStatus, |
| serverTimestamp: rpcServerTimestamp, |
| sendChat: rpcSendChat, onChat: rpcOnChat, getChatBefore: rpcGetChatBefore, setLeagueMember: rpcSetLeagueMember, removeLeagueMember: rpcRemoveLeagueMember, armMatchDisconnect: rpcArmMatchDisconnect |
| }; |
| window.dispatchEvent(new Event("dobRPCReady")); |
| </script> |
|
|
| <script> |
| // ═══════════════════════════════════════════════ |
| // WAIT FOR AUTH + RPC BRIDGE TO BE READY |
| // ═══════════════════════════════════════════════ |
| |
| // ═══ Eigene Verbindung überwachen ═══ |
| (function () { |
| const bar = document.createElement("div"); |
| bar.id = "connBanner"; |
| bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:9999;display:none;padding:8px;text-align:center;font:700 14px sans-serif;background:#c0392b;color:#fff;"; |
| bar.textContent = "⚠ Connection lost – reconnecting… (leaving a running match counts as a forfeit)"; |
| document.body.appendChild(bar); |
| let wasUp = false, downTimer = null; |
| function set(up) { |
| if (up) { clearTimeout(downTimer); downTimer = null; bar.style.display = "none"; wasUp = true; return; } |
| if (!wasUp) return; // erster Status vor dem Verbinden ignorieren |
| downTimer = setTimeout(() => { bar.style.display = "block"; }, 1500); // kurze Aussetzer nicht anzeigen |
| } |
| window.addEventListener("offline", () => set(false)); |
| window.addEventListener("online", () => { /* Firebase meldet den Rest */ }); |
| const iv = setInterval(() => { // warten bis die Firebase-Brücke bereit ist |
| if (!window._dobRPC || !window.dobIsReady || !window.dobIsReady()) return; |
| clearInterval(iv); |
| try { window._dobRPC.on(".info/connected", v => set(v === true)); } catch (e) {} |
| }, 500); |
| })(); |
| |
| let fbReady = false; // kept for minimal diff with old code: true once we can talk to the DB AS our own uid |
| let RPC = null; |
| let myUid = null; |
| |
| function _tryActivate() { |
| if (window._dobRPC && window.dobIsReady && window.dobIsReady()) { |
| RPC = window._dobRPC; |
| myUid = window.dobMyUid(); |
| if (!fbReady) { |
| fbReady = true; |
| onFirebaseAndAuthReady(); |
| startChatListener(); |
| } |
| } |
| } |
| window.addEventListener("dobRPCReady", _tryActivate); |
| window.addEventListener("dobAuthReady", _tryActivate); |
| // Also poll briefly in case both events already fired before listeners attached |
| let _activatePoll = setInterval(() => { |
| _tryActivate(); |
| if (fbReady) clearInterval(_activatePoll); |
| }, 150); |
| |
| async function onFirebaseAndAuthReady() { |
| // CHANGED: we now have a real, server-verified uid (myUid) instead |
| // of trusting a client-chosen name as the database key. |
| if (onlineAccount) { |
| if (window._dobAuth && window._dobAuth.isPasswordAccount()) markDeviceAccount(onlineAccount.name); |
| await pullAccountFromServer(); // erst Serverstand holen, damit ein veralteter lokaler Stand nichts ueberschreibt |
| if (readActiveMatchMarker()) { // last session ended in the middle of an online match |
| const mk0 = readActiveMatchMarker(); |
| clearActiveMatchMarker(); |
| const loss = applyForfeitLoss(mk0 && mk0.loss); |
| try { leagueToast(tr("Match abandoned – penalty applied") + " (−" + loss + " ELO)", "#ff4444"); } catch (e) {} |
| } |
| await pushAccountToFirebase(); |
| |
| // Automatically go offline if the connection drops or tab closes — |
| // now scoped to OUR OWN uid-keyed node only. |
| RPC.onDisconnectSetOwnStatus({ status: "offline" }); |
| |
| await updatePlayerStatus("Idle in Menu"); |
| leagueResolvePrevious().catch(() => {}); |
| } |
| startMenuPresenceListener(); |
| } |
| |
| // ───────────────────────────────────────────── |
| // dbGet/dbOn-style read helpers retained under the SAME names used |
| // throughout the rest of the file below, but now backed by the |
| // guarded RPC bridge instead of raw global Firebase handles. This |
| // keeps the diff in the game logic itself minimal while removing the |
| // vulnerability (no function here can write to an arbitrary path). |
| // ───────────────────────────────────────────── |
| async function dbGet(path) { return RPC ? RPC.get(path) : null; } |
| function dbOn(path, cb) { return RPC ? RPC.on(path, cb) : (() => {}); } |
| |
| async function getHeadToHeadScore(uidA, uidB) { |
| const score = await RPC.getHeadToHead(uidA, uidB); |
| return score || { [uidA]: 0, [uidB]: 0 }; |
| } |
| |
| // Bot-Score lag bisher nur im localStorage -> Zuschauer (Online-TV) sahen bei Bot-Matches immer 0-0. |
| // Jetzt wird er zusaetzlich nach headToHead/{myUid VS bot} gespiegelt (Rules erlauben das). |
| function syncBotH2H(botScore) { |
| try { |
| if (!fbReady || !myUid || !botScore) return; |
| RPC.setHeadToHead(myUid, ONLINE_BOT_UID, { [myUid]: botScore.me || 0, [ONLINE_BOT_UID]: botScore.bot || 0 }) |
| .catch(e => console.warn("bot h2h sync failed", e)); |
| } catch (e) {} |
| } |
| |
| async function recordHeadToHeadWin(winnerUid, loserUid, roundKey) { |
| const score = await getHeadToHeadScore(winnerUid, loserUid); |
| // Idempotent: both players may call this; the result of one round is counted only once. |
| if (roundKey && score._last === roundKey) return score; |
| if (roundKey) score._last = roundKey; |
| score[winnerUid] = (score[winnerUid] || 0) + 1; |
| score[loserUid] = score[loserUid] || 0; |
| await RPC.setHeadToHead(winnerUid, loserUid, score); |
| return score; |
| } |
| |
| // Aktualisiert den Aktivitätsstatus des Spielers in Firebase |
| // CHANGED: writes only to players/{myUid} via the guarded RPC, never |
| // to an arbitrary name-keyed path. |
| async function updatePlayerStatus(statusText) { |
| if (!fbReady || !onlineAccount) return; |
| await RPC.updateOwnPlayer({ |
| status: statusText, |
| lastActive: Date.now() |
| }); |
| } |
| |
| // Live-Listener für alle Online-Spieler im Menü |
| // CHANGED: players are now keyed by uid; the displayed name is read |
| // from the `name` field inside each record. We compare against |
| // `myUid` (not a name) to detect "is this me". |
| let menuPresenceUnsubscribe = null; |
| function startMenuPresenceListener() { |
| if (menuPresenceUnsubscribe) { menuPresenceUnsubscribe(); menuPresenceUnsubscribe = null; } |
| if (!fbReady) return; |
| |
| if (!window._presenceTick) window._presenceTick = setInterval(() => { if (window._lastPlayersData) renderPresence(window._lastPlayersData); }, 15000); |
| menuPresenceUnsubscribe = dbOn("players", (playersData) => { window._lastPlayersData = playersData; renderPresence(playersData); recolorChatNames(); }); |
| } |
| // Haengt die Spielerliste >10 s bei "Connecting…", den Grund anzeigen statt ewig zu warten |
| setTimeout(async () => { |
| const panel = document.getElementById("onlinePlayersPanel"); |
| if (!panel || window._lastPlayersData) return; |
| let why; |
| if (!fbReady) why = "No connection to the server (sign-in not completed)."; |
| else { try { await RPC.get("players/" + myUid); why = "Connected, but the player list does not arrive."; } catch (e) { why = "Server error: " + (e.code || e.message); } } |
| if (!window._lastPlayersData) panel.innerHTML = "<b>🟢 Active Players:</b><br><span style='color:#ff8844;font-size:12px;'>⚠ " + why + " Reload the page.</span>"; |
| }, 10000); |
| const _liveTrack = {}; // uid -> { sig, at, absent } |
| let _liveTrackUnsub = null; |
| function _liveAlive(uid) { |
| const o = _liveTrack[uid], now = Date.now(); |
| if (!o) return !(window._liveReady && now - window._liveReady > 10000); // nie einen Live-Eintrag gesehen -> tot (nach kurzer Wartezeit) |
| if (o.absent) return now - o.absent < 10000 && now - (o.at || 0) < 30000; // Eintrag fehlt seit <10 s (Start/Wechsel) |
| return now - o.at < 30000; // Eintrag aendert sich noch |
| } |
| function _startLiveTrack() { |
| if (_liveTrackUnsub || !fbReady || !RPC) return; |
| _liveTrackUnsub = dbOn("survivalLive", (d) => { |
| d = d || {}; const now = Date.now(); |
| if (!window._liveReady) window._liveReady = now; |
| for (const [uid, v] of Object.entries(d)) { |
| const sig = String(v && v.x) + ":" + String(v && v.y) + ":" + String(v && v.bx) + ":" + String(v && v.by) + ":" + String(v && v.b); |
| const o = _liveTrack[uid]; |
| if (!o) _liveTrack[uid] = { sig, at: now }; |
| else if (o.sig !== sig) _liveTrack[uid] = { sig, at: now }; |
| else if (o.absent) _liveTrack[uid] = { sig, at: o.at, last: o.at }; // neu angelegt, aber gleicher eingefrorener Zustand |
| } |
| for (const uid of Object.keys(_liveTrack)) if (!d[uid] && !_liveTrack[uid].absent) { const o = _liveTrack[uid]; _liveTrack[uid] = { absent: now, sig: o.sig, at: o.at }; } |
| if (window._lastPlayersData) renderPresence(window._lastPlayersData); |
| }); |
| } |
| function renderPresence(playersData) { |
| _startLiveTrack(); |
| { |
| const panel = document.getElementById("onlinePlayersPanel"); |
| if (!panel) return; |
| |
| if (!playersData) { |
| panel._lastHtml = null; panel.innerHTML = "<b>🟢 Active Players:</b><br><span style='color:#888; font-size:12px;'>No active players</span>"; |
| return; |
| } |
| |
| // Dedupe: gleicher Name = ein Eintrag (eigene UID hat Vorrang, sonst der aktuellste) |
| const _seen = new Map(); |
| Object.entries(playersData) |
| .map(([uid, p]) => ({ uid, ...p })) |
| .filter(p => |
| p.status && p.status !== "offline" && (Date.now() - (p.lastActive || 0) < 120000) |
| ) |
| .forEach(p => { |
| if ((p.status === "Survival" || p.status === "Daily" || p.status === "Playing Streaks") && p.uid !== myUid && !_liveAlive(p.uid)) p.status = "Idle in Menu"; p._ghost = true; // Geist: Stream tot |
| const key = String(p.name || "").toLowerCase(); |
| const prev = _seen.get(key); |
| const better = !prev |
| || p.uid === myUid |
| || (prev.uid !== myUid && (p.lastActive || 0) > (prev.lastActive || 0)); |
| if (better) _seen.set(key, p); |
| }); |
| const activeList = [..._seen.values()]; |
| |
| if (activeList.length === 0) { |
| panel._lastHtml = null; panel.innerHTML = "<b>🟢 Active Players:</b><br><span style='color:#888; font-size:12px;'>No other players online</span>"; |
| return; |
| } |
| |
| let html = "<b>🟢 Active Players:</b><div style='margin-top: 8px; display:flex; flex-direction:column; gap:6px;'>"; |
| activeList.forEach(p => { |
| const isMe = myUid && p.uid === myUid; |
| const _busy = statusInMatch(p.status); |
| const _pend = challengeOut.has(p.uid), _other = challengeOut.size > 0 && !_pend; |
| const _tvBtn = (!isMe && fbReady && _busy) |
| ? `<button class="ch-btn" onclick="event.stopPropagation(); watchPlayerTV('${escapeHtml(p.uid)}')" title="Watch live">📺</button>` |
| : ""; |
| const _chBtn = (!isMe && onlineAccount && fbReady) |
| ? (_pend |
| ? `<button class="ch-btn" onclick="event.stopPropagation(); cancelChallenge('${escapeHtml(p.uid)}')" title="Cancel challenge">⏳</button>` |
| : `<button class="ch-btn${(_busy || _other) ? ' ch-busy' : ''}" ${(_busy || _other) ? 'disabled' : ''} onclick="event.stopPropagation(); challengePlayer('${escapeHtml(p.uid)}')" title="${_other ? 'You already have an open challenge' : (_busy ? 'Currently in a match' : 'Challenge to a 1v1')}">⚔</button>`) |
| : ""; |
| html += ` |
| <div style="font-size: 13px; display: flex; justify-content: space-between; align-items:center; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 4px; cursor:pointer;" |
| class="pr-row" |
| onclick="openOnlineProfile('${escapeHtml(p.uid)}')" title="View profile"> |
| <span style="color: ${isMe ? '#00ff88' : '#fff'}; font-weight: ${isMe ? 'bold' : 'normal'};">${escapeHtml(dn(p.name))}${isMe ? ' (You)' : ''}</span> |
| <span style="display:flex;align-items:center;"><span style="color: #ffd700; font-style: italic;">${escapeHtml(p.status)}</span>${_tvBtn}${_chBtn}</span> |
| </div> |
| `; |
| }); |
| html += "</div>"; |
| if (panel._lastHtml !== html) { panel._lastHtml = html; panel.innerHTML = html; } |
| } |
| } |
| |
| |
| // ═══════════════════════════════════════════════ |
| // 1v1-HERAUSFORDERUNGEN (aus der Spielerliste) |
| // Popup bei dem Herausgeforderten: Name, ELO, Platzierung, ✓ annehmen / ✗ ablehnen. |
| // Wird IMMER angezeigt, ausser der Spieler ist gerade in einem 1v1-Match oder im Survival. |
| // ═══════════════════════════════════════════════ |
| const CHALLENGE_TTL = 45000; |
| const challengeIn = new Map(); // fromUid -> { name, elo, ts, aspect, recv } |
| const challengeOut = new Map(); // targetUid -> { unsub, timer } |
| const challengeLastSent = {}; |
| let challengeUnsub = null, challengeShown = null; |
| |
| function statusInMatch(st) { |
| st = String(st || ""); |
| return st === "Survival" || st === "Daily" || st === "Playing Streaks" || st === "In 1v1 Match" || st.indexOf("1v1 vs") === 0; |
| } |
| function inMatchOrSurvival() { |
| try { return statusInMatch(currentPresenceStatus()); } catch (e) { return false; } |
| } |
| function challengeRankOf(uid, name) { |
| const data = window._lastPlayersData; |
| if (!data) return null; |
| const seen = new Map(); |
| Object.entries(data).map(([u, p]) => ({ uid: u, ...p })) |
| .filter(p => ((Number(p.wins) || 0) + (Number(p.losses) || 0)) >= 1) |
| .forEach(p => { |
| const key = String(p.name || "").toLowerCase(), prev = seen.get(key); |
| const games = q => (Number(q.wins) || 0) + (Number(q.losses) || 0); |
| if (!prev || p.uid === myUid || (prev.uid !== myUid && games(p) > games(prev))) seen.set(key, p); |
| }); |
| const sorted = [...seen.values()].sort((a, b) => (Number(b.elo) || 1500) - (Number(a.elo) || 1500)); |
| let i = sorted.findIndex(p => p.uid === uid); |
| if (i < 0 && name) i = sorted.findIndex(p => String(p.name || "").toLowerCase() === String(name).toLowerCase()); |
| return i >= 0 ? i + 1 : null; |
| } |
| function challengePrepareForMatch() { |
| try { cleanupOnline(); } catch (e) {} |
| try { clearAllTimers(); } catch (e) {} |
| try { running = false; } catch (e) {} |
| } |
| function challengeToast(txt, color) { try { leagueToast(tr(txt), color); } catch (e) {} } |
| |
| // ── Senden ── |
| async function challengePlayer(targetUid) { |
| if (!fbReady || !myUid || !onlineAccount) { challengeToast("Log in to challenge players", "#ff8844"); return; } |
| if (!targetUid || targetUid === myUid) return; |
| if (inMatchOrSurvival()) { challengeToast("You are in a match right now", "#ff8844"); return; } |
| if (challengeOut.size > 0) { challengeToast("You already have an open challenge", "#ff8844"); return; } |
| const now = Date.now(); |
| if (challengeLastSent[targetUid] && now - challengeLastSent[targetUid] < 15000) { challengeToast("Challenge already sent – wait a moment", "#ff8844"); return; } |
| const t = (window._lastPlayersData || {})[targetUid]; |
| if (t && statusInMatch(t.status)) { challengeToast("Player is in a match right now", "#ff8844"); return; } |
| challengeLastSent[targetUid] = now; |
| const tname = (t && t.name) || "player"; |
| // Sofort reservieren (vor dem await), damit schnelles Doppeltippen keine zweite Herausforderung startet |
| const entry = { unsub: () => {}, timer: null, finish: null }; |
| const finish = () => { try { entry.unsub(); } catch (e) {} clearTimeout(entry.timer); challengeOut.delete(targetUid); RPC.removeChallenge(targetUid, myUid).catch(() => {}); challengeRefreshList(); }; |
| entry.finish = finish; |
| challengeOut.set(targetUid, entry); |
| challengeRefreshList(); |
| try { |
| await RPC.sendChallenge(targetUid, { name: onlineAccount.name, elo: onlineAccount.elo, ts: now, status: "pending", aspect: screenAspect() }); |
| } catch (e) { |
| console.warn("challenge send failed", e); |
| challengeOut.delete(targetUid); challengeRefreshList(); |
| challengeToast("Could not send challenge", "#ff4444"); |
| return; |
| } |
| challengeToast("⚔ Challenge sent to " + tname, "#ffd700"); |
| entry.unsub = dbOn("challenges/" + targetUid + "/" + myUid, async (d) => { |
| if (!d) return; |
| if (d.status === "declined") { finish(); challengeToast(tname + " declined the challenge", "#ff4455"); return; } |
| if (d.status === "accepted" && d.matchId) { |
| finish(); |
| if (onlineMatchId || inMatchOrSurvival()) return; // inzwischen woanders im Match |
| try { |
| const md = await RPC.getMatch(d.matchId); |
| if (!md) return; |
| challengePrepareForMatch(); |
| joinMatch(d.matchId, { uid: targetUid, name: d.name || tname, elo: d.elo }, md); |
| } catch (e) { console.warn("challenge join failed", e); } |
| } |
| }); |
| entry.timer = setTimeout(() => { finish(); challengeToast(tname + " did not respond", "#888"); }, CHALLENGE_TTL + 3000); |
| } |
| function challengeRefreshList() { try { if (window._lastPlayersData) renderPresence(window._lastPlayersData); } catch (e) {} } |
| function cancelChallenge(targetUid) { |
| const e = challengeOut.get(targetUid); |
| if (!e) return; |
| if (e.finish) e.finish(); |
| challengeToast("Challenge cancelled", "#888"); |
| } |
| |
| // ── Empfangen ── |
| function startChallengeListener() { |
| if (challengeUnsub || !fbReady || !myUid || !onlineAccount) return; |
| challengeUnsub = dbOn("challenges/" + myUid, (data) => { |
| const now = Date.now(), seen = new Set(); |
| if (data) Object.entries(data).forEach(([uid, c]) => { |
| if (!c || c.status !== "pending") return; |
| if (!c.ts || Math.abs(now - c.ts) > 120000) { RPC.removeChallenge(myUid, uid).catch(() => {}); return; } // alt/verwaist |
| seen.add(uid); |
| const prev = challengeIn.get(uid); |
| if (!prev || prev.ts !== c.ts) challengeIn.set(uid, { ...c, recv: now }); |
| }); |
| for (const k of [...challengeIn.keys()]) if (!seen.has(k)) challengeIn.delete(k); |
| renderChallengeBox(); |
| }); |
| } |
| function stopChallengeListener() { |
| if (challengeUnsub) { try { challengeUnsub(); } catch (e) {} challengeUnsub = null; } |
| challengeIn.clear(); renderChallengeBox(); |
| } |
| function hideChallengeBox() { const b = document.getElementById("challengeBox"); if (b) b.remove(); challengeShown = null; } |
| |
| function renderChallengeBox() { |
| if (!challengeUnsub && fbReady && myUid && onlineAccount) startChallengeListener(); |
| if (challengeUnsub && !onlineAccount) { stopChallengeListener(); return; } |
| const now = Date.now(); |
| for (const [k, c] of challengeIn) { |
| if (now - c.recv > CHALLENGE_TTL) { challengeIn.delete(k); RPC.removeChallenge(myUid, k).catch(() => {}); } |
| } |
| if (inMatchOrSurvival() || onlineMatchId && !onlineResultHandled) { hideChallengeBox(); return; } |
| let pick = null; |
| for (const [k, c] of challengeIn) if (!pick || c.recv < pick.c.recv) pick = { k, c }; |
| if (!pick) { hideChallengeBox(); return; } |
| let box = document.getElementById("challengeBox"); |
| if (!box || challengeShown !== pick.k + ":" + pick.c.ts) { |
| hideChallengeBox(); |
| const live = (window._lastPlayersData || {})[pick.k] || {}; |
| const name = live.name || pick.c.name || "Player"; |
| const elo = Math.round(Number(live.elo != null ? live.elo : pick.c.elo) || 1500); |
| const rank = challengeRankOf(pick.k, name); |
| box = document.createElement("div"); |
| box.id = "challengeBox"; |
| box.innerHTML = '<div class="ch-title"></div><div class="ch-row"><div class="ch-info"><div class="ch-name"></div><div class="ch-elo"><b></b><i></i></div></div>' + |
| '<button class="ch-yes" title="Accept">✓</button><button class="ch-no" title="Decline">✗</button></div><div class="ch-bar"><i></i></div>'; |
| box.querySelector(".ch-title").textContent = "⚔ " + tr("1v1 CHALLENGE"); |
| box.querySelector(".ch-name").textContent = dn(name); |
| box.querySelector(".ch-elo b").textContent = "ELO " + elo; |
| box.querySelector(".ch-elo i").textContent = rank ? "#" + rank : ""; |
| box.querySelector(".ch-yes").addEventListener("click", () => acceptChallenge(pick.k)); |
| box.querySelector(".ch-no").addEventListener("click", () => declineChallenge(pick.k)); |
| document.body.appendChild(box); |
| challengeShown = pick.k + ":" + pick.c.ts; |
| } |
| const bar = box.querySelector(".ch-bar i"); |
| if (bar) bar.style.width = Math.max(0, 100 - (now - pick.c.recv) / CHALLENGE_TTL * 100) + "%"; |
| } |
| setInterval(renderChallengeBox, 250); |
| |
| async function declineChallenge(fromUid) { |
| const c = challengeIn.get(fromUid); |
| challengeIn.delete(fromUid); hideChallengeBox(); |
| if (!c || !myUid || !onlineAccount) return; |
| try { await RPC.respondChallenge(fromUid, { name: onlineAccount.name, elo: onlineAccount.elo, ts: Date.now(), status: "declined" }); } catch (e) { console.warn(e); } |
| setTimeout(() => RPC.removeChallenge(myUid, fromUid).catch(() => {}), 8000); |
| } |
| |
| async function acceptChallenge(fromUid) { |
| const c = challengeIn.get(fromUid); |
| if (!c || !myUid || !onlineAccount) return; |
| if (inMatchOrSurvival() || onlineMatchId && !onlineResultHandled) { challengeToast("You are in a match right now", "#ff8844"); return; } |
| challengeIn.delete(fromUid); hideChallengeBox(); |
| try { |
| const matchId = [myUid, fromUid].sort().join("__VS__"); |
| const existing = await RPC.getMatch(matchId); |
| if (existing && (existing.status === "waiting" || existing.status === "playing")) { |
| const alive = Math.max(Number(existing.ping_p1) || 0, Number(existing.ping_p2) || 0, Number(existing.createdAt) || 0); |
| if (Date.now() - alive < 30000) { |
| challengeToast("A match between you two is already running", "#ff8844"); |
| RPC.respondChallenge(fromUid, { name: onlineAccount.name, elo: onlineAccount.elo, ts: Date.now(), status: "declined" }).catch(() => {}); |
| return; |
| } |
| } |
| const live = (window._lastPlayersData || {})[fromUid] || {}; |
| const oppName = live.name || c.name, oppElo = Number(live.elo != null ? live.elo : c.elo) || 1500; |
| const isP1 = myUid < fromUid; |
| const matchData = { |
| p1uid: isP1 ? myUid : fromUid, p2uid: isP1 ? fromUid : myUid, |
| p1: isP1 ? onlineAccount.name : oppName, p2: isP1 ? oppName : onlineAccount.name, |
| p1elo: isP1 ? onlineAccount.elo : oppElo, p2elo: isP1 ? oppElo : onlineAccount.elo, |
| status: "waiting", winner: null, createdAt: Date.now(), |
| arena: Math.min(screenAspect(), Number(c.aspect) || 16 / 9), |
| seed: Math.floor(Math.random() * 1000000) |
| }; |
| await RPC.createMatch(matchId, matchData); |
| await RPC.respondChallenge(fromUid, { name: onlineAccount.name, elo: onlineAccount.elo, ts: Date.now(), status: "accepted", matchId }); |
| setTimeout(() => RPC.removeChallenge(myUid, fromUid).catch(() => {}), 20000); |
| challengePrepareForMatch(); |
| joinMatch(matchId, { uid: fromUid, name: oppName, elo: oppElo }, matchData); |
| } catch (e) { |
| console.warn("accept challenge failed", e); |
| challengeToast("Could not start the match", "#ff4444"); |
| } |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // ACCOUNT |
| // ═══════════════════════════════════════════════ |
| // ═══ GLOBAL CHAT (Text wird per textContent gesetzt -> kein XSS) ═══ |
| let chatUnsub = null, lastChatSent = 0, chatFirst = true, chatSentTimes = [], lastChatText = ""; |
| // Einfacher Wortfilter (Liste nach Bedarf erweitern) + Links werden entfernt |
| const CHAT_BAD_WORDS = ["fuck","shit","bitch","nigg","fotze","hurensohn","wichser","arschloch","schlampe"]; |
| function chatFilter(t) { |
| t = t.replace(/(https?:\/\/|www\.)\S+/gi, "[link]"); |
| for (const w of CHAT_BAD_WORDS) t = t.replace(new RegExp(w, "gi"), m => "*".repeat(m.length)); |
| return t; |
| } |
| // Verbotene Namen (leicht erweiterbar). Leetspeak (h1tl3r, h!tler …) wird erkannt. |
| const NAME_BLOCK = ["hitler","nazi","hakenkreuz","goebbels","himmler","1488","nigg"]; |
| function nameIsBlocked(n) { |
| const raw = String(n || "").toLowerCase().replace(/[^a-z0-9]/g, ""); |
| const norm = String(n || "").toLowerCase().replace(/[@4]/g, "a").replace(/3/g, "e").replace(/[1!|]/g, "i") |
| .replace(/0/g, "o").replace(/[5$]/g, "s").replace(/7/g, "t").replace(/[^a-z0-9]/g, ""); |
| return NAME_BLOCK.some(w => raw.includes(w) || norm.includes(w)); |
| } |
| let chatAll = {}; // alle bisher gesehenen Nachrichten (Push-Key -> Nachricht) |
| let chatLoadingOlder = false, chatNoMoreOlder = false; |
| const CHAT_DEV_NAME = "DarkOnCrack"; // bekommt das [DEV]-Label überall |
| function dn(name) { return name === CHAT_DEV_NAME ? "[DEV] " + name : name; } |
| |
| // Chat name color = exact color of the sender's current league (own league from the account, others from the live players data) |
| function chatLeagueColor(uid) { |
| let t = 0; |
| if (uid && myUid && uid === myUid && onlineAccount) t = onlineAccount.leagueTier || 0; |
| else if (uid && window._lastPlayersData && window._lastPlayersData[uid]) t = window._lastPlayersData[uid].leagueTier || 0; |
| return leagueByTier(t).color; |
| } |
| function chatLeagueTier(uid) { |
| if (uid && myUid && uid === myUid && onlineAccount) return Math.max(0, Math.min(3, onlineAccount.leagueTier || 0)); |
| if (uid && window._lastPlayersData && window._lastPlayersData[uid]) return Math.max(0, Math.min(3, window._lastPlayersData[uid].leagueTier || 0)); |
| return 0; |
| } |
| function chatStyleName(el, uid) { |
| const t = chatLeagueTier(uid); |
| el.style.color = chatLeagueColor(uid); |
| el.classList.remove("nm-t1", "nm-t2", "nm-t3"); |
| if (t > 0) el.classList.add("nm-t" + t); |
| } |
| function recolorChatNames() { |
| document.querySelectorAll("#chatMessages b[data-uid]").forEach(el => { chatStyleName(el, el.dataset.uid); }); |
| } |
| function _modIsAdminSafe(){ try { return typeof _modIsAdmin === "function" && _modIsAdmin(); } catch(e){ return false; } } |
| function renderChat(keepScroll) { |
| const box = document.getElementById("chatMessages"); |
| if (!box) return; |
| const prevH = box.scrollHeight, prevTop = box.scrollTop; |
| const atBottom = prevH - prevTop - box.clientHeight < 40; |
| const _realBox = box, _frag = document.createElement("div"); |
| const list = Object.entries(chatAll).sort((a, b) => (a[1].ts || 0) - (b[1].ts || 0) || (a[0] < b[0] ? -1 : 1)); |
| if (!list.length) { _realBox._sig = null; _realBox.textContent = "No messages yet."; return; } |
| if (chatNoMoreOlder) { |
| const top = document.createElement("div"); |
| top.style.cssText = "font-size:10px;color:#8b93a6;text-align:center;"; |
| top.textContent = "— Beginning of chat —"; |
| _frag.appendChild(top); |
| } |
| let prevSender = null; |
| for (const [mkey, m] of list) { |
| const d = new Date(m.ts || 0); |
| const row = document.createElement("div"); |
| // thin separator line between messages, but not between consecutive messages of the same user |
| const sender = m.uid || m.name; |
| if (prevSender !== null && sender !== prevSender) { |
| row.style.cssText = "border-top:1px solid rgba(255,255,255,0.10);padding-top:6px;"; |
| } |
| prevSender = sender; |
| const meta = document.createElement("div"); |
| meta.style.cssText = "font-size:10px;color:#8b93a6;"; |
| meta.textContent = d.toLocaleDateString("en-GB") + " " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); |
| const nm = document.createElement("b"); |
| nm.setAttribute("translate", "no"); |
| nm.textContent = m.name + ": "; |
| nm.dataset.uid = m.uid || ""; |
| chatStyleName(nm, m.uid); |
| if (m.uid) { |
| nm.style.cursor = "pointer"; |
| nm.title = "View profile"; |
| if (window.matchMedia && matchMedia("(hover:hover) and (pointer:fine)").matches) { |
| nm.onmouseenter = () => nm.style.textDecoration = "underline"; |
| nm.onmouseleave = () => nm.style.textDecoration = "none"; |
| } |
| nm.onclick = () => openOnlineProfile(m.uid); |
| } |
| const tx = document.createElement("span"); |
| tx.setAttribute("translate", "no"); |
| tx.textContent = m.text; |
| tx.style.cssText = "color:#eaf2fb;word-break:break-word;"; |
| if (m.name === CHAT_DEV_NAME) { |
| const tag = document.createElement("b"); |
| tag.textContent = "[DEV] "; |
| tag.title = "Developer"; |
| tag.style.cssText = "color:#ff4d6d;text-shadow:0 0 8px rgba(255,77,109,0.55);letter-spacing:.5px;"; |
| row.append(meta, tag, nm, tx); |
| } else row.append(meta, nm, tx); |
| if (typeof _modIsAdmin === "function" && _modIsAdmin()) { |
| const mb = document.createElement("button"); mb.textContent = "🛡"; mb.title = "Mod actions for this player"; |
| mb.style.cssText = "margin:0 0 0 8px;padding:0 5px;font-size:11px;"; mb.onclick = () => openModPanel(m.name); |
| const db_ = document.createElement("button"); db_.textContent = "🗑"; db_.title = "Delete message"; |
| db_.style.cssText = "margin:0 0 0 4px;padding:0 5px;font-size:11px;"; db_.onclick = () => modDeleteChatMsg(mkey); |
| row.append(mb, db_); |
| } |
| _frag.appendChild(row); |
| } |
| const _sig = _frag.innerHTML + (_modIsAdminSafe() ? "|a" : ""); |
| if (_realBox._sig === _sig) return; // nichts geändert -> DOM nicht anfassen (sonst gehen Taps auf iPad verloren) |
| _realBox._sig = _sig; |
| box.replaceChildren(..._frag.childNodes); |
| if (keepScroll) box.scrollTop = prevTop + (box.scrollHeight - prevH); // beim Nachladen oben: Position halten |
| else if (atBottom || chatFirst) box.scrollTop = box.scrollHeight; |
| chatFirst = false; |
| } |
| |
| async function loadOlderChat() { |
| if (chatLoadingOlder || chatNoMoreOlder || !RPC) return; |
| const keys = Object.keys(chatAll).sort(); |
| if (!keys.length) return; |
| chatLoadingOlder = true; |
| try { |
| const older = await RPC.getChatBefore(keys[0], 50); |
| if (!older || !Object.keys(older).length) chatNoMoreOlder = true; |
| else Object.assign(chatAll, older); |
| renderChat(true); |
| } catch (e) { console.warn("older chat failed", e); } |
| chatLoadingOlder = false; |
| } |
| |
| function startChatListener() { |
| if (chatUnsub || !RPC) return; |
| const box = document.getElementById("chatMessages"); |
| if (box) box.addEventListener("scroll", () => { if (box.scrollTop < 30) loadOlderChat(); }); |
| chatUnsub = RPC.onChat(msgs => { |
| { // vom Mod gelöschte Nachrichten auch aus dem lokalen Verlauf entfernen |
| const cur = msgs || {}, ks = Object.keys(cur).sort(), minK = ks.length >= 50 ? ks[0] : ""; |
| for (const k of Object.keys(chatAll)) if (!(k in cur) && k >= minK) delete chatAll[k]; |
| } |
| if (msgs) Object.assign(chatAll, msgs); // Live-Fenster (letzte 50) in den Gesamtverlauf mischen |
| renderChat(false); |
| }); |
| } |
| async function sendChat() { |
| const inp = document.getElementById("chatInput"); |
| const text = inp.value.trim(); |
| if (!text) return; |
| if (!onlineAccount) { updateChatLock(); return; } |
| if (typeof _modChatBlock === "function" && _modChatBlock()) { _modChatLock(); return; } |
| if (!fbReady || Date.now() - lastChatSent < 1000) return; |
| const now = Date.now(); |
| chatSentTimes = chatSentTimes.filter(t => now - t < 10000); |
| if (chatSentTimes.length >= 5) { inp.placeholder = "Slow down…"; return; } // max. 5 Nachrichten / 10 s |
| let clean = text.replace(/[\u0000-\u001f\u200b-\u200f\u202a-\u202e]/g, "").replace(/\s{3,}/g, " ").slice(0, 200); |
| if (!clean.trim()) return; |
| if (clean === lastChatText && now - lastChatSent < 15000) return; // gleiche Nachricht nicht wiederholen |
| clean = chatFilter(clean); |
| lastChatText = clean; chatSentTimes.push(now); |
| lastChatSent = now; |
| inp.value = ""; |
| try { await RPC.sendChat(onlineAccount.name, clean); } catch(e) { console.warn("chat failed", e); } |
| } |
| document.getElementById("chatInput").addEventListener("keydown", e => { if (e.key === "Enter") sendChat(); }); |
| |
| // ═══ UI wird durchsichtig, wenn Maus/Finger in der Nähe ist ═══ |
| (function () { |
| const ids = ["ui","onlineHUD","coinHUD","pingDisplay","opponentInfo","spectatorInfo"], M = 60; |
| function check(x, y) { |
| for (const id of ids) { |
| const el = document.getElementById(id); |
| if (!el) continue; |
| const r = el.getBoundingClientRect(); |
| if (!r.width) continue; |
| const near = x > r.left - M && x < r.right + M && y > r.top - M && y < r.bottom + M; |
| el.style.opacity = near ? "0.12" : "1"; |
| } |
| } |
| document.addEventListener("mousemove", e => check(e.clientX, e.clientY)); |
| document.addEventListener("touchmove", e => { const t = e.touches[0]; if (t) check(t.clientX, t.clientY); }, { passive: true }); |
| })(); |
| |
| function escapeHtml(v) { |
| return String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); |
| } |
| |
| // Presence: der Status wird bei JEDER Änderung (Menü, Suche, Match, TV, Offline-Modus) sofort geschrieben |
| // (Prüfung alle 2s) und zusätzlich alle 30s als Heartbeat, damit der Eintrag nicht nach 2 Min verschwindet. |
| function currentPresenceStatus() { |
| const modeNames = { single: "Singleplayer", multi: "Local Multiplayer", ai: "AI Spectate", tournament: "Tournament", coin: "Coin Hunt", streak: "Streaks" }; |
| try { |
| if (document.hidden && (survivalActive || (mode === "streak" && document.body.classList.contains("ingame")))) return "Idle in Menu"; |
| if (survivalActive) return _dailyKey ? "Daily" : "Survival"; |
| // Hauptmenue sichtbar = nicht im Spiel, auch wenn alte Match-Variablen (running/onlineMatchId) noch gesetzt sind |
| const _m = document.getElementById("menu"); |
| if (_m && _m.style.display === "flex") return "Idle in Menu"; |
| if (mode === "streak" && document.body.classList.contains("ingame")) return "Playing Streaks"; |
| if (running && !(mode === "online" && onlineResultHandled)) { |
| if (mode === "online") { |
| if (onlineVsBot) return "1v1 vs Bot"; |
| return onlineOpponent && onlineOpponent.name ? "1v1 vs " + onlineOpponent.name : "In 1v1 Match"; |
| } |
| if (mode === "online_tv") return "Watching Online TV"; |
| return "Playing " + (modeNames[mode] || mode); |
| } |
| // VS-Screen vor dem Start zaehlt noch als Match; nach dem Ergebnis (onlineResultHandled) nicht mehr |
| if (onlineMatchId && !onlineResultHandled) return onlineVsBot ? "1v1 vs Bot" : "In 1v1 Match"; |
| if (onlineSearchInterval) return "Searching for match"; |
| } catch (e) {} |
| return "Idle in Menu"; |
| } |
| let _lastPresence = "", _lastPresenceAt = 0; |
| function syncPresence(force) { |
| if (!fbReady || !onlineAccount) return; |
| const s = currentPresenceStatus(), now = Date.now(); |
| if (!force && s === _lastPresence && now - _lastPresenceAt < 30000) return; |
| _lastPresence = s; _lastPresenceAt = now; |
| updatePlayerStatus(s).catch(() => {}); |
| } |
| setInterval(() => syncPresence(false), 2000); |
| document.addEventListener("visibilitychange", () => { if (!document.hidden) { try { const m = document.getElementById("menu"); if (m && m.style.display === "flex" && !onlineMatchId) pullAccountFromServer(); } catch (e) {} } if (!document.hidden) syncPresence(true); else { try { syncPresence(true); } catch (e) {} try { if (fbReady && myUid && (_skLiveOn || (typeof mode !== "undefined" && (mode === "survival" || mode === "streak")))) RPC.removeSurvivalLive().catch(() => {}); } catch (e) {} } }); |
| window.addEventListener("pagehide", () => { try { if (fbReady && myUid) RPC.removeSurvivalLive().catch(() => {}); } catch (e) {} }); |
| |
| |
| // Watchdog: Streak/Survival, in dem sich 30 s (sichtbarer Tab) nichts bewegt (Spieler + Baelle), wird abgebrochen und geloescht |
| (function () { |
| let sig = "", still = 0, last = Date.now(); |
| setInterval(() => { |
| const now = Date.now(), dt = now - last; last = now; |
| let solo = false; |
| try { solo = !!survivalActive || (mode === "streak" && streak && streak.phase !== "over" && document.body.classList.contains("ingame")); } catch (e) {} |
| if (!solo || document.hidden) { still = 0; sig = ""; return; } |
| let cur = ""; |
| try { cur = (players || []).map(p => Math.round(p.x) + "," + Math.round(p.y)).join(";") + "|" + (balls || []).map(b => Math.round(b.x) + "," + Math.round(b.y)).join(";"); } catch (e) {} |
| if (cur !== sig) { sig = cur; still = 0; return; } |
| still += Math.min(dt, 5000); |
| if (still >= 30000) { |
| still = 0; |
| try { if (fbReady && myUid) { RPC.removeSurvivalLive().catch(() => {}); updatePlayerStatus("Idle in Menu").catch(() => {}); } } catch (e) {} |
| try { exitToMenu(); } catch (e) { location.reload(); } |
| } |
| }, 2000); |
| })(); |
| |
| // ═══ ZUSCHAUER-ANZEIGE (klein, unten rechts) ═══ |
| // Spieler (1v1 / Survival) sehen, wie viele gerade zuschauen; TV-Zuschauer tragen sich selbst ein und sehen die Gesamtzahl. |
| // Schluessel: 1v1 = Match-ID, Survival = "surv_" + UID des Spielers. |
| let _viewKey = null, _viewUnsub = null, _viewWrite = false; |
| function viewerBadge(n) { |
| let e = document.getElementById("viewerBadge"); |
| if (n === null) { if (e) e.remove(); return; } |
| if (!e) { |
| e = document.createElement("div"); e.id = "viewerBadge"; |
| e.style.cssText = "position:fixed;right:10px;bottom:calc(10px + env(safe-area-inset-bottom,0px));z-index:140;pointer-events:none;padding:6px 14px;border-radius:12px;background:rgba(12,12,18,.75);border:1px solid rgba(255,255,255,.2);color:#f0f0f5;font:700 16px system-ui,sans-serif;opacity:.92;"; |
| e.setAttribute("translate", "no"); |
| document.body.appendChild(e); |
| } |
| e.textContent = "👁 " + n; |
| } |
| function stopViewerCount() { |
| if (_viewUnsub) { try { _viewUnsub(); } catch (e) {} _viewUnsub = null; } |
| if (_viewKey && _viewWrite && fbReady && myUid) { try { RPC.clearTvViewing(_viewKey).catch(() => {}); } catch (e) {} } |
| _viewKey = null; _viewWrite = false; viewerBadge(null); |
| } |
| function watchViewerCount(key, imViewer) { |
| stopViewerCount(); |
| if (!key || !fbReady) return; |
| _viewKey = String(key); _viewWrite = !!imViewer; |
| if (_viewWrite && myUid) { try { RPC.setTvViewing(_viewKey).catch(e => console.warn("tvViewers: Eintragen fehlgeschlagen (Firebase Rules mit tvViewers veroeffentlicht?)", e)); } catch (e) {} } |
| viewerBadge(0); |
| _viewUnsub = dbOn("tvViewers/" + _viewKey, (d) => { viewerBadge(d ? Object.keys(d).length : 0); }); |
| } |
| |
| // ═══ GAST-LIMIT: ohne Account darf jeder Modus 3x gespielt werden, danach kommt das Account-Popup ═══ |
| const GUEST_LIMIT = 3, GUEST_KEY = "dobGuestPlays"; |
| function myPlayName() { return onlineAccount ? onlineAccount.name : "Guest"; } |
| function guestPlaysRead() { try { return JSON.parse(localStorage.getItem(GUEST_KEY) || "{}") || {}; } catch (e) { return {}; } } |
| function guestGate(key) { |
| if (onlineAccount) return true; |
| const o = guestPlaysRead(), n = Number(o[key]) || 0; |
| if (n >= GUEST_LIMIT) { showGuestLimit(); return false; } |
| o[key] = n + 1; |
| try { localStorage.setItem(GUEST_KEY, JSON.stringify(o)); } catch (e) {} |
| return true; |
| } |
| function showGuestLimit() { |
| if (document.getElementById("guestLimit")) return; |
| const ov = document.createElement("div"); |
| ov.id = "guestLimit"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:2000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);"; |
| ov.innerHTML = '<div style="max-width:380px;background:#14141e;border:1px solid rgba(255,215,0,0.5);border-radius:16px;padding:26px 30px;text-align:center;color:#f0f0f5;font-family:var(--font-display);box-shadow:0 14px 50px rgba(0,0,0,.65);">' + |
| '<div style="font-size:22px;font-weight:700;margin-bottom:10px;color:#ffd700;">Create an account</div>' + |
| '<div style="font-size:15px;line-height:1.5;margin-bottom:18px;opacity:.9;">You have played this mode ' + GUEST_LIMIT + ' times as a guest. Create a free account to keep playing.</div>' + |
| '<button id="glCreate" style="margin:4px 6px;">Create account</button><button id="glBack" style="margin:4px 6px;">Back</button></div>'; |
| document.body.appendChild(ov); |
| ov.querySelector("#glCreate").onclick = () => { ov.remove(); showCreateAccount(); }; |
| ov.querySelector("#glBack").onclick = () => ov.remove(); |
| } |
| |
| // ═══ SURVIVAL (online, solo) ═══ |
| // Gleiche Baelle/Spawnrate/Steuerung wie im 1v1, aber ohne Gegner. Zeit = Ticks/60. |
| // Replay: Spielerposition alle 3 Ticks + Ball-Spawns (Tick, Position, Geschwindigkeit). Die Baelle bewegen |
| // sich deterministisch, deshalb reicht das, um den Lauf exakt nachzuspielen (nur der Rekord wird gespeichert). |
| let survivalActive = false, survTick = 0, survRec = null, survHudIv = null, _survInfo = null; |
| function survFmt(t) { |
| const m = Math.floor(t / 60), s = t - m * 60; |
| return m ? m + ":" + (s < 10 ? "0" : "") + s.toFixed(1) : s.toFixed(1) + "s"; |
| } |
| let _dailyKey = null; |
| // Tages-Challenges. Gruppen-Felder: n=Startanzahl, v=Tempo (px/Tick), r=[min,max] Radius, h=Verfolger (Lenkung rad/Tick), |
| // life=Lebensdauer in Ticks (60/s), respawn=Ticks zwischen Nachspawns, cap=max. gleichzeitig (Standard n), |
| // stagger=Startbaelle unterschiedlich alt, rot=Drehung der Flugrichtung pro Tick (rotRand=zufaellige Richtung), |
| // acc/accMax=Beschleunigung pro Tick / Hoechsttempo, g=Schwerkraft, grow/rMax=Radiuswachstum pro Tick / Endradius, |
| // emerge=Ticks bis der Ball auf Endgroesse waechst, from="top"|"echo" (Spawnort), delay=Echo-Verzoegerung in Ticks, |
| // col=Farbe, burst={n,v,r,life,col} (Feuerwerk beim Ablaufen). |
| // Verfolger laufen wie im Streak-Modus ab (9 s = 540 Ticks) und spawnen alle 1,5 s (90 Ticks) nach. |
| const DAILY_CHALLENGES = [ |
| { icon: "🐌", name: "70 Slow Balls", groups: [{ n: 70, v: 1.6 }] }, |
| { icon: "🪨", name: "Giants", groups: [{ n: 0, cap: 99999, respawn: 120, v: 3.2, r: [55, 70] }] }, // startet leer, alle 2 s (120 Ticks) kommt ein Riese dazu, keiner verschwindet |
| { icon: "🐝", name: "80 Tiny Swarm", playerR: 8, groups: [{ n: 80, v: 3, r: [8, 8] }] }, // playerR: dein Ball ist genau so gross wie die anderen |
| { icon: "🔫", name: "8 Bullets", groups: [{ n: 8, v: 11, r: [10, 14] }] }, |
| { icon: "🌋", name: "Meteor Shower", groups: [{ n: 0, cap: 15, respawn: 20, life: 420, from: "top", v: 2, acc: 0.014, accMax: 9, r: [12, 20], col: "#ff7a3d" }] }, |
| // Echo Trail: startet mit 0 Baellen, nur blaue Echo-Baelle, die nie verschwinden (cap unbegrenzt, kein life) |
| { icon: "👣", name: "Echo Trail", groups: [{ n: 0, cap: 99999, respawn: 55, from: "echo", delay: 150, emerge: 40, v: 1.5, r: [17, 21], col: "#5ec8ff" }] }, |
| // Overdrive: 5 Baelle, Tempo waechst ohne Obergrenze (accMax riesig) |
| { icon: "🚀", name: "Overdrive", groups: [{ n: 5, v: 1.8, acc: 0.0018, accMax: 1e9, col: "#ffd24a" }] }, |
| // Fireworks: Spawnabstand wird mit jedem Feuerwerk ganz langsam kuerzer (-0.5 Tick pro Spawn, bis min. 10 Ticks), kein Limit gleichzeitiger Feuerwerke |
| { icon: "🎆", name: "Fireworks", groups: [{ n: 0, cap: 99999, respawn: 90, respawnGrow: -0.5, respawnMin: 10, life: 130, v: 2.8, r: [11, 13], col: "#ffe14d", burst: { n: 9, v: 3.6, r: [8, 10], life: 260, col: "#ff5fa2" } }] } |
| ]; |
| // ── Welcher Modus ist an welchem Tag dran? ── |
| // 1) Mod-Festlegung dailyPick/{tag} (Index) hat Vorrang. 2) Sonst Zufall (pro Tag fest per Seed, fuer alle Spieler gleich), |
| // der nie einen Modus der letzten 14 Tage nimmt (Festlegungen des Mods zaehlen als "dran gewesen"). |
| let _dailyPicks = {}; // aus der Datenbank geladen: { "2026-10-06": 3, ... } |
| function _dayAdd(key, n) { return new Date(Date.parse(key + "T12:00:00Z") + n * 86400000).toISOString().slice(0, 10); } |
| const _dailyIdxCache = {}; |
| function dailyIdxFor(key, depth) { |
| depth = depth || 0; |
| const N = DAILY_CHALLENGES.length, ov = _dailyPicks[key]; |
| if (Number.isInteger(ov) && ov >= 0 && ov < N) return ov; |
| const ck = key + "|" + JSON.stringify(_dailyPicks); |
| if (_dailyIdxCache[ck] !== undefined) return _dailyIdxCache[ck]; |
| const recent = new Set(); |
| const WIN = Math.min(14, N - 1); // bei wenigen Modi kann das Fenster nicht groesser als N-1 sein |
| if (depth < 16) for (let i = 1; i <= WIN; i++) recent.add(dailyIdxFor(_dayAdd(key, -i), depth + 1)); // Tiefe begrenzt die Rekursion |
| let cand = []; for (let i = 0; i < N; i++) if (!recent.has(i)) cand.push(i); |
| if (!cand.length) cand = DAILY_CHALLENGES.map((_, i) => i); |
| const r = dailySeed("pick|" + key) % cand.length; |
| return (_dailyIdxCache[ck] = cand[r]); |
| } |
| let _dailyTest = null; // nur Admin: Testlauf eines beliebigen Modus (wird nie gespeichert) |
| function dailyChallengeFor(key) { return DAILY_CHALLENGES[dailyIdxFor(key)]; } |
| function dailyActiveCh() { return _dailyTest || dailyChallengeFor(_dailyKey); } |
| async function dailyPicksRefresh() { |
| try { const v = (typeof RPC !== "undefined" && RPC) ? await RPC.get("dailyPick") : null; _dailyPicks = {}; if (v) for (const k of Object.keys(v)) if (Number.isInteger(Number(v[k]))) _dailyPicks[k] = Number(v[k]); } catch (e) { console.warn("dailyPick load failed", e); } |
| try { updDailyBtn(); } catch (e) {} |
| } |
| async function startDaily() { await Promise.race([dailyPicksRefresh(), new Promise(r => setTimeout(r, 2500))]); startSurvival(true); } |
| setInterval(dailyPicksRefresh, 60000); setTimeout(dailyPicksRefresh, 1500); setTimeout(dailyPicksRefresh, 5000); |
| let _dailyRun = null; |
| // ── Licht-Modi: Sichtbarkeit (0..1) eines Balls, gleiche Funktion live und im Replay ── |
| let _dailyLight = null; |
| function dailyVis(L, b, px, py, tick) { |
| const torch = (R) => { const d = Math.hypot(b.x - px, b.y - py) - b.radius; return d <= R * 0.7 ? 1 : d >= R ? 0 : (R - d) / (R * 0.3); }; |
| if (L.type === "torch") return torch(L.r); |
| if (L.type === "storm") { |
| const ph = (tick + L.period - 90) % L.period; // erster Blitz nach 1,5 s |
| const fl = ph < L.flash ? (ph < L.flash * 0.6 ? 1 : (L.flash - ph) / (L.flash * 0.4)) : 0; |
| return Math.max(torch(L.near), fl); |
| } |
| return 1; |
| } |
| function dailyLightFx(c, L, px, py, tick, Wd, Hd) { // Lichtschein um den Ball bzw. Blitz-Aufhellung |
| if (L.type === "torch") { |
| const g = c.createRadialGradient(px, py, 0, px, py, L.r); g.addColorStop(0, "rgba(255,244,200,0.16)"); g.addColorStop(1, "rgba(255,244,200,0)"); |
| c.fillStyle = g; c.fillRect(px - L.r, py - L.r, L.r * 2, L.r * 2); |
| } else if (L.type === "storm") { |
| const ph = (tick + L.period - 90) % L.period; |
| if (ph < L.flash) { c.globalAlpha = 0.16 * (ph < L.flash * 0.6 ? 1 : (L.flash - ph) / (L.flash * 0.4)); c.fillStyle = "#cfe3ff"; c.fillRect(0, 0, Wd, Hd); c.globalAlpha = 1; } |
| } |
| } |
| function dailyMakeBall(g, gi, T, initial) { |
| const rng = Math.random, me = players[0]; |
| const cx = me ? me.x : W() / 2, cy = me ? me.y : H() / 2, mr = me ? me.radius : 12; |
| const r = g.r ? g.r[0] + rng() * (g.r[1] - g.r[0]) : 14 + rng() * 10; |
| let x = null, y = null, vx, vy, tries = 0; |
| const a = rng() * Math.PI * 2; |
| vx = Math.cos(a) * g.v; vy = Math.sin(a) * g.v; |
| if (g.from === "top") { |
| do { x = r + rng() * (W() - 2 * r); } while (Math.abs(x - cx) < mr + r + 140 && tries++ < 40); |
| y = r; vx = (rng() - 0.5) * 2; vy = g.v; |
| } else if (g.from === "echo") { // Spawn an der Stelle, an der du vor g.delay Ticks warst |
| const P = survRec && survRec.p, k = Math.floor((T - (g.delay || 150)) / 3); |
| if (P && k >= 0 && P[2 * k + 1] !== undefined) { x = P[2 * k]; y = P[2 * k + 1]; } |
| } |
| if (x === null && g.from === "near") { // direkt in deiner Naehe (130-230 px), wachsen dort aus dem Nichts |
| tries = 0; |
| do { const an = rng() * Math.PI * 2, d = 130 + rng() * 100; x = Math.min(W() - r, Math.max(r, cx + Math.cos(an) * d)); y = Math.min(H() - r, Math.max(r, cy + Math.sin(an) * d)); } while (Math.hypot(x - cx, y - cy) < 110 && tries++ < 30); |
| } |
| if (x === null) { |
| x = cx; y = cy; tries = 0; |
| do { x = r + rng() * (W() - 2 * r); y = r + rng() * (H() - 2 * r); } while (Math.hypot(x - cx, y - cy) < mr + r + 220 && tries++ < 200); |
| } |
| const b = { x, y, radius: r, vx, vy, gi }; |
| if (g.h) { b.chase = true; b.dch = { spd: g.v, turn: g.h }; } // chase: orange Optik wie im Streak-Modus |
| if (g.rot) b.rot = g.rot * (g.rotRand && rng() < 0.5 ? -1 : 1); |
| if (g.acc) { b.acc = g.acc; b.accMax = g.accMax; } |
| if (g.g) b.grav = g.g; |
| if (g.grow) { b.grow = g.grow; b.rMax = g.rMax; } |
| if (g.emerge) { b.grow = (r - 4) / g.emerge; b.rMax = r; b.radius = 4; } |
| if (g.life) { |
| let age = 0; |
| if (initial && g.stagger) age = Math.floor(rng() * g.life * 0.7); |
| b.exp = T + g.life - age; |
| if (g.grow && age) b.radius = Math.min(g.rMax, r + g.grow * age); |
| } |
| if (g.col) b.col = g.col; |
| if (g.burst) b.burst = g.burst; |
| return b; |
| } |
| function makeDailyBalls(ch) { |
| _dailyRun = { ch, next: ch.groups.map(g => g.respawn || 0), cnt: ch.groups.map(() => 0) }; |
| const out = []; |
| ch.groups.forEach((g, gi) => { for (let i = 0; i < g.n; i++) out.push(dailyMakeBall(g, gi, 0, true)); }); |
| return out; |
| } |
| // Spawn ins Replay schreiben: [tick, x, y, vx, vy, r, huntTurn, huntSpd, lebensdauerTicks, extras] |
| function dailyRec(b, T) { |
| if (!survRec) return; |
| const e = [T, +b.x.toFixed(3), +b.y.toFixed(3), +b.vx.toFixed(6), +b.vy.toFixed(6), +b.radius.toFixed(2), b.dch ? b.dch.turn : 0, b.dch ? b.dch.spd : 0, b.exp ? b.exp - T : 0]; // genau genug, damit das Replay nicht wegdriftet |
| const x = [b.rot || 0, b.acc || 0, b.accMax || 0, b.grav || 0, b.grow || 0, b.rMax || 0, b.col || 0]; |
| if (x.some(Boolean)) e.push(x); |
| survRec.s.push(e); |
| } |
| // pro Tick (live UND Replay identisch): Richtungsdrehung, Beschleunigung, Schwerkraft, Wachstum |
| function dailyAdv(b) { |
| if (b.rot) { const c = Math.cos(b.rot), sn = Math.sin(b.rot), nx = b.vx * c - b.vy * sn; b.vy = b.vx * sn + b.vy * c; b.vx = nx; } |
| if (b.acc) { const sp = Math.hypot(b.vx, b.vy); if (sp > 0 && sp < b.accMax) { const f = Math.min(b.accMax / sp, 1 + b.acc); b.vx *= f; b.vy *= f; } } |
| if (b.grav) b.vy += b.grav; |
| if (b.grow && b.radius < b.rMax) b.radius = Math.min(b.rMax, b.radius + b.grow); |
| } |
| // pro Tick (nur live): abgelaufene Baelle entfernen (+Feuerwerk), Gruppen wie im Streak-Modus nachspawnen |
| function dailyTick() { |
| if (!_dailyRun) return; |
| const T = survTick, ch = _dailyRun.ch; |
| for (let i = balls.length - 1; i >= 0; i--) { |
| const b = balls[i]; |
| if (!b.exp || b.exp > T) continue; |
| balls.splice(i, 1); |
| if (b.burst) { |
| const B = b.burst, a0 = Math.random() * Math.PI * 2; |
| for (let k = 0; k < B.n; k++) { |
| const an = a0 + k * 2 * Math.PI / B.n; |
| const nb = { x: b.x, y: b.y, radius: B.r[0] + Math.random() * (B.r[1] - B.r[0]), vx: Math.cos(an) * B.v, vy: Math.sin(an) * B.v, gi: -1, exp: T + B.life, col: B.col }; |
| balls.push(nb); dailyRec(nb, T); |
| } |
| } |
| } |
| ch.groups.forEach((g, gi) => { |
| if (!g.respawn || T < _dailyRun.next[gi]) return; |
| let alive = 0; for (const b of balls) if (b.gi === gi) alive++; |
| if (alive >= (g.cap != null ? g.cap : g.n)) return; |
| if (g.from === "echo" && T < (g.delay || 150)) return; // erst spawnen, wenn es eine Spur zum Nachziehen gibt |
| _dailyRun.next[gi] = T + Math.max(g.respawnMin || 1, g.respawn + (g.respawnGrow || 0) * _dailyRun.cnt[gi]++); |
| const nb = dailyMakeBall(g, gi, T, false); balls.push(nb); dailyRec(nb, T); |
| }); |
| } |
| let _dailyCdUntil = 0; |
| function dailyCountdown() { |
| _dailyCdUntil = Date.now() + 3000; |
| startTime = _dailyCdUntil; // Time-Anzeige beginnt erst nach dem Countdown |
| const old = document.getElementById("dailyCd"); if (old) old.remove(); |
| const el = document.createElement("div"); el.id = "dailyCd"; |
| el.style.cssText = "position:fixed;inset:0;z-index:160;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;color:#fff;text-shadow:0 0 30px rgba(0,255,136,.8);font-family:system-ui,sans-serif;"; |
| document.body.appendChild(el); |
| const ch = dailyActiveCh(); |
| const tick = () => { |
| const left = _dailyCdUntil - Date.now(); |
| if (left <= 0 || !survivalActive) { el.remove(); _dailyCdUntil = 0; if (survivalActive) startTime = Date.now(); return; } |
| el.innerHTML = '<div style="font-size:22px;font-weight:700;margin-bottom:8px;">' + ch.icon + ' ' + ch.name + '</div><div style="font-size:120px;font-weight:800;line-height:1;">' + Math.ceil(left / 1000) + '</div>'; |
| setTimeout(tick, 100); |
| }; |
| tick(); |
| } |
| function dailySteer() { |
| const me = players[0]; if (!me || !me.alive) return; |
| for (const b of balls) { |
| if (!b.dch) continue; |
| const want = Math.atan2(me.y - b.y, me.x - b.x), cur = Math.atan2(b.vy, b.vx); |
| let d = want - cur; |
| while (d > Math.PI) d -= Math.PI * 2; |
| while (d < -Math.PI) d += Math.PI * 2; |
| const na = cur + Math.max(-b.dch.turn, Math.min(b.dch.turn, d)); |
| b.vx = Math.cos(na) * b.dch.spd; b.vy = Math.sin(na) * b.dch.spd; |
| } |
| } |
| function updDailyBtn() { const b = document.getElementById("dailyBtn"); if (!b) return; const c = dailyChallengeFor(dailyKeyNow()); b.textContent = "📅 Daily: " + c.icon + " " + c.name; } |
| setInterval(updDailyBtn, 30000); setTimeout(updDailyBtn, 300); setTimeout(updDailyBtn, 2000); |
| function dailyKeyNow() { // Kalendertag in deutscher Zeit (Europe/Berlin), Wechsel um 0:00 Uhr |
| try { return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()); } |
| catch (e) { return new Date().toISOString().slice(0, 10); } |
| } |
| function dailySeed(k) { let h = 2166136261; for (let i = 0; i < k.length; i++) { h ^= k.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) % 1000000000 + 1; } |
| function startSurvival(daily, testIdx) { |
| _dailyKey = daily === true ? dailyKeyNow() : null; |
| _dailyTest = (daily === true && Number.isInteger(testIdx) && _modIsAdminSafe()) ? DAILY_CHALLENGES[testIdx] : null; |
| if (!guestGate("survival")) return; |
| countModeRun("survPlayed"); |
| try { cleanupOnline(); } catch (e) {} |
| clearAllTimers(); |
| ["survivalResult", "survReplay"].forEach(id => { const el = document.getElementById(id); if (el) el.remove(); }); |
| hideAllScreensForGame(); |
| mode = "online"; document.body.classList.add("mode-online"); |
| balls = []; players = []; eliminated = []; |
| running = true; startTime = Date.now(); |
| onlineVsBot = true; onlineIsHost = true; onlineMatchId = null; onlineOpponent = null; // keine Netzwerk-Schreibzugriffe im Match |
| onlineResultHandled = false; campReset(); onlineMatchStatus = null; onlineSimTick = 0; myTrail = []; |
| onlineRng = makeLCG(_dailyKey ? dailySeed(_dailyKey) : Math.floor(Math.random() * 1e9) + 1); |
| onlineBallSpawnTimer = Date.now(); |
| const md = document.getElementById("mode"); if (md) md.textContent = _dailyKey ? (_dailyTest ? "Test: " : "Daily: ") + dailyActiveCh().name : "Survival"; |
| document.getElementById("onlineHUD").style.display = "none"; |
| const me = createPlayer(W() / 2, H() / 2, myBallColor, myPlayName()); |
| players.push(me); |
| survTick = 0; survivalActive = true; |
| if (_dailyKey) { const _ch = dailyActiveCh(); if (_ch.playerR) { me.radius = _ch.playerR; } } |
| if (_dailyKey) { balls = makeDailyBalls(dailyActiveCh()); dailyCountdown(); } else _dailyCdUntil = 0; |
| try { if (fbReady && myUid) RPC.armSurvivalLiveDisconnect().catch(() => {}); } catch (e) {} |
| survRec = { v: 1, w: W(), h: H(), c: myBallColor, r: me.radius, p: [Math.round(me.x), Math.round(me.y)], s: [] }; |
| _dailyLight = _dailyKey ? (dailyActiveCh().light || null) : null; if (_dailyLight) survRec.lt = _dailyLight; |
| if (_dailyKey) for (const b of balls) dailyRec(b, 1); |
| const hud = document.createElement("div"); |
| hud.id = "survivalHUD"; |
| hud.style.cssText = "position:fixed;left:50%;top:calc(10px + env(safe-area-inset-top,0px));transform:translateX(-50%);z-index:150;pointer-events:none;padding:6px 18px;border-radius:12px;background:rgba(12,12,18,.85);border:1px solid rgba(0,255,136,.5);color:#00ff88;font:700 22px var(--font-display),system-ui,sans-serif;"; |
| hud.textContent = "0.0s"; |
| document.body.appendChild(hud); |
| if (survHudIv) clearInterval(survHudIv); |
| survHudIv = setInterval(() => { const h = document.getElementById("survivalHUD"); if (h) h.textContent = survFmt(survTick / 60); }, 100); |
| syncPresence(true); |
| } |
| let _survLiveAt = 0; |
| function survPushLive(done, finalTime) { |
| if (!fbReady || !myUid || !onlineAccount) return; |
| const now = Date.now(); |
| if (!done && now - _survLiveAt < 120) return; |
| _survLiveAt = now; |
| const me = players[0]; if (!me) return; |
| const d = { name: onlineAccount.name, c: myBallColor, elo: Math.round(onlineAccount.elo || 1500), r: me.radius, x: Math.round(me.x), y: Math.round(me.y), t: survTick, done: !!done }; |
| if (_dailyKey) d.k = "daily"; |
| if (done) d.time = finalTime; |
| d.b = JSON.stringify(balls.map(b => ({ x: Math.round(b.x), y: Math.round(b.y), vx: parseFloat(b.vx.toFixed(2)), vy: parseFloat(b.vy.toFixed(2)), radius: b.radius }))); |
| RPC.setSurvivalLive(d).catch(() => {}); |
| } |
| function survivalTick() { |
| survTick++; |
| survPushLive(false); |
| if (survTick % 3 === 0) { const me = players[0]; if (me) survRec.p.push(Math.round(me.x), Math.round(me.y)); } |
| } |
| let _runCountChain = Promise.resolve(); |
| function countModeRun(field) { |
| if (!onlineAccount) return; |
| onlineAccount[field] = (onlineAccount[field] || 0) + 1; |
| try { saveAccountLocal(); } catch (e) {} |
| // Jeder Start (auch Retry / Try again) zaehlt. Seriell + atomar: nur das eine Feld wird geschrieben, |
| // Basis ist der hoehere Wert aus Server und lokal -> schnelle Retries ueberschreiben sich nicht. |
| _runCountChain = _runCountChain.then(async () => { |
| if (!fbReady || !myUid || !onlineAccount) return; |
| try { |
| const cur = Number(await dbGet("players/" + myUid + "/" + field)) || 0; |
| const v = Math.max(cur + 1, onlineAccount[field] || 0); |
| onlineAccount[field] = v; |
| saveAccountLocal(); |
| await RPC.updateOwnPlayer({ [field]: v }); |
| } catch (e) { console.warn("run count sync failed", e); } |
| }); |
| } |
| async function endSurvival(camped) { |
| if (!survivalActive) return; |
| survivalActive = false; running = false; |
| clearAllTimers(); campWarn(false); |
| if (survHudIv) { clearInterval(survHudIv); survHudIv = null; } |
| const hud = document.getElementById("survivalHUD"); if (hud) hud.remove(); |
| const me = players[0], rec = survRec; survRec = null; |
| rec.n = survTick; rec.e = me ? [Math.round(me.x), Math.round(me.y)] : [0, 0]; |
| const t = Math.round(survTick / 60 * 100) / 100; |
| survPushLive(true, t); // TV-Zuschauer sehen das Ende |
| stopViewerCount(); |
| _survInfo = null; |
| setTimeout(() => renderSurvivalResult(t, camped), 900); |
| if (_dailyKey) { await _endDaily(t, camped, rec); return; } |
| let best = 0, isRecord = false, saved = true; |
| try { |
| if (fbReady && myUid && onlineAccount) { |
| const old = await dbGet("survival/" + myUid); |
| best = (old && Number(old.time)) || 0; |
| if (t > best && t >= 1) { |
| await RPC.saveSurvival(t, JSON.stringify(rec), onlineAccount.name); |
| isRecord = true; best = t; |
| } |
| } |
| } catch (e) { saved = false; console.warn("survival save failed", e); } |
| _survInfo = { best, isRecord, saved }; |
| if (document.getElementById("survivalResult")) renderSurvivalResult(t, camped); |
| } |
| async function dailyCleanup() { // eigene Replays vergangener Tage loeschen |
| if (!fbReady || !myUid || !RPC) return; |
| try { |
| const all = await dbGet("dailyReplays/" + myUid), today = dailyKeyNow(); |
| if (all) for (const d of Object.keys(all)) if (d < today) await RPC.removeDailyReplay(d); |
| } catch (e) { console.warn("daily cleanup failed", e); } |
| } |
| async function dailyClaimWins() { // vergangene Tage (max. 14) pruefen: war ich Erster? -> Sieg eintragen |
| if (!fbReady || !myUid || !RPC) return; |
| try { |
| const mine = (await dbGet("dailyWins/" + myUid)) || {}, base = Date.parse(dailyKeyNow() + "T12:00:00Z"); |
| for (let i = 1; i <= 14; i++) { |
| const day = new Date(base - i * 86400000).toISOString().slice(0, 10); |
| if (mine[day]) continue; |
| const me = await dbGet("daily/" + day + "/" + myUid); |
| if (!me) continue; |
| const all = await dbGet("daily/" + day); |
| const arr = Object.entries(all || {}).map(([u, v]) => ({ u, t: Number(v.time) || 0, ts: Number(v.ts) || 0 })).sort((a, b) => b.t - a.t || a.ts - b.ts); |
| if (arr.length && arr[0].u === myUid) await RPC.claimDailyWin(day); |
| } |
| } catch (e) { console.warn("daily wins check failed", e); } |
| } |
| const _dcIv = setInterval(() => { if (typeof fbReady !== "undefined" && fbReady && myUid && RPC) { clearInterval(_dcIv); dailyCleanup(); dailyClaimWins(); } }, 4000); |
| // Tageswechsel (0:00 Berlin) erkennen: Sieg von gestern sofort eintragen, ohne dass die Seite neu geladen werden muss |
| let _dcLastDay = dailyKeyNow(), _dcBusy = false; |
| async function _dcRecheck() { if (_dcBusy) return; _dcBusy = true; try { await dailyClaimWins(); dailyCleanup(); } finally { _dcBusy = false; } } |
| function _dcCheckDayChange() { const k = dailyKeyNow(); if (k !== _dcLastDay) { _dcLastDay = k; _dcRecheck(); } } |
| setInterval(_dcCheckDayChange, 30000); |
| document.addEventListener("visibilitychange", () => { if (!document.hidden) _dcCheckDayChange(); }); |
| async function watchDailyReplay(day, uid, label) { |
| if (!fbReady) return; |
| let rec = null; |
| try { const raw = await dbGet("dailyReplays/" + uid + "/" + day); rec = raw && JSON.parse(raw); } catch (e) {} |
| if (!rec || !Array.isArray(rec.p) || !rec.n) { alert("No replay found."); return; } |
| playSurvivalReplay(rec, label); |
| } |
| async function _endDaily(t, camped, rec) { |
| if (_dailyTest) { _survInfo = { daily: true, key: dailyKeyNow(), best: t, isRecord: false, saved: true, top: [], rank: 0, test: true }; if (document.getElementById("survivalResult")) renderSurvivalResult(t, camped); return; } |
| const key = _dailyKey; let best = 0, isRecord = false, saved = true, top = [], rank = 0; |
| try { const l = JSON.parse(localStorage.getItem("dobDaily") || "null"); if (l && l.k === key) best = Number(l.b) || 0; } catch (e) {} // nur Notfall-Wert (offline/Gast) |
| try { |
| if (fbReady && myUid && onlineAccount) { |
| const old = await dbGet("daily/" + key + "/" + myUid); |
| best = (old && Number(old.time)) || 0; // Datenbank ist massgeblich: wurden die Zeiten geloescht, zaehlt die alte lokale Bestzeit nicht mehr |
| if (!old) { try { localStorage.removeItem("dobDaily"); } catch (e) {} } |
| if (t > best && t >= 1) { |
| await RPC.saveDaily(key, t, onlineAccount.name); isRecord = true; best = t; |
| try { await RPC.saveDailyReplay(key, JSON.stringify(rec)); } catch (e) { console.warn("daily replay save failed", e); } |
| dailyCleanup(); |
| } |
| } else if (t > best) { isRecord = true; best = t; } |
| } catch (e) { saved = false; if (t > best) { isRecord = true; best = t; } console.warn("daily save failed", e); } |
| try { if (isRecord) localStorage.setItem("dobDaily", JSON.stringify({ k: key, b: best })); } catch (e) {} |
| try { |
| const all = await dbGet("daily/" + key); |
| if (all) { const arr = Object.entries(all).map(([u, v]) => ({ u, n: v.name, t: Number(v.time) || 0 })).sort((a, b) => b.t - a.t); top = arr.slice(0, 10); rank = arr.findIndex(x => x.u === myUid) + 1; } |
| } catch (e) {} |
| _survInfo = { daily: true, key, best, isRecord, saved, top, rank }; |
| if (document.getElementById("survivalResult")) renderSurvivalResult(t, camped); |
| } |
| function renderSurvivalResult(t, camped) { |
| let ov = document.getElementById("survivalResult"); |
| if (!ov) { |
| ov = document.createElement("div"); ov.id = "survivalResult"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6);"; |
| document.body.appendChild(ov); |
| } |
| const i = _survInfo; |
| const dly = i && i.daily; |
| const line = dly ? (i.isRecord ? "🏆 New best for today!" : "Your best today: " + survFmt(i.best)) + (i.rank ? " · Rank #" + i.rank : "") + (i.saved ? "" : " (⚠ not saved online)") |
| : !onlineAccount ? "Guest run – create an account to save your records." : !i ? "Saving…" : !i.saved ? "⚠ Could not save the record (connection/permission)." : |
| i.isRecord ? "🏆 New personal record!" : "Your record: " + survFmt(i.best); |
| ov.innerHTML = '<div style="background:#14141e;border:1px solid rgba(255,255,255,.15);border-radius:14px;padding:22px 28px;text-align:center;color:#f0f0f5;max-width:90vw;">' |
| + (_dailyKey ? '<div style="font-size:13px;color:#ffd700;letter-spacing:1px;">' + (_dailyTest ? '🧪 TEST (not saved)' : '📅 DAILY · ' + _dailyKey) + '</div><div style="font-size:18px;font-weight:700;margin:2px 0 4px;">' + dailyActiveCh().icon + ' ' + dailyActiveCh().name + '</div>' : '') |
| + '<div style="font-size:15px;opacity:.8;margin-bottom:6px;">' + (camped ? "🚫 Corner camping – game over" : "💥 Game over") + '</div>' |
| + '<div style="font-size:44px;font-weight:700;color:#00ff88;margin:4px 0 10px;">' + survFmt(t) + '</div>' |
| + '<div style="font-size:15px;margin-bottom:' + (dly && i.top.length ? '8' : '16') + 'px;">' + line + '</div>' |
| + (dly && i.top.length ? '<div style="font-size:13px;margin-bottom:14px;text-align:left;display:inline-block;">' + i.top.map((x, k) => '<div style="cursor:pointer;" onclick="watchDailyReplay(\'' + i.key + '\',\'' + escapeHtml(x.u) + '\',\'' + escapeHtml(dn(x.n)).replace(/'/g, "") + '\')" title="Watch replay">' + (k + 1) + '. ' + escapeHtml(dn(x.n)) + ' – ' + survFmt(x.t) + ' ▶</div>').join('') + '</div>' : '') |
| + '<button id="survRetry" style="margin:4px 6px;">🔄 Retry</button>' |
| + (i && i.best ? '<button id="survWatch" style="margin:4px 6px;">▶ My record replay</button>' : '') |
| + '<button id="survHome" style="margin:4px 6px;">🏠 Home</button></div>'; |
| ov.querySelector("#survRetry").onclick = () => { if (_dailyKey && _dailyTest) startSurvival(true, DAILY_CHALLENGES.indexOf(_dailyTest)); else if (_dailyKey) startDaily(); else startSurvival(false); }; |
| ov.querySelector("#survHome").onclick = () => { ov.remove(); exitToMenu(); }; |
| const w = ov.querySelector("#survWatch"); if (w) w.onclick = () => (_survInfo && _survInfo.daily) ? watchDailyReplay(_survInfo.key, myUid, "My best today") : watchSurvivalReplay(myUid, "My record"); |
| } |
| async function watchSurvivalReplay(uid, label) { |
| if (!fbReady) return; |
| let rec = null; |
| try { const raw = await dbGet("survivalReplays/" + uid); rec = raw && JSON.parse(raw); } catch (e) {} |
| if (!rec || !Array.isArray(rec.p) || !rec.n) { alert("No replay found."); return; } |
| playSurvivalReplay(rec, label); |
| } |
| function playSurvivalReplay(rec, label) { |
| const old = document.getElementById("survReplay"); if (old) old.remove(); |
| const ov = document.createElement("div"); ov.id = "survReplay"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:9500;background:#05050a;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:env(safe-area-inset-top,0px) 8px env(safe-area-inset-bottom,0px);box-sizing:border-box;"; |
| ov.innerHTML = '<div style="color:#f0f0f5;font:700 16px system-ui,sans-serif;"></div>' |
| + '<canvas style="width:100%;max-width:min(100%,calc((100vh - 120px) * 16 / 9));aspect-ratio:16/9;border:1px solid rgba(255,255,255,.15);border-radius:10px;background:#0a0a12;"></canvas>' |
| + '<div><button class="srR" style="margin:4px 6px;">↺ Restart</button><button class="srC" style="margin:4px 6px;">✕ Close</button></div>'; |
| document.body.appendChild(ov); |
| const head = ov.firstChild, cv = ov.querySelector("canvas"), c = cv.getContext("2d"); |
| const Wd = cv.width = rec.w || 1280, Hd = cv.height = rec.h || 720; |
| head.textContent = "▶ " + label + " – " + survFmt(rec.n / 60); |
| const P = rec.p, m = P.length / 2, last = 3 * (m - 1), S = rec.s || []; |
| const posAt = (tk) => { |
| let ax, ay, bx, by, f; |
| if (tk >= last) { ax = P[2 * (m - 1)]; ay = P[2 * (m - 1) + 1]; bx = rec.e[0]; by = rec.e[1]; f = (tk - last) / Math.max(1, rec.n - last); } |
| else { const k = Math.floor(tk / 3); ax = P[2 * k]; ay = P[2 * k + 1]; bx = P[2 * k + 2]; by = P[2 * k + 3]; f = (tk % 3) / 3; } |
| f = Math.min(1, f); return [ax + (bx - ax) * f, ay + (by - ay) * f]; |
| }; |
| let bl, tick, si, t0, raf = 0, closed = false; |
| const reset = () => { bl = []; tick = 0; si = 0; t0 = performance.now(); }; |
| const step = () => { |
| tick++; |
| bl = bl.filter(b => !b.exp || b.exp > tick); // abgelaufene Baelle (Lebensdauer) entfernen |
| while (si < S.length && S[si][0] === tick) { |
| const q = S[si++], nb = { x: q[1], y: q[2], vx: q[3], vy: q[4], radius: q[5], h: q[6], hs: q[7], exp: q[8] ? q[0] + q[8] : 0 }; |
| if (q[9]) { nb.rot = q[9][0]; nb.acc = q[9][1]; nb.accMax = q[9][2]; nb.grav = q[9][3]; nb.grow = q[9][4]; nb.rMax = q[9][5]; nb.col = q[9][6] || 0; } |
| bl.push(nb); |
| } |
| const pp = posAt(Math.min(tick, rec.n)); |
| for (const b of bl) if (b.h) { let d = Math.atan2(pp[1] - b.y, pp[0] - b.x) - Math.atan2(b.vy, b.vx); while (d > Math.PI) d -= Math.PI * 2; while (d < -Math.PI) d += Math.PI * 2; const na = Math.atan2(b.vy, b.vx) + Math.max(-b.h, Math.min(b.h, d)); b.vx = Math.cos(na) * b.hs; b.vy = Math.sin(na) * b.hs; } |
| for (const b of bl) { |
| dailyAdv(b); |
| b.x += b.vx; b.y += b.vy; |
| if (b.x - b.radius < 0) { b.vx = Math.abs(b.vx); b.x = b.radius; } |
| if (b.x + b.radius > Wd) { b.vx = -Math.abs(b.vx); b.x = Wd - b.radius; } |
| if (b.y - b.radius < 0) { b.vy = Math.abs(b.vy); b.y = b.radius; } |
| if (b.y + b.radius > Hd) { b.vy = -Math.abs(b.vy); b.y = Hd - b.radius; } |
| } |
| }; |
| const frame = () => { |
| if (closed) return; |
| const target = Math.min(rec.n, Math.floor((performance.now() - t0) / (1000 / 60))); |
| while (tick < target) step(); |
| c.clearRect(0, 0, Wd, Hd); |
| const _L = rec.lt || null, _pp = posAt(Math.min(tick, rec.n)), _alive = tick < rec.n; |
| if (_L && _alive) dailyLightFx(c, _L, _pp[0], _pp[1], tick, Wd, Hd); |
| for (const b of bl) { |
| const _v = (_L && _alive) ? dailyVis(_L, b, _pp[0], _pp[1], tick) : 1; if (_v <= 0) continue; |
| let al = 1; if (b.exp) { const left = (b.exp - tick) * (1000 / 60); if (left < 2000) al = Math.floor(left / 120) % 2 ? 0.35 : 0.9; } |
| c.globalAlpha = al * _v; c.beginPath(); c.arc(b.x, b.y, b.radius, 0, Math.PI * 2); c.fillStyle = b.col || (b.h ? "#ff9d00" : "#ff4444"); c.fill(); c.globalAlpha = 1; |
| } |
| const [px, py] = posAt(tick); |
| c.beginPath(); c.arc(px, py, rec.r || 18, 0, Math.PI * 2); c.fillStyle = rec.c || "#00ff88"; c.fill(); |
| c.fillStyle = "#fff"; c.font = "700 28px system-ui,sans-serif"; c.textAlign = "left"; |
| c.fillText(survFmt(tick / 60), 16, 40); |
| if (tick >= rec.n) { c.textAlign = "center"; c.font = "700 44px system-ui,sans-serif"; c.fillText("💥 " + survFmt(rec.n / 60), Wd / 2, Hd / 2); return; } |
| raf = requestAnimationFrame(frame); |
| }; |
| reset(); raf = requestAnimationFrame(frame); |
| ov.querySelector(".srR").onclick = () => { cancelAnimationFrame(raf); reset(); raf = requestAnimationFrame(frame); }; |
| ov.querySelector(".srC").onclick = () => { closed = true; cancelAnimationFrame(raf); ov.remove(); }; |
| } |
| |
| // Leaderboard: Tabs ELO | Survival |
| function lbShowTab(tab) { |
| const e = document.getElementById("lbTabElo"), v = document.getElementById("lbTabSurv"), k = document.getElementById("lbTabStreak"); |
| if (e) e.style.borderColor = tab === "elo" ? "#00ff88" : ""; if (v) v.style.borderColor = tab === "surv" ? "#00ff88" : ""; |
| if (k) k.style.borderColor = tab === "streak" ? "#00ff88" : ""; |
| const dd = document.getElementById("lbTabDaily"), dw = document.getElementById("lbTabDailyWins"); |
| if (dd) dd.style.borderColor = tab === "daily" ? "#00ff88" : ""; if (dw) dw.style.borderColor = tab === "dwins" ? "#00ff88" : ""; |
| if (tab === "surv") loadSurvivalBoard(); else if (tab === "streak") loadStreakBoard(); else if (tab === "daily") loadDailyBoard(); else if (tab === "dwins") loadDailyWinsBoard(); else loadLeaderboard(); |
| } |
| async function loadStreakBoard() { |
| const t = document.getElementById("lbTable"); |
| t.innerHTML = '<div class="lb-row"><span>Rank</span><span>Name</span><span>Level</span><span></span></div>'; |
| if (!fbReady) { t.innerHTML += '<div class="lb-row"><span>Firebase not connected</span></div>'; return; } |
| try { |
| const [sb, pl] = await Promise.all([dbGet("streakBoard"), dbGet("players")]); |
| const rows = Object.entries(sb || {}) |
| .filter(([uid]) => !pl || pl[uid]) // geloeschte Accounts ausblenden |
| .map(([uid, r]) => ({ uid, level: Number(r && r.level) || 0, ts: Number(r && r.ts) || 0, name: (pl && pl[uid] && pl[uid].name) || (r && r.name) || "?" })) |
| .filter(r => r.level > 0) |
| .sort((a, b) => b.level - a.level || a.ts - b.ts) // gleiches Level: wer es zuerst erreicht hat steht oben |
| .slice(0, 100); |
| if (!rows.length) { t.innerHTML += '<div class="lb-row"><span style="grid-column: span 4; text-align: center;">No Streaks records yet</span></div>'; return; } |
| t.innerHTML += rows.map((r, i) => `<div class="lb-row${myUid && r.uid === myUid ? " lb-me" : ""}" style="cursor:pointer;" onclick="openOnlineProfile('${escapeHtml(r.uid)}')"> |
| <span>#${i + 1}</span><span>${escapeHtml(dn(r.name))}</span><span>${r.level}</span><span></span></div>`).join(""); |
| } catch (e) { t.innerHTML += '<div class="lb-row"><span>Error while loading</span></div>'; } |
| } |
| async function modDeleteAllDaily() { |
| if (!confirm("Delete ALL daily runs and replays of ALL players? This cannot be undone.")) return; |
| try { await RPC.modDeleteAllDaily(); try { localStorage.removeItem("dobDaily"); } catch (e) {} alert("All daily runs deleted."); loadDailyBoard(); } |
| catch (e) { alert("Delete failed: " + (e && e.message || e)); } |
| } |
| async function loadDailyBoard() { |
| const t = document.getElementById("lbTable"), key = dailyKeyNow(), ch = dailyChallengeFor(key); |
| t.innerHTML = '<div class="lb-row"><span style="margin:0 auto;">📅 Today: ' + ch.icon + ' ' + escapeHtml(ch.name) + ' · ' + key + '</span></div>' |
| + '<div class="lb-row"><span>Rank</span><span>Name</span><span>Time</span><span>Replay</span></div>'; |
| if (typeof _modIsAdmin === "function" && _modIsAdmin()) t.insertAdjacentHTML("beforeend", '<div class="lb-row"><button style="margin:0 auto;padding:6px 14px;font-size:13px;" onclick="modDeleteAllDaily()">🗑 Delete all daily runs</button></div>'); |
| if (!fbReady) { t.innerHTML += '<div class="lb-row"><span>Firebase not connected</span></div>'; return; } |
| try { |
| const [dl, pl] = await Promise.all([dbGet("daily/" + key), dbGet("players")]); |
| const rows = Object.entries(dl || {}) |
| .filter(([uid]) => !pl || pl[uid]) |
| .map(([uid, r]) => ({ uid, time: Number(r && r.time) || 0, ts: Number(r && r.ts) || 0, name: (pl && pl[uid] && pl[uid].name) || (r && r.name) || "?" })) |
| .filter(r => r.time > 0).sort((a, b) => b.time - a.time || a.ts - b.ts).slice(0, 100); |
| if (!rows.length) { t.innerHTML += '<div class="lb-row"><span style="margin:0 auto;">No runs yet today – be the first!</span></div>'; return; } |
| t.innerHTML += rows.map((r, i) => `<div class="lb-row${myUid && r.uid === myUid ? " lb-me" : ""}" style="cursor:pointer;" onclick="openOnlineProfile('${escapeHtml(r.uid)}')"> |
| <span>#${i + 1}</span><span>${escapeHtml(dn(r.name))}</span><span>${survFmt(r.time)}</span> |
| <span><button style="margin:0;padding:4px 12px;font-size:13px;" onclick="event.stopPropagation();watchDailyReplay('${key}','${escapeHtml(r.uid)}','${escapeHtml(dn(r.name)).replace(/'/g, "")}')">▶</button></span></div>`).join(""); |
| } catch (e) { t.innerHTML += '<div class="lb-row"><span>Error while loading</span></div>'; } |
| } |
| async function loadDailyWinsBoard() { |
| const t = document.getElementById("lbTable"); |
| t.innerHTML = '<div class="lb-row"><span>Rank</span><span>Name</span><span>👑 Wins</span><span></span></div>'; |
| if (!fbReady) { t.innerHTML += '<div class="lb-row"><span>Firebase not connected</span></div>'; return; } |
| try { |
| const [dw, pl] = await Promise.all([dbGet("dailyWins"), dbGet("players")]); |
| const rows = Object.entries(dw || {}) |
| .filter(([uid]) => pl && pl[uid]) |
| .map(([uid, days]) => ({ uid, wins: Object.keys(days || {}).length, name: pl[uid].name || "?" })) |
| .filter(r => r.wins > 0).sort((a, b) => b.wins - a.wins).slice(0, 100); |
| if (!rows.length) { t.innerHTML += '<div class="lb-row"><span style="margin:0 auto;">No daily wins yet</span></div>'; return; } |
| t.innerHTML += rows.map((r, i) => `<div class="lb-row${myUid && r.uid === myUid ? " lb-me" : ""}" style="cursor:pointer;" onclick="openOnlineProfile('${escapeHtml(r.uid)}')"> |
| <span>#${i + 1}</span><span>${escapeHtml(dn(r.name))}</span><span>${r.wins}</span><span></span></div>`).join(""); |
| } catch (e) { t.innerHTML += '<div class="lb-row"><span>Error while loading</span></div>'; } |
| } |
| async function loadSurvivalBoard() { |
| const t = document.getElementById("lbTable"); |
| t.innerHTML = '<div class="lb-row"><span>Rank</span><span>Name</span><span>Time</span><span>Replay</span></div>'; |
| if (!fbReady) { t.innerHTML += '<div class="lb-row"><span>Firebase not connected</span></div>'; return; } |
| try { |
| const [sv, pl] = await Promise.all([dbGet("survival"), dbGet("players")]); |
| const rows = Object.entries(sv || {}) |
| .filter(([uid]) => !pl || pl[uid]) // geloeschte Accounts ausblenden |
| .map(([uid, r]) => ({ uid, time: Number(r && r.time) || 0, name: (pl && pl[uid] && pl[uid].name) || (r && r.name) || "?" })) |
| .filter(r => r.time > 0).sort((a, b) => b.time - a.time) |
| .filter((r, i, arr) => { // pro Spieler nur die beste Zeit (Liste ist bereits absteigend sortiert) |
| const key = String(dn(r.name)).trim().toLowerCase(); |
| return arr.findIndex(x => String(dn(x.name)).trim().toLowerCase() === key) === i; |
| }).slice(0, 100); |
| if (!rows.length) { t.innerHTML += '<div class="lb-row"><span style="grid-column: span 4; text-align: center;">No survival records yet</span></div>'; return; } |
| t.innerHTML += rows.map((r, i) => `<div class="lb-row${myUid && r.uid === myUid ? " lb-me" : ""}" style="cursor:pointer;" onclick="openOnlineProfile('${escapeHtml(r.uid)}')"> |
| <span>#${i + 1}</span><span>${escapeHtml(dn(r.name))}</span><span>${survFmt(r.time)}</span> |
| <span><button style="margin:0;padding:4px 12px;font-size:13px;" onclick="event.stopPropagation();watchSurvivalReplay('${escapeHtml(r.uid)}','${escapeHtml(dn(r.name)).replace(/'/g, "")}')">▶</button></span></div>`).join(""); |
| } catch (e) { t.innerHTML += '<div class="lb-row"><span>Error while loading</span></div>'; } |
| } |
| |
| function sanitizeAccount(acc) { |
| if (!acc) return null; |
| acc.elo = acc.elo ?? 1500; |
| acc.wins = acc.wins ?? 0; |
| acc.losses = acc.losses ?? 0; |
| acc.highestElo = acc.highestElo ?? acc.elo; |
| acc.lowestElo = acc.lowestElo ?? acc.elo; |
| acc.currentStreak = acc.currentStreak ?? 0; |
| acc.bestStreak = acc.bestStreak ?? 0; |
| acc.totalEloGained = acc.totalEloGained ?? 0; |
| acc.totalEloLost = acc.totalEloLost ?? 0; |
| acc.bestWinChange = acc.bestWinChange ?? 0; |
| acc.bestWinOpp = acc.bestWinOpp ?? ""; |
| acc.worstLossChange = acc.worstLossChange ?? 0; |
| acc.worstLossOpp = acc.worstLossOpp ?? ""; |
| acc.survPlayed = Number(acc.survPlayed) > 0 ? Math.floor(acc.survPlayed) : 0; |
| acc.streakPlayed = Number(acc.streakPlayed) > 0 ? Math.floor(acc.streakPlayed) : 0; |
| acc.leagueTier = acc.leagueTier ?? 0; |
| acc.leagueBest = acc.leagueBest ?? acc.leagueTier; |
| acc.leagueWeek = acc.leagueWeek ?? null; |
| acc.leagueGroup = acc.leagueGroup ?? null; |
| acc.leagueLastResult = acc.leagueLastResult ?? null; |
| return acc; |
| } |
| |
| // CHANGED: ELO INTEGRITY (soft client-side mitigation — see header |
| // comment block at the top of the file for why this is NOT a real |
| // security boundary by itself). Any single match's ELO delta is now |
| // clamped to a sane maximum, and we always derive the new value from |
| // (current known value + clamped delta) rather than letting a stray |
| // large number get written wholesale. A determined console user who |
| // owns their own uid can still repeatedly call this with the max |
| // clamp value — only a trusted backend (Cloud Function) that |
| // computes match results itself can fully close this gap. |
| const MIN_ELO_GAIN = 10; |
| const MAX_ELO_GAIN = 50; |
| const MAX_ELO_LOSS = -1; |
| |
| function clampEloChange(delta) { |
| const d = Number(delta) || 0; |
| |
| // If it's a loss (negative number), the absolute worst it can be is -1. |
| // Any loss larger than -1 (like -5, -20, -50) gets boosted to -1. |
| if (d < 0) { |
| return Math.max(MAX_ELO_LOSS, d); |
| } |
| |
| // If it's a win (positive number), clamp it between +10 and +50. |
| return Math.max(MIN_ELO_GAIN, Math.min(MAX_ELO_GAIN, d)); |
| } |
| |
| |
| |
| |
| // ═══════════════════════════════════════════════ |
| // DISCONNECT PENALTY (online 1v1 vs. real players) |
| // • Opponent sends no sign of life for 3 s (also before the start) -> forfeit: they lose, you win. |
| // • Leaving mid-match (menu / reload) -> the leaver loses exactly the ELO of a normal loss. |
| // • Tab closed / connection lost -> Firebase marks the match as forfeited instantly (onDisconnect); |
| // the penalty is applied at the latest on the next start (marker in localStorage). |
| // ═══════════════════════════════════════════════ |
| // A forfeit counts exactly like a normal result: same ELO formula as reportOnlineResult() |
| function normalEloChange(winnerElo, loserElo) { |
| const diff = (Number(loserElo) || 1500) - (Number(winnerElo) || 1500); |
| return clampEloChange(Math.max(1, Math.min(15, Math.round(5 + diff / 100)))); |
| } |
| // iAmWinner = true -> what I would gain if the opponent forfeits; false -> what I lose if I forfeit |
| function forfeitChange(iAmWinner) { |
| const me = onlineAccount ? onlineAccount.elo : 1500, opp = onlineOpponent ? onlineOpponent.elo : 1500; |
| return iAmWinner ? normalEloChange(me, opp) : normalEloChange(opp, me); |
| } |
| const OPP_TIMEOUT_PLAYING_MS = 3000; // kein Lebenszeichen fuer 3 s -> sofort verloren |
| const OPP_TIMEOUT_WAITING_MS = 3000; // auch vor dem Start: nicht im Match erschienen -> nach 3 s verloren |
| let onlineDisarm = null; |
| function readActiveMatchMarker() { try { return JSON.parse(localStorage.getItem("dobActiveMatch") || "null"); } catch (e) { return null; } } |
| function setActiveMatchMarker(id, loss) { try { localStorage.setItem("dobActiveMatch", JSON.stringify({ id, loss, ts: Date.now() })); } catch (e) {} } |
| function clearActiveMatchMarker() { try { localStorage.removeItem("dobActiveMatch"); } catch (e) {} } |
| function disarmOnlineDisconnect() { |
| if (onlineDisarm) { try { onlineDisarm.cancel().catch(() => {}); } catch (e) {} onlineDisarm = null; } |
| } |
| function armOnlineDisconnect(forfeit) { |
| disarmOnlineDisconnect(); |
| if (!fbReady || !onlineMatchId) return; |
| if (onlineVsBot) forfeit = false; // Bot-Match: bei Verbindungsabbruch nur beenden, keine Strafe |
| try { onlineDisarm = RPC.armMatchDisconnect(onlineMatchId, forfeit ? { status: "abandoned", forfeitBy: myUid, eloChange: forfeitChange(false) } : { status: "abandoned" }); } catch (e) {} |
| } |
| // Applies the forfeit loss to the OWN account (synchronous part; caller pushes to Firebase) |
| function applyForfeitLoss(change) { |
| if (!onlineAccount) return 0; |
| const loss = Math.abs(clampEloChange(Number(change) || 10)); |
| onlineAccount = sanitizeAccount(onlineAccount); |
| onlineAccount.elo = Math.max(100, onlineAccount.elo - loss); |
| onlineAccount.losses++; |
| onlineAccount.currentStreak = 0; |
| onlineAccount.totalEloLost += loss; |
| if (onlineAccount.elo < onlineAccount.lowestElo) onlineAccount.lowestElo = onlineAccount.elo; |
| saveAccountLocal(); |
| try { updateAccountBtn(); } catch (e) {} |
| return loss; |
| } |
| // Called when the opponent stopped sending signs of life |
| async function forfeitOpponent() { |
| if (onlineResultHandled || !onlineMatchId || onlineVsBot || !fbReady) return; |
| const id = onlineMatchId; |
| try { // if the match was already finished / forfeited by someone else, let the normal listener handle it |
| const cur = await RPC.getMatch(id); |
| if (!cur || cur.status === "abandoned" || cur.status === "done") return; |
| } catch (e) {} |
| if (onlineResultHandled || onlineMatchId !== id) return; |
| disarmOnlineDisconnect(); |
| const change = forfeitChange(true); |
| RPC.updateMatch(id, { status: "abandoned", forfeitBy: (onlineOpponent && onlineOpponent.uid) || null, eloChange: change }).catch(() => {}); |
| handleOnlineResult(onlineAccount.name, change); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // ANTI CORNER-CAMPING (online 1v1) |
| // • "Deep corner" = player centre inside a square of CAMP_ZONE * min(W,H) in any of the 4 corners. |
| // • Meter: +1 s per second in a corner (switching corners counts), only CAMP_DECAY s lost per second outside. |
| // • Warning from CAMP_WARN_MS (2.5 s), at CAMP_LIMIT_MS (5 s) the player has LOST the match (normal ELO loss) + message. |
| // • Every client checks itself (instant message) AND the opponent (+1.5 s grace) so it cannot be switched off. |
| // ═══════════════════════════════════════════════ |
| const CAMP_ZONE = 0.17, CAMP_WARN_MS = 50000, CAMP_LIMIT_MS = 500000, CAMP_DECAY = 50000; |
| let campState = null, onlineMatchStatus = null; |
| function campReset() { campState = { me: { m: 0 }, opp: { m: 0 }, last: Date.now(), done: false }; campWarn(false); } |
| function inDeepCorner(p) { |
| const z = Math.min(W(), H()) * CAMP_ZONE; |
| return (p.x < z || p.x > W() - z) && (p.y < z || p.y > H() - z); |
| } |
| function campBanner(id, text, color, ms) { |
| let el = document.getElementById(id); |
| if (!el) { |
| el = document.createElement("div"); el.id = id; |
| el.style.cssText = "position:fixed;left:50%;top:14%;transform:translateX(-50%);z-index:180;pointer-events:none;text-align:center;max-width:90vw;" + |
| "padding:12px 22px;border-radius:14px;font-family:var(--font-display);font-weight:700;font-size:20px;letter-spacing:.4px;" + |
| "background:rgba(12,12,18,.92);backdrop-filter:blur(6px);box-shadow:0 8px 30px rgba(0,0,0,.5);"; |
| document.body.appendChild(el); |
| } |
| el.style.border = "1px solid " + color; el.style.color = color; el.textContent = text; |
| if (ms) { clearTimeout(el._t); el._t = setTimeout(() => el.remove(), ms); } |
| } |
| function campWarn(on, secLeft) { |
| const el = document.getElementById("campWarn"); |
| if (!on) { if (el) el.remove(); return; } |
| campBanner("campWarn", "⚠ " + tr("No corner camping!") + " " + tr("Move out in") + " " + secLeft + "s", "#ffb020"); |
| } |
| function campTick() { |
| if (!campState || campState.done || onlineResultHandled || !running || mode !== "online" || !onlineAccount) return; |
| const now = Date.now(), dt = Math.min(250, now - campState.last); |
| campState.last = now; |
| if (!(onlineVsBot || onlineMatchStatus === "playing")) return; // not before the real start |
| const me = players.find(p => p.name === myPlayName()); |
| const opp = players.find(p => p.name !== myPlayName()); |
| const upd = (st, p) => { if (!p || p.alive === false) return; st.m = inDeepCorner(p) ? st.m + dt : Math.max(0, st.m - dt * CAMP_DECAY); }; |
| upd(campState.me, me); |
| if (!onlineVsBot) upd(campState.opp, opp); |
| const m = campState.me.m; |
| if (m >= CAMP_WARN_MS) campWarn(true, Math.max(0, Math.ceil((CAMP_LIMIT_MS - m) / 1000))); else campWarn(false); |
| if (m >= CAMP_LIMIT_MS) { campState.done = true; campDisqualify(true); } |
| else if (!onlineVsBot && campState.opp.m >= CAMP_LIMIT_MS + 1500) { campState.done = true; campDisqualify(false); } |
| } |
| function campDisqualify(selfCamped) { |
| if (survivalActive) { const m0 = players[0]; if (m0) m0.alive = false; endSurvival(true); return; } |
| const meName = onlineAccount.name, oppName = onlineOpponent ? onlineOpponent.name : "Online Bot"; |
| campWarn(false); |
| if (selfCamped) campBanner("campMsg", "🚫 " + tr("Corner camping is not allowed – you lost the match."), "#ff4444", 8000); |
| else campBanner("campMsg", "🚫 " + tr("Opponent was disqualified for corner camping – you win!"), "#00ff88", 8000); |
| if (!onlineVsBot && fbReady && onlineMatchId) RPC.updateMatch(onlineMatchId, { dq: "camp" }).catch(() => {}); |
| if (selfCamped) reportOnlineResult(oppName, meName); else reportOnlineResult(meName, oppName); |
| } |
| |
| |
| // ═══════════════════════════════════════════════ |
| // ANTI CORNER-CAMPING FUER ALLE MODI (zentral) |
| // campGuardTick() laeuft in update() fuer JEDEN Modus automatisch, auch fuer kuenftige: |
| // • Standard: jeder lokale Spieler (isLocalPlayer) wird ueberwacht, bei Disqualifikation scheidet er aus (arenaEliminate). |
| // • Neuer Modus mit eigenem Spielende? -> CAMP_ADAPTERS["meinmodus"] = { humans(), active(), frozen(p), kill(p) } eintragen, |
| // alle Felder sind optional (fehlt etwas, gilt der Standard). |
| // • Nicht ueberwacht: "online" (hat eigene Logik oben), "online_tv" (nur Zuschauer). |
| // Regeln (Zone, Warnung 2.5s, Limit 5s) sind dieselben wie online: CAMP_ZONE / CAMP_WARN_MS / CAMP_LIMIT_MS / CAMP_DECAY. |
| // ═══════════════════════════════════════════════ |
| const CAMP_SKIP = { online: 1, online_tv: 1 }; |
| const CAMP_ADAPTERS = { |
| multi: { humans: () => players.slice(0, 2) }, |
| tournament: { |
| humans: () => { const m = playerMatchMap.get("YOU"); const me = m && m.players.find(p => p.name === "YOU"); return me ? [me] : []; }, |
| active: () => { const m = playerMatchMap.get("YOU"); return !!(m && m.running); }, |
| kill: (p) => { |
| const m = playerMatchMap.get("YOU"); if (!m || !m.running) return; |
| p.alive = false; |
| const alive = m.players.filter(x => x.alive); |
| if (alive.length <= 1) { m.running = false; finishMatch(m, alive[0] || m.players.find(x => x !== p), p); } |
| } |
| }, |
| coin: { |
| frozen: (p) => !!(p.stunnedUntil && Date.now() < p.stunnedUntil), // betaeubt = darf sich nicht bewegen, zaehlt nicht |
| kill: (p) => { p.coinsCollected = 0; coinScorePlayer = 0; const e = document.getElementById("coinScore"); if (e) e.textContent = 0; endCoinMode(); } |
| }, |
| streak: { |
| active: () => !!(streak && streak.phase === "play"), |
| kill: (p) => { p.alive = false; streakEnd(); } |
| } |
| }; |
| let campGen = { key: "", meters: new WeakMap(), last: 0 }; |
| function campGenReset() { campGen = { key: mode + ":" + startTime, meters: new WeakMap(), last: Date.now() }; campWarn(false); } |
| function campGuardTick() { |
| if (!running || CAMP_SKIP[mode] || spectateModeActive) return; |
| const key = mode + ":" + startTime; |
| if (campGen.key !== key) campGenReset(); |
| const ad = CAMP_ADAPTERS[mode] || {}; |
| const now = Date.now(), dt = Math.min(250, now - campGen.last); |
| campGen.last = now; |
| if (ad.active && !ad.active()) return; |
| const humans = (ad.humans ? ad.humans() : players.filter(isLocalPlayer)).filter(p => p && p.alive !== false); |
| let worst = 0, worstP = null; |
| for (const p of humans) { |
| if (ad.frozen && ad.frozen(p)) continue; |
| const prev = campGen.meters.get(p) || 0; |
| const m = inDeepCorner(p) ? prev + dt : Math.max(0, prev - dt * CAMP_DECAY); |
| campGen.meters.set(p, m); |
| if (m > worst) { worst = m; worstP = p; } |
| } |
| if (worst >= CAMP_WARN_MS) campWarn(true, Math.max(0, Math.ceil((CAMP_LIMIT_MS - worst) / 1000))); else campWarn(false); |
| if (worstP && worst >= CAMP_LIMIT_MS) { |
| campWarn(false); |
| const who = humans.length > 1 ? dn(worstP.label || worstP.name) + ": " : ""; |
| campBanner("campMsg", "🚫 " + who + tr("Corner camping is not allowed – you lost the match."), "#ff4444", 8000); |
| campGen.meters.delete(worstP); |
| if (ad.kill) ad.kill(worstP); else arenaEliminate(worstP); |
| } |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // WEEKLY LEAGUE (Duolingo style) – independent of ELO |
| // • Each week (Mon 00:00 to Sun 24:00 German time) active players of a league are placed in groups |
| // of max. 20 players. Only wins of the current week count. |
| // • Top players move up, bottom players move down, the rest stay. ELO stays a pure matchmaking/ranking system. |
| // • No server job: every client evaluates its OWN previous week from the (public) group data |
| // and only ever writes its own entry (see Rules). |
| // ═══════════════════════════════════════════════ |
| const LEAGUES = [ |
| { name: "Bronze", color: "#cd7f32", icon: "🥉" }, |
| { name: "Silver", color: "#c0c8d4", icon: "🥈" }, |
| { name: "Gold", color: "#ffd700", icon: "🥇" }, |
| { name: "Platinum", color: "#4fe0d0", icon: "💠" } |
| ]; |
| const LEAGUE_GROUP_SIZE = 20; // max. players per group |
| const LEAGUE_PROMOTE_TOP = 5; // max. promoted players per group |
| const LEAGUE_PROMOTE_TOP_BRONZE = 10; // Bronze: top half moves up (higher leagues are still empty) |
| const LEAGUE_RELEGATE_BOTTOM = 5; // max. relegated players per group |
| const LEAGUE_MIN_GROUP_PROMOTE = 5; // groups with fewer players promote nobody |
| const LEAGUE_GRACE_MS = 20000; // so lange nach Wochenende wird noch nicht ausgewertet |
| const LEAGUE_KEEP_WEEKS = 4; // own entries of older weeks get cleaned up |
| const LEAGUE_MIN_WINS_PROMOTE = 3; // fewer than 3 weekly wins = no promotion |
| |
| function leagueByTier(t) { return LEAGUES[Math.max(0, Math.min(LEAGUES.length - 1, Number(t) || 0))]; } |
| function leagueLabel(t) { const l = leagueByTier(t); return l.icon + " " + tr(l.name); } |
| function leagueBadgeHTML(t) { |
| const l = leagueByTier(t); |
| return `<span style="color:${l.color};font-weight:700;white-space:nowrap;">${l.icon} <span>${l.name}</span></span>`; |
| } |
| // Week = Monday 00:00 UTC; the id is the date of that Monday (e.g. "2026-09-28") |
| // Woche = Montag 00:00 bis Sonntag 24:00 DEUTSCHE Zeit (Europe/Berlin, inkl. Sommer-/Winterzeit) |
| const LEAGUE_TZ = "Europe/Berlin"; |
| const _lgFmt = new Intl.DateTimeFormat("en-GB", { timeZone: LEAGUE_TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); |
| function _lgParts(ms) { |
| const o = {}; |
| for (const p of _lgFmt.formatToParts(new Date(ms))) if (p.type !== "literal") o[p.type] = Number(p.value); |
| return o; |
| } |
| function _lgOffsetMs(ms) { // Berlin minus UTC zum Zeitpunkt ms |
| const p = _lgParts(ms); |
| return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000; |
| } |
| function _lgMidnightBerlin(y, m, d) { // UTC-ms von 00:00 Berliner Zeit am Datum y-m-d |
| let t = Date.UTC(y, m - 1, d); |
| t = Date.UTC(y, m - 1, d) - _lgOffsetMs(t); |
| t = Date.UTC(y, m - 1, d) - _lgOffsetMs(t); |
| return t; |
| } |
| function leagueWeekStart(ts) { |
| const p = _lgParts(ts || Date.now()); |
| const loc = new Date(Date.UTC(p.year, p.month - 1, p.day)); |
| const day = (loc.getUTCDay() + 6) % 7; // Mo=0 … So=6 |
| const mon = new Date(Date.UTC(p.year, p.month - 1, p.day - day)); |
| return _lgMidnightBerlin(mon.getUTCFullYear(), mon.getUTCMonth() + 1, mon.getUTCDate()); |
| } |
| function leagueWeekEnd(ts) { return leagueWeekStart(leagueWeekStart(ts) + 7 * 86400000 + 12 * 3600000); } |
| function leagueWeekId(ts) { |
| const p = _lgParts(leagueWeekStart(ts) + 12 * 3600000); // Mittag des Montags -> Datum sicher der Montag |
| return p.year + "-" + String(p.month).padStart(2, "0") + "-" + String(p.day).padStart(2, "0"); |
| } |
| function leagueCountdownText() { |
| const ms = Math.max(0, leagueWeekEnd(Date.now()) - Date.now()); |
| const d = Math.floor(ms / 86400000), h = Math.floor((ms % 86400000) / 3600000), m = Math.floor((ms % 3600000) / 60000); |
| return tr("Week ends in") + " " + (d ? d + "d " : "") + h + "h " + m + "m"; |
| } |
| // Ein Liga-Eintrag entsteht nur durch einen Sieg. Eintraege mit 0 Siegen (z.B. Abbruch zwischen Beitritt und Sieg-Eintrag) zaehlen nicht. |
| function leagueCleanGroup(g) { |
| const o = {}; |
| for (const [k, m] of Object.entries(g || {})) if (m && ((Number(m.wins) || 0) > 0 || m.uid === myUid)) o[k] = m; |
| return o; |
| } |
| function leagueCleanGroups(groups) { |
| const o = {}; |
| for (const [id, g] of Object.entries(groups || {})) { const c = leagueCleanGroup(g); if (Object.keys(c).length) o[id] = c; } |
| return o; |
| } |
| function leagueSortMembers(list) { |
| return [...list].filter(m => m && m.uid).sort((a, b) => |
| (b.wins || 0) - (a.wins || 0) || |
| (a.lastWinAt || Infinity) - (b.lastWinAt || Infinity) || // tie-break: whoever reached the wins earlier ranks higher |
| (a.uid < b.uid ? -1 : 1)); |
| } |
| function leagueZones(n, tier) { |
| const up = (tier < LEAGUES.length - 1 && n >= LEAGUE_MIN_GROUP_PROMOTE) |
| ? (tier === 0 ? Math.max(1, Math.min(LEAGUE_PROMOTE_TOP_BRONZE, Math.floor(n / 2))) |
| : Math.max(1, Math.min(LEAGUE_PROMOTE_TOP, Math.floor(n / 3)))) : 0; |
| const down = tier > 0 ? Math.min(LEAGUE_RELEGATE_BOTTOM, Math.floor(n / 4)) : 0; |
| return { up, down }; |
| } |
| function leagueOutcome(rankIdx, n, wins, tier) { |
| const z = leagueZones(n, tier); |
| if (rankIdx < z.up && wins >= LEAGUE_MIN_WINS_PROMOTE) return "up"; |
| if (z.down > 0 && rankIdx >= n - z.down) return "down"; |
| return "stay"; |
| } |
| |
| let leagueViewTier = null, leagueViewGid = null; // nur anschauen: andere Liga / andere Gruppe |
| function leagueView(t, gid) { leagueViewTier = (t == null ? null : Number(t)); leagueViewGid = gid || null; renderLeague().catch(() => {}); } |
| let lastLeagueNote = ""; // note about the last match shown in the winner overlay |
| let _leagueBusy = null, leagueTimer = null; |
| |
| function updateWinnerLeagueLine() { |
| const wl = document.getElementById("winnerLeague"); |
| if (!wl || !onlineAccount) return; |
| const l = leagueByTier(onlineAccount.leagueTier); |
| wl.style.color = l.color; |
| wl.textContent = l.icon + " " + tr(l.name) + (lastLeagueNote ? " · " + lastLeagueNote : ""); |
| } |
| function leagueToast(text, color) { |
| const t = document.createElement("div"); |
| t.style.cssText = "position:fixed;left:50%;bottom:70px;transform:translateX(-50%);z-index:900;padding:12px 22px;border-radius:12px;" + |
| "background:rgba(16,18,26,0.95);border:1px solid " + (color || "#ffd700") + ";color:#fff;font-family:var(--font-display);font-size:18px;box-shadow:0 10px 30px rgba(0,0,0,0.5);"; |
| t.textContent = text; |
| document.body.appendChild(t); |
| setTimeout(() => t.remove(), 6000); |
| } |
| |
| // Evaluate the previous week (only the OWN account is modified) |
| let _lgAdoptAt = 0; |
| async function leagueAdoptServer() { |
| if (Date.now() - _lgAdoptAt < 60000) return; |
| _lgAdoptAt = Date.now(); |
| try { |
| const s = await dbGet(`players/${myUid}`); |
| if (!s) return; |
| const sr = s.leagueLastResult, lr = onlineAccount.leagueLastResult; |
| if (sr && String(sr.week) > String((lr && lr.week) || "")) { |
| onlineAccount.leagueTier = Number(s.leagueTier) || 0; |
| onlineAccount.leagueBest = Math.max(Number(s.leagueBest) || 0, onlineAccount.leagueBest || 0); |
| onlineAccount.leagueWeek = s.leagueWeek ?? null; |
| onlineAccount.leagueGroup = s.leagueGroup ?? null; |
| onlineAccount.leagueLastResult = sr; |
| saveAccountLocal(); updateAccountBtn(); |
| try { leagueCelebrate(sr); } catch (e) {} |
| } |
| } catch (e) { console.warn("league adopt failed", e); } |
| } |
| async function leagueResolvePrevious() { |
| if (!fbReady || !onlineAccount || !myUid) return; |
| await leagueAdoptServer(); |
| const w = onlineAccount.leagueWeek, g = onlineAccount.leagueGroup; |
| if (!w || !g || w === leagueWeekId()) return; |
| if (Date.now() < leagueWeekStart() + LEAGUE_GRACE_MS) return; // kurze Karenz: Nachzuegler-Siege der alten Woche sollen noch ankommen |
| const tier = onlineAccount.leagueTier || 0; |
| let grp; |
| try { grp = await dbGet(`weeklyLeague/${w}/${tier}/${g}`); if (grp) grp = leagueCleanGroup(grp); } |
| catch (e) { console.warn("league resolve read failed", e); return; } // try again later |
| let outcome = "stay", rank = 0, size = 0, wins = 0; |
| if (grp) { |
| const list = leagueSortMembers(Object.values(grp)); |
| size = list.length; |
| const idx = list.findIndex(m => m.uid === myUid); |
| if (idx >= 0) { rank = idx + 1; wins = list[idx].wins || 0; outcome = leagueOutcome(idx, size, wins, tier); } |
| } |
| let newTier = tier; |
| if (outcome === "up") newTier = Math.min(LEAGUES.length - 1, tier + 1); |
| if (outcome === "down") newTier = Math.max(0, tier - 1); |
| onlineAccount.leagueTier = newTier; |
| onlineAccount.leagueBest = Math.max(onlineAccount.leagueBest || 0, newTier); |
| onlineAccount.leagueLastResult = { week: w, rank, size, wins, outcome, from: tier, to: newTier, group: g }; |
| onlineAccount.leagueWeek = null; |
| onlineAccount.leagueGroup = null; |
| saveAccountLocal(); |
| updateAccountBtn(); |
| try { leagueCelebrate(onlineAccount.leagueLastResult); } catch (e) { console.warn(e); } |
| try { await pushAccountToFirebase(); } catch (e) { console.warn(e); } |
| } |
| |
| // Join a group of the current week (happens with the first counted win) |
| let _lgJoinedWithWin = false; |
| async function leagueJoinCurrent() { |
| if (!fbReady || !onlineAccount || !myUid) return; |
| const week = leagueWeekId(); |
| if (onlineAccount.leagueWeek === week && onlineAccount.leagueGroup) return; |
| const tier = onlineAccount.leagueTier || 0; |
| const groups = leagueCleanGroups(await dbGet(`weeklyLeague/${week}/${tier}`)); |
| let gid = null; |
| for (const [id, g] of Object.entries(groups)) if (g && g[myUid]) { gid = id; break; } // already a member (e.g. other device) |
| if (!gid) { |
| let bestCount = -1; |
| for (const [id, g] of Object.entries(groups)) { |
| const c = g ? Object.keys(g).length : 0; |
| if (c < LEAGUE_GROUP_SIZE && c > bestCount) { bestCount = c; gid = id; } // fill the fullest group that still has room |
| } |
| } |
| if (!gid) gid = "g" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); |
| if (!(groups[gid] && groups[gid][myUid])) { |
| await RPC.setLeagueMember(week, tier, gid, { uid: myUid, name: onlineAccount.name, wins: 1, lastWinAt: Date.now(), joinedAt: Date.now() }); |
| _lgJoinedWithWin = true; // der erste Sieg ist damit schon eingetragen |
| } |
| leagueHistoryAdd(week, tier, gid); |
| onlineAccount.leagueWeek = week; |
| onlineAccount.leagueGroup = gid; |
| saveAccountLocal(); |
| await pushAccountToFirebase(); |
| } |
| // Cleanup (own entries only, local history because the rules only allow writing your own uid) |
| function leagueHistoryGet() { try { return JSON.parse(localStorage.getItem("dobLeagueHistory") || "[]"); } catch (e) { return []; } } |
| function leagueHistorySet(h) { try { localStorage.setItem("dobLeagueHistory", JSON.stringify(h)); } catch (e) {} } |
| function leagueHistoryAdd(week, tier, gid) { |
| const h = leagueHistoryGet(); |
| if (!h.some(e => e.w === week && e.t === tier && e.g === gid)) { h.push({ w: week, t: tier, g: gid }); leagueHistorySet(h); } |
| } |
| async function leagueCleanupOld() { |
| if (!fbReady || !myUid) return; |
| const cutoff = leagueWeekId(Date.now() - LEAGUE_KEEP_WEEKS * 7 * 86400000); |
| const keep = []; |
| for (const e of leagueHistoryGet()) { |
| if (e.w < cutoff) { try { await RPC.removeLeagueMember(e.w, e.t, e.g); } catch (err) { keep.push(e); } } |
| else keep.push(e); |
| } |
| leagueHistorySet(keep); |
| } |
| // Wochenwechsel waehrend man online ist: im Menue (oder Liga-Screen) wird die alte Woche automatisch ausgewertet und der Screen gezeigt. |
| // Waehrend eines Matches / Survival / Streak passiert nichts, das kommt erst, wenn man wieder im Menue ist. |
| setInterval(() => { |
| if (!fbReady || !onlineAccount || !myUid) return; |
| if (!onlineAccount.leagueWeek || onlineAccount.leagueWeek === leagueWeekId()) return; |
| const m = document.getElementById("menu"), l = document.getElementById("leagueScreen"); |
| if (!((m && m.style.display === "flex") || (l && l.style.display === "flex"))) return; |
| leagueResolvePrevious().catch(() => {}); |
| }, 10000); |
| setInterval(() => { |
| if (!onlineAccount || !onlineAccount.leagueLastResult) return; |
| const m = document.getElementById("menu"); |
| if (m && m.style.display === "flex") { try { leagueCelebrate(onlineAccount.leagueLastResult); } catch (e) {} } |
| }, 8000); |
| function leagueEnsureJoined() { |
| if (_leagueBusy) return _leagueBusy; |
| _leagueBusy = (async () => { |
| try { |
| const wait = leagueWeekStart() + LEAGUE_GRACE_MS - Date.now(); |
| if (wait > 0 && onlineAccount && onlineAccount.leagueWeek && onlineAccount.leagueWeek !== leagueWeekId()) await new Promise(r => setTimeout(r, wait + 300)); |
| await leagueResolvePrevious(); await leagueJoinCurrent(); leagueCleanupOld().catch(() => {}); } |
| finally { _leagueBusy = null; } |
| })(); |
| return _leagueBusy; |
| } |
| |
| // Counts one online win (including wins against the Online Bot) for the weekly league |
| async function leagueRecordWin() { |
| if (!fbReady || !onlineAccount || !myUid) return; |
| _lgJoinedWithWin = false; |
| await leagueEnsureJoined(); |
| const week = onlineAccount.leagueWeek, gid = onlineAccount.leagueGroup, tier = onlineAccount.leagueTier || 0; |
| if (!week || !gid) return; |
| if (_lgJoinedWithWin) { _lgJoinedWithWin = false; lastLeagueNote = tr("+1 league win") + " (1)"; updateWinnerLeagueLine(); return; } |
| const cur = (await dbGet(`weeklyLeague/${week}/${tier}/${gid}/${myUid}`)) || { joinedAt: Date.now() }; |
| const wins = (cur.wins || 0) + 1; |
| await RPC.setLeagueMember(week, tier, gid, { uid: myUid, name: onlineAccount.name, wins, lastWinAt: Date.now(), joinedAt: cur.joinedAt || Date.now() }); |
| lastLeagueNote = tr("+1 league win") + " (" + wins + ")"; |
| updateWinnerLeagueLine(); |
| } |
| |
| // ── Weekly league screen ── |
| async function showLeagueScreen() { |
| if (!onlineAccount) { showCreateAccount(); return; } |
| showScreen("leagueScreen"); |
| leagueViewTier = null; leagueViewGid = null; |
| const box = document.getElementById("leagueContent"); |
| box.textContent = tr("Loading league…"); |
| if (!fbReady) { box.textContent = tr("Firebase not connected"); return; } |
| try { |
| await leagueResolvePrevious(); |
| await renderLeague(); |
| } catch (e) { console.warn("league screen failed", e); box.textContent = tr("Error while loading"); return; } |
| clearInterval(leagueTimer); |
| leagueTimer = setInterval(async () => { |
| const scr = document.getElementById("leagueScreen"); |
| if (!scr || scr.style.display !== "flex") { clearInterval(leagueTimer); leagueTimer = null; return; } |
| try { await renderLeague(); } catch (e) {} |
| }, 20000); |
| } |
| async function renderLeague() { |
| const box = document.getElementById("leagueContent"); |
| if (!box || !onlineAccount) return; |
| const tier = onlineAccount.leagueTier || 0, week = leagueWeekId(); |
| const vt = leagueViewTier == null ? tier : leagueViewTier, L = leagueByTier(vt); // vt = angeschaute Liga |
| const ws = leagueWeekStart(Date.now()), pct = Math.min(100, Math.max(0, (Date.now() - ws) / (leagueWeekEnd(Date.now()) - ws) * 100)); |
| const grpId = onlineAccount.leagueWeek === week ? onlineAccount.leagueGroup : null; |
| const groups = leagueCleanGroups(await dbGet(`weeklyLeague/${week}/${vt}`)); |
| const gids = Object.keys(groups).filter(id => groups[id] && Object.keys(groups[id]).length) |
| .sort((a, b) => ((vt === tier && b === grpId) - (vt === tier && a === grpId)) || Object.keys(groups[b]).length - Object.keys(groups[a]).length || (a < b ? -1 : 1)); |
| const shownGid = (leagueViewGid && gids.includes(leagueViewGid)) ? leagueViewGid |
| : (vt === tier && grpId && gids.includes(grpId) ? grpId : (gids[0] || null)); |
| const isOwn = vt === tier && !!grpId && shownGid === grpId; // eigene Gruppe = volle Ansicht |
| let list = [], n = 0, z = leagueZones(0, vt), myIdx = -1; |
| if (shownGid) { |
| list = leagueSortMembers(Object.values(groups[shownGid])); n = list.length; z = leagueZones(n, vt); |
| myIdx = list.findIndex(m => m.uid === myUid); |
| } |
| const myWins = myIdx >= 0 ? (list[myIdx].wins || 0) : 0; |
| |
| // 1) Hero |
| let html = `<div class="lg-hero" style="--lc:${L.color}"> |
| <div class="lg-hero-icon">${L.icon}</div> |
| <div class="lg-hero-name">${escapeHtml(tr(L.name))}</div> |
| <div class="lg-hero-sub">${vt === tier ? tr("Your league this week") : "👁 " + tr("Viewing only")}${shownGid ? " · " + n + "/" + LEAGUE_GROUP_SIZE + " " + tr("players") : ""}</div> |
| <div class="lg-time"><div class="lg-time-top"><span>${tr("Mon")}</span><b>${leagueCountdownText()}</b><span>${tr("Sun")}</span></div> |
| <div class="lg-bar"><i style="width:${pct.toFixed(1)}%"></i></div></div></div>`; |
| |
| // 2) Ladder |
| html += `<div class="lg-ladder">` + LEAGUES.map((l, i) => |
| `<div class="lg-step ${i < tier ? "done" : i === tier ? "now" : ""} ${i === vt ? "sel" : ""}" style="--c:${l.color}" onclick="leagueView(${i})"><span>${l.icon}</span>${escapeHtml(tr(l.name))}</div>` |
| ).join('<div class="lg-arrow">›</div>') + `</div>`; |
| // Gruppen-Auswahl (nur anschauen), wenn es in dieser Liga mehrere Gruppen gibt |
| if (gids.length > 1) { |
| html += `<div class="lg-groups">` + gids.map((id, i) => `<button class="lg-chip ${id === shownGid ? "on" : ""}" onclick="leagueView(${vt},'${escapeHtml(id)}')">${tr("Group")} ${i + 1}${vt === tier && id === grpId ? " ★" : ""}<small>${Object.keys(groups[id]).length}/${LEAGUE_GROUP_SIZE}</small></button>`).join("") + `</div>`; |
| } |
| |
| // 3) Last week |
| const r = onlineAccount.leagueLastResult; |
| if (r && vt === tier && isOwn) { |
| const cls = r.outcome === "up" ? "up" : r.outcome === "down" ? "down" : ""; |
| const verb = r.outcome === "up" ? "⬆ " + tr("Promoted to") + " " + leagueLabel(r.to) |
| : r.outcome === "down" ? "⬇ " + tr("Relegated to") + " " + leagueLabel(r.to) |
| : "= " + tr("Stayed in") + " " + leagueLabel(r.to); |
| html += `<div class="lg-card ${cls}"><h4>${tr("Last week")}</h4>#${r.rank || "-"}/${r.size || "-"} · ${r.wins || 0} ${tr("Wins")} → ${verb}</div>`; |
| } |
| |
| if (isOwn && myIdx >= 0) { |
| // 4) Status |
| let cls = "safe", title, text; |
| if (myIdx < z.up) { |
| if (myWins >= LEAGUE_MIN_WINS_PROMOTE) { cls = "up"; title = "🟢 " + tr("You're in the promotion zone!"); text = tr("Keep your spot until Sunday and you move up."); } |
| else { cls = "up"; title = "🟡 " + tr("Almost there!"); text = (LEAGUE_MIN_WINS_PROMOTE - myWins) + " " + tr("more wins needed – promotion needs at least") + " " + LEAGUE_MIN_WINS_PROMOTE + " " + tr("Wins") + "."; } |
| } else if (z.down > 0 && myIdx >= n - z.down) { |
| cls = "down"; title = "🔴 " + tr("You're in the relegation zone!"); text = tr("Win a few matches to climb out before the week ends."); |
| } else if (z.up > 0) { |
| const gap = Math.max(1, (list[z.up - 1].wins || 0) - myWins + 1); |
| title = "🔵 " + tr("You're safe"); text = "~" + gap + " " + tr("more wins to reach the promotion zone") + "."; |
| } else if (tier < LEAGUES.length - 1) { |
| title = "👥 " + tr("Group too small"); text = tr("Groups under") + " " + LEAGUE_MIN_GROUP_PROMOTE + " " + tr("players can't promote anyone this week."); |
| } else { title = "👑 " + tr("Top league"); text = tr("You can't be promoted further – defend your place!"); } |
| html += `<div class="lg-card ${cls}"><h4>${title}</h4>${text}</div>`; |
| if (z.up > 0) { |
| const got = Math.min(myWins, LEAGUE_MIN_WINS_PROMOTE), ok = got >= LEAGUE_MIN_WINS_PROMOTE; |
| html += `<div class="lg-prog"><div class="lg-prog-top"><span>${tr("Promotion eligibility")}</span><b>${got}/${LEAGUE_MIN_WINS_PROMOTE} ${tr("Wins")}${ok ? " ✓" : ""}</b></div> |
| <div class="lg-bar ${ok ? "ok" : ""}"><i style="width:${(got / LEAGUE_MIN_WINS_PROMOTE * 100).toFixed(0)}%"></i></div></div>`; |
| } |
| html += `<div class="lg-stats"> |
| <div class="lg-stat"><b>#${myIdx + 1}</b><small>${tr("Rank")}</small></div> |
| <div class="lg-stat"><b>${myWins}</b><small>${tr("Wins")}</small></div> |
| <div class="lg-stat"><b>${n}</b><small>${tr("players")}</small></div></div>`; |
| |
| // 5) Table with zone labels |
| html += `<div class="lg-table">`; |
| if (z.up) html += `<div class="lg-zone up">⬆ ${tr("Promotion zone")} · ${tr("Top")} ${z.up} · ${tr("min.")} ${LEAGUE_MIN_WINS_PROMOTE} ${tr("Wins")}</div>`; |
| list.forEach((m, i) => { |
| if (i === z.up && z.up) html += `<div class="lg-zone mid">${tr("Safe zone")}</div>`; |
| if (z.down && i === n - z.down) html += `<div class="lg-zone down">⬇ ${tr("Relegation zone")} · ${tr("Bottom")} ${z.down}</div>`; |
| const me = m.uid === myUid, medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : "#" + (i + 1); |
| html += `<div class="lg-row ${me ? "me" : ""}" style="--lc:${L.color}" onclick="openOnlineProfile('${escapeHtml(m.uid)}')"> |
| <span class="lg-rank">${medal}</span><span class="lg-av">${escapeHtml((m.name || "?").charAt(0).toUpperCase())}</span> |
| <span class="lg-name">${escapeHtml(dn(m.name))}${me ? "<small>" + tr("You") + "</small>" : ""}</span> |
| <span class="lg-wins">${m.wins || 0}</span></div>`; |
| }); |
| html += `</div>`; |
| } else if (shownGid) { |
| // Nur anschauen: andere Liga oder andere Gruppe |
| html += `<div class="lg-card"><h4>👁 ${tr("Viewing only")}</h4>${escapeHtml(tr(L.name))} · ${tr("Group")} ${gids.indexOf(shownGid) + 1}</div>`; |
| if (vt === tier && !grpId) html += `<div class="lg-card safe"><h4>🎯 ${tr("Not in a group yet")}</h4>${tr("Win a match to enter this week's league group.")}</div>`; |
| html += `<div class="lg-table">`; |
| if (z.up) html += `<div class="lg-zone up">⬆ ${tr("Promotion zone")} · ${tr("Top")} ${z.up} · ${tr("min.")} ${LEAGUE_MIN_WINS_PROMOTE} ${tr("Wins")}</div>`; |
| list.forEach((m, i) => { |
| if (i === z.up && z.up) html += `<div class="lg-zone mid">${tr("Safe zone")}</div>`; |
| if (z.down && i === n - z.down) html += `<div class="lg-zone down">⬇ ${tr("Relegation zone")} · ${tr("Bottom")} ${z.down}</div>`; |
| const me = m.uid === myUid, medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : "#" + (i + 1); |
| html += `<div class="lg-row ${me ? "me" : ""}" style="--lc:${L.color}" onclick="openOnlineProfile('${escapeHtml(m.uid)}')"> |
| <span class="lg-rank">${medal}</span><span class="lg-av">${escapeHtml((m.name || "?").charAt(0).toUpperCase())}</span> |
| <span class="lg-name">${escapeHtml(dn(m.name))}${me ? "<small>" + tr("You") + "</small>" : ""}</span> |
| <span class="lg-wins">${m.wins || 0}</span></div>`; |
| }); |
| html += `</div>`; |
| if (leagueViewTier != null || leagueViewGid) html += `<button onclick="leagueView(null)" style="margin:0;">← ${tr("My league")}</button>`; |
| } else { |
| if (vt === tier) html += `<div class="lg-card safe"><h4>🎯 ${tr("Not in a group yet")}</h4>${tr("Win a match to enter this week's league group.")}</div>`; |
| else html += `<div class="lg-card"><h4>👁 ${tr("Viewing only")}</h4>${tr("No groups in this league yet")}</div><button onclick="leagueView(null)" style="margin:0;">← ${tr("My league")}</button>`; |
| } |
| |
| if (r && r.group) html += `<button onclick="leagueShowLastRanking()" style="margin:0;">📜 ${tr("Last week's ranking")}</button>`; |
| |
| // 6) How it works |
| html += `<details class="lg-how" ${grpId ? "" : "open"}><summary>❓ ${tr("How does the league work?")}</summary><div class="lg-steps"> |
| <div class="lg-s"><b>1</b><div><strong>${tr("Win matches")}</strong><br>${tr("Every online win this week = 1 point. Wins against the Online Bot count too.")}</div></div> |
| <div class="lg-s"><b>2</b><div><strong>${tr("Your group")}</strong><br>${tr("You compete against up to")} ${LEAGUE_GROUP_SIZE} ${tr("players of your league. Ties are won by whoever got there first.")}</div></div> |
| <div class="lg-s"><b>3</b><div><strong>${tr("Sunday = decision")}</strong><br>${tr("The week ends Sunday 24:00 (German time). A new week starts Monday with fresh groups and 0 wins.")}</div></div> |
| <div class="lg-s"><b>4</b><div><strong>${tr("Up or down")}</strong><br>${tr("Top players move up (at least")} ${LEAGUE_MIN_WINS_PROMOTE} ${tr("wins needed), the bottom ones drop a league, everyone else stays.")}</div></div> |
| <div class="lg-s"><b>🏆</b><div><strong>${LEAGUES.map(l => l.icon).join(" → ")}</strong><br>${tr("Reach Platinum – your ELO is not affected by the league.")}</div></div> |
| </div></details> |
| <div class="lg-foot">${tr("Every win counts, including wins against the Online Bot.")}</div>`; |
| box.innerHTML = html; |
| leagueCelebrate(r); |
| } |
| |
| // Einmaliger Screen nach der Wochenauswertung (Aufstieg / Abstieg / geblieben). force = Testmodus |
| function leagueCelebrate(r, force) { |
| if (!r || (!force && ((!r.rank && r.outcome !== "idle") || document.querySelector(".lg-celebrate")))) return; |
| if (!force) { |
| let seen = null; try { seen = localStorage.getItem("dobLeagueSeen"); } catch (e) {} |
| if (seen === r.week) return; |
| try { localStorage.setItem("dobLeagueSeen", r.week); } catch (e) {} |
| } |
| const l = leagueByTier(r.to), up = r.outcome === "up", down = r.outcome === "down"; |
| const cols = ["#00ff88", "#00c8ff", "#ffd700", "#ff4444", l.color]; |
| let conf = ""; |
| if (up) for (let i = 0; i < 60; i++) conf += `<i style="left:${Math.random() * 100}%;background:${cols[i % 5]};animation-delay:${(Math.random() * 0.9).toFixed(2)}s;animation-duration:${(2 + Math.random() * 1.6).toFixed(2)}s;--dx:${Math.round(Math.random() * 160 - 80)}px"></i>`; |
| const title = up ? tr("Congratulations!") : down ? tr("Relegated") : tr("Well played!"); |
| const line = up ? tr("You were promoted to") : down ? tr("You were relegated to") : tr("You stay in"); |
| const msg = r.outcome === "idle" ? tr("You did not play last week") : up ? tr("Welcome to the next league – new week, new goals!") : down ? tr("New week, new chance – win your way back up!") : tr("New week – push for the top!"); |
| const ov = document.createElement("div"); |
| ov.className = "lg-celebrate " + (down ? "down" : "up"); |
| ov.style.setProperty("--lc", l.color); |
| ov.innerHTML = conf + `<div class="lg-cel-box"><div class="lg-cel-icon">${down ? "📉" : l.icon}</div> |
| <div class="lg-cel-title">${title}</div> |
| <div class="lg-cel-meta" style="margin-top:8px;">${line}</div> |
| <div class="lg-cel-sub">${l.icon} ${escapeHtml(tr(l.name))}</div> |
| <div class="lg-cel-meta">${tr("Last week")}: #${r.rank || "-"}/${r.size || "-"} · ${r.wins || 0} ${tr("Wins")}</div> |
| <div class="lg-cel-meta">${msg}</div> |
| <div><button class="lgRk">📜 ${tr("Last week's ranking")}</button><button class="lgOk">OK</button></div></div>`; |
| const close = () => { ov.classList.add("out"); setTimeout(() => ov.remove(), 300); }; |
| ov.querySelector(".lgOk").addEventListener("click", e => { e.stopPropagation(); close(); }); |
| ov.querySelector(".lgRk").addEventListener("click", e => { e.stopPropagation(); close(); leagueShowLastRanking(r); }); |
| document.body.appendChild(ov); |
| } |
| |
| // Rangliste der vergangenen Woche (eigene Gruppe, bleibt ca. 4 Wochen in der Datenbank) |
| async function leagueShowLastRanking(r) { |
| r = r || (onlineAccount && onlineAccount.leagueLastResult); |
| if (!r || !r.group) { leagueToast(tr("No ranking available"), "#ff8844"); return; } |
| const old = document.getElementById("lgRankOv"); if (old) old.remove(); |
| const L = leagueByTier(r.from); |
| const ov = document.createElement("div"); |
| ov.id = "lgRankOv"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:210;display:flex;align-items:center;justify-content:center;background:rgba(5,5,10,.85);backdrop-filter:blur(6px);padding:14px;box-sizing:border-box;"; |
| ov.style.setProperty("--lc", L.color); |
| ov.innerHTML = `<div style="width:460px;max-width:96vw;max-height:92vh;display:flex;flex-direction:column;gap:10px;background:#0d0f16;border:1px solid ${L.color};border-radius:16px;padding:16px;color:#eaf2fb;"> |
| <div style="display:flex;justify-content:space-between;align-items:center;"><b style="font-size:18px;font-family:var(--font-display);">📜 ${tr("Last week's ranking")}</b><button class="lgX" style="margin:0;padding:4px 10px;font-size:14px;">✕</button></div> |
| <div style="font-size:13px;color:var(--text-dim);">${L.icon} ${escapeHtml(tr(L.name))} · ${tr("Week of")} ${escapeHtml(r.week)}</div> |
| <div class="lgBody lg-table" style="max-height:60vh;">${tr("Loading league…")}</div></div>`; |
| ov.addEventListener("click", e => { if (e.target === ov) ov.remove(); }); |
| ov.querySelector(".lgX").onclick = () => ov.remove(); |
| document.body.appendChild(ov); |
| const body = ov.querySelector(".lgBody"); |
| let list = []; |
| { |
| try { |
| const grp = leagueCleanGroup(await dbGet(`weeklyLeague/${r.week}/${r.from}/${r.group}`)); |
| list = grp ? leagueSortMembers(Object.values(grp)) : []; |
| } catch (e) { body.textContent = tr("Error while loading"); return; } |
| } |
| if (!list.length) { body.textContent = tr("No ranking available"); return; } |
| const n = list.length, z = leagueZones(n, r.from); |
| let html = ""; |
| if (z.up) html += `<div class="lg-zone up">⬆ ${tr("Promotion zone")} · ${tr("Top")} ${z.up} · ${tr("min.")} ${LEAGUE_MIN_WINS_PROMOTE} ${tr("Wins")}</div>`; |
| list.forEach((m, i) => { |
| if (i === z.up && z.up) html += `<div class="lg-zone mid">${tr("Safe zone")}</div>`; |
| if (z.down && i === n - z.down) html += `<div class="lg-zone down">⬇ ${tr("Relegation zone")} · ${tr("Bottom")} ${z.down}</div>`; |
| const me = (myUid && m.uid === myUid), medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : "#" + (i + 1); |
| html += `<div class="lg-row ${me ? "me" : ""}" style="--lc:${L.color}" onclick="openOnlineProfile('${escapeHtml(m.uid)}')"> |
| <span class="lg-rank">${medal}</span><span class="lg-av">${escapeHtml((m.name || "?").charAt(0).toUpperCase())}</span> |
| <span class="lg-name">${escapeHtml(dn(m.name))}${me ? "<small>" + tr("You") + "</small>" : ""}</span> |
| <span class="lg-wins">${m.wins || 0}</span></div>`; |
| }); |
| body.innerHTML = html; |
| } |
| |
| const DATA_VERSION = "2"; // bei jedem kuenftigen Reset hochzaehlen |
| if (localStorage.getItem("dobDataVersion") !== DATA_VERSION) { |
| localStorage.removeItem("dobAccount"); |
| localStorage.removeItem("onlineBotElo"); |
| Object.keys(localStorage).filter(k => k.startsWith("h2h_bot_")).forEach(k => localStorage.removeItem(k)); |
| localStorage.setItem("dobDataVersion", DATA_VERSION); |
| } |
| let onlineAccount = null; |
| try { |
| onlineAccount = JSON.parse(localStorage.getItem("dobAccount") || "null"); |
| if (onlineAccount) onlineAccount = sanitizeAccount(onlineAccount); |
| } catch(e) {} |
| |
| function saveAccountLocal() { |
| if (onlineAccount) localStorage.setItem("dobAccount", JSON.stringify(onlineAccount)); |
| } |
| |
| // Cross-device sync: the server copy is the source of truth for stats. Without this, a device with an old local |
| // copy pushed its stale numbers over the newer ones from another device (set() overwrites the whole record). |
| // League fields are skipped here because leagueAdoptServer() has its own logic for them. |
| const _SYNC_SKIP = new Set(["uid", "status", "lastActive", "updatedAt", "leagueTier", "leagueBest", "leagueWeek", "leagueGroup", "leagueLastResult"]); |
| async function pullAccountFromServer() { |
| if (!fbReady || !myUid || !onlineAccount) return false; |
| try { |
| const sv = await dbGet("players/" + myUid); |
| if (!sv || typeof sv !== "object") return false; |
| const merged = Object.assign({}, onlineAccount); |
| for (const k of Object.keys(sv)) if (!_SYNC_SKIP.has(k)) merged[k] = sv[k]; |
| ["survPlayed", "streakPlayed"].forEach(k => { merged[k] = Math.max(Number(sv[k]) || 0, Number(onlineAccount[k]) || 0); }); // Run-Zaehler nie zuruecksetzen |
| onlineAccount = sanitizeAccount(merged); |
| saveAccountLocal(); |
| try { updateAccountBtn(); } catch (e) {} |
| return true; |
| } catch (e) { console.warn("account pull failed", e); return false; } |
| } |
| |
| // CHANGED: writes to players/{myUid} via the guarded RPC (own data |
| // only), and stores `name` as a field rather than as the key. |
| async function pushAccountToFirebase() { |
| if (!fbReady || !onlineAccount || !myUid) return; |
| await RPC.setOwnPlayer({ |
| uid: myUid, |
| name: onlineAccount.name, |
| elo: onlineAccount.elo, |
| wins: onlineAccount.wins, |
| losses: onlineAccount.losses, |
| highestElo: onlineAccount.highestElo, |
| lowestElo: onlineAccount.lowestElo, |
| currentStreak: onlineAccount.currentStreak, |
| bestStreak: onlineAccount.bestStreak, |
| totalEloGained: onlineAccount.totalEloGained, |
| totalEloLost: onlineAccount.totalEloLost, |
| bestWinChange: onlineAccount.bestWinChange, |
| bestWinOpp: onlineAccount.bestWinOpp, |
| worstLossChange: onlineAccount.worstLossChange, |
| worstLossOpp: onlineAccount.worstLossOpp, |
| survPlayed: onlineAccount.survPlayed || 0, |
| streakPlayed: onlineAccount.streakPlayed || 0, |
| leagueTier: onlineAccount.leagueTier ?? 0, |
| leagueBest: onlineAccount.leagueBest ?? 0, |
| leagueWeek: onlineAccount.leagueWeek ?? null, |
| leagueGroup: onlineAccount.leagueGroup ?? null, |
| leagueLastResult: onlineAccount.leagueLastResult ?? null, |
| updatedAt: Date.now() |
| }); |
| // Maintain the name → uid lookup index so leaderboard / profile / |
| // "is this name taken" checks don't need to scan all players. |
| await RPC.setUsernameIndex(onlineAccount.name, myUid); |
| } |
| |
| // CHANGED: renaming now updates ONLY the caller's own uid-keyed |
| // record (`players/{myUid}.name`) plus the username index — it never |
| // deletes or writes another player's node, unlike the old |
| // `dbRemove("players/" + oldName)` which depended on names being keys. |
| async function changeUsername(newName) { |
| if (!fbReady || !onlineAccount || !myUid) return; |
| newName = newName.trim(); |
| |
| if (newName.length < 2) { |
| alert("Name must be at least 2 characters."); |
| return; |
| } |
| if (!/^[a-zA-Z0-9_-]+$/.test(newName)) { |
| alert("Only letters, numbers, _ and - allowed."); |
| return; |
| } |
| if (nameIsBlocked(newName)) { |
| alert("This name is not allowed."); |
| return; |
| } |
| |
| const oldName = onlineAccount.name; |
| if (oldName === newName) return; |
| |
| const existingUid = await dbGet("usernames/" + newName.toLowerCase()); |
| if (existingUid && existingUid !== myUid) { |
| alert("This name is already taken!"); |
| return; |
| } |
| |
| await RPC.removeUsernameIndex(oldName); |
| onlineAccount.name = newName; |
| saveAccountLocal(); |
| await pushAccountToFirebase(); // also re-creates the new username index |
| |
| // Liga-Eintrag und alte Chatnachrichten auf den neuen Namen ziehen (Fehler hier brechen die Umbenennung nicht ab) |
| try { |
| if (onlineAccount.leagueWeek && onlineAccount.leagueGroup) |
| await RPC.renameLeagueEntry(onlineAccount.leagueWeek, onlineAccount.leagueTier || 0, onlineAccount.leagueGroup, myUid, newName); |
| } catch (e) { console.warn("league rename failed", e); } |
| try { await RPC.renameChatMessages(myUid, newName); } catch (e) { console.warn("chat rename failed", e); } |
| |
| alert("Name successfully changed!"); |
| if (document.getElementById("leaderboardScreen").style.display === "flex") { |
| loadLeaderboard(); |
| } |
| } |
| |
| window.triggerNameChange = function() { |
| if (!onlineAccount) { |
| alert("You need to create an account first!"); |
| return; |
| } |
| const newName = prompt("Enter your new username:", onlineAccount.name); |
| if (newName !== null && newName.trim() !== "") { |
| changeUsername(newName); |
| } |
| }; |
| |
| |
| // ═══════════════════════════════════════════════ |
| // SETTINGS |
| // ═══════════════════════════════════════════════ |
| let startBallCount = 3; |
| let spawnDelay = 5000; |
| let ballSpeedMultiplier = 0.5; |
| let playerRadiusSetting = 18; |
| let aiBotCount = 10; |
| let rankingMode = "points"; |
| |
| const ONLINE_START_BALLS = 0; |
| const ONLINE_SPAWN_MS = 1000; |
| const ONLINE_BALL_SPEED = 0.5; |
| |
| // ═══════════════════════════════════════════════ |
| // BALL COLOR + TRAIL (persisted to localStorage) |
| // ═══════════════════════════════════════════════ |
| const BALL_COLOR_PRESETS = ["#00ff88","#00c8ff","#ff4444","#ffd700","#ff88aa","#a855f7","#ffffff","#ff8844"]; |
| let myBallColor = localStorage.getItem("dobBallColor") || "#00ff88"; |
| let ballTrailEnabled = localStorage.getItem("dobBallTrail") !== "0"; |
| let myTrail = []; |
| |
| function isLocalPlayer(p) { |
| if (!p) return false; |
| if (p.name === "Player" || p.name === "YOU" || p.name === "Player 1") return true; |
| if (mode === "online" && p.name === myPlayName()) return true; |
| return false; |
| } |
| |
| function setBallColor(color) { |
| myBallColor = color; |
| localStorage.setItem("dobBallColor", color); |
| renderBallColorUI(); |
| } |
| |
| function setBallTrail(enabled) { |
| ballTrailEnabled = enabled; |
| localStorage.setItem("dobBallTrail", enabled ? "1" : "0"); |
| if (!enabled) myTrail = []; |
| } |
| |
| function renderBallColorUI() { |
| const row = document.getElementById("ballColorRow"); |
| if (!row) return; |
| row.innerHTML = ""; |
| for (const c of BALL_COLOR_PRESETS) { |
| const sw = document.createElement("button"); |
| sw.type = "button"; |
| sw.className = "color-swatch" + (c.toLowerCase() === myBallColor.toLowerCase() ? " active" : ""); |
| sw.style.background = c; |
| sw.title = c; |
| sw.onclick = () => setBallColor(c); |
| row.appendChild(sw); |
| } |
| const isCustom = !BALL_COLOR_PRESETS.some(c => c.toLowerCase() === myBallColor.toLowerCase()); |
| const customInput = document.createElement("input"); |
| customInput.type = "color"; |
| customInput.id = "ballColorCustom"; |
| customInput.title = "Custom color"; |
| customInput.value = isCustom ? myBallColor : "#ffffff"; |
| if (isCustom) customInput.classList.add("active"); |
| customInput.oninput = (e) => setBallColor(e.target.value); |
| row.appendChild(customInput); |
| |
| const trailToggle = document.getElementById("ballTrailToggle"); |
| if (trailToggle) trailToggle.checked = ballTrailEnabled; |
| |
| drawBallPreview(); |
| } |
| |
| function drawBallPreview() { |
| const cvs = document.getElementById("ballPreviewCanvas"); |
| if (!cvs) return; |
| const pctx = cvs.getContext("2d"); |
| pctx.clearRect(0, 0, cvs.width, cvs.height); |
| const cx = cvs.width / 2, cy = cvs.height / 2; |
| if (ballTrailEnabled) { |
| for (let i = 3; i >= 1; i--) { |
| pctx.beginPath(); |
| pctx.arc(cx - i * 9, cy, 16 - i * 3, 0, Math.PI * 2); |
| pctx.fillStyle = myBallColor; |
| pctx.globalAlpha = 0.12 * (4 - i); |
| pctx.fill(); |
| } |
| pctx.globalAlpha = 1; |
| } |
| pctx.beginPath(); |
| pctx.arc(cx, cy, 18, 0, Math.PI * 2); |
| pctx.fillStyle = myBallColor; |
| pctx.shadowColor = myBallColor; |
| pctx.shadowBlur = 18; |
| pctx.fill(); |
| pctx.shadowBlur = 0; |
| } |
| |
| function openSettings() { showScreen("settings"); renderBallColorUI(); } |
| |
| // Offline-Einstellungen (nur Offline-Modi), in localStorage gespeichert |
| function openOfflineSettings() { |
| document.getElementById("startBalls").value = startBallCount; |
| document.getElementById("spawnTime").value = spawnDelay / 1000; |
| document.getElementById("ballSpeed").value = String(ballSpeedMultiplier); |
| document.getElementById("botCount").value = aiBotCount; |
| document.getElementById("rankingMode").value = rankingMode; |
| showScreen("offlineSettings"); |
| } |
| function saveSettings() { |
| startBallCount = Math.max(1, Number(document.getElementById("startBalls").value) || 3); |
| spawnDelay = Math.max(1, Number(document.getElementById("spawnTime").value) || 5) * 1000; |
| ballSpeedMultiplier = Number(document.getElementById("ballSpeed").value); |
| aiBotCount = Math.max(2, Number(document.getElementById("botCount").value) || 10); |
| rankingMode = document.getElementById("rankingMode").value; |
| try { |
| localStorage.setItem("dobOfflineSettings", JSON.stringify({ |
| startBallCount, spawnDelay, ballSpeedMultiplier, aiBotCount, rankingMode |
| })); |
| } catch(e) {} |
| showScreen("menu"); |
| showMenuPage("menuOffline"); |
| } |
| (function loadOfflineSettings() { |
| try { |
| const o = JSON.parse(localStorage.getItem("dobOfflineSettings") || "null"); |
| if (!o) return; |
| if (Number.isFinite(o.startBallCount)) startBallCount = o.startBallCount; |
| if (Number.isFinite(o.spawnDelay)) spawnDelay = o.spawnDelay; |
| if (Number.isFinite(o.ballSpeedMultiplier)) ballSpeedMultiplier = o.ballSpeedMultiplier; |
| if (Number.isFinite(o.aiBotCount)) aiBotCount = o.aiBotCount; |
| if (o.rankingMode === "points" || o.rankingMode === "elo") rankingMode = o.rankingMode; |
| } catch(e) {} |
| })(); |
| |
| // ═══════════════════════════════════════════════ |
| // STATE |
| // ═══════════════════════════════════════════════ |
| let balls = [], players = [], ranking = [], eliminated = []; |
| let running = false, startTime = 0; |
| let spawnTimeoutId = null, survivalIntervalId = null, coinIntervalId = null; |
| let mode = "single"; |
| let tournamentRounds = 100, currentRound = 1; |
| let participants = [], matches = [], waitingQueue = []; |
| let playerMatchMap = new Map(), recentOpponents = new Map(); |
| let keys = {}; |
| |
| let spectateModeActive = false, spectateMatchId = null; |
| let followMode = "none", targetName = null, humanRemovedFromQueue = false; |
| |
| let coins = [], coinScorePlayer = 0, coinTimeLeft = 60; |
| let mirrorBot = null; |
| let survivalWave = 1; |
| |
| // ═══════════════════════════════════════════════ |
| // ONLINE STATE |
| // ═══════════════════════════════════════════════ |
| let onlineMatchId = null; |
| let onlineRole = null; // "p1" or "p2" |
| let onlineOpponent = null; // { uid, name, elo, ... } |
| let onlineUnsubscribes = []; |
| let onlineSearchInterval = null; |
| let onlineResultHandled = false; |
| let onlineIsHost = false; |
| let onlineVsBot = false; |
| let onlineTvUnsubscribe = null; |
| let onlineTvInnerUnsubscribe = null; |
| let onlineTvCurrentMatchId = null; |
| let tvClock = null; // Online TV: Zeit des gerade angeschauten Matches/Runs { base (s), at (ms) } |
| function tvClockSec() { return tvClock ? Math.max(0, Math.floor(tvClock.base + (Date.now() - tvClock.at) / 1000)) : 0; } |
| |
| let onlineRng = null; |
| let onlineBallSpawnTimer = 0; |
| let onlineSearchStartTime = 0; |
| let onlineBotMatchTimeout = null; |
| let onlineBotElo = parseInt(localStorage.getItem("onlineBotElo")) || 1500; |
| let onlineSimTick = 0; |
| let onlineMatchStamp = 0; |
| let onlineRound = 0, postUnsub = null, rematchSent = false, lastPostChat = 0; |
| let onlineBallIv = null; |
| |
| let record = parseInt(localStorage.getItem("fingerDodgeRecord") || "0"); |
| document.getElementById("record").textContent = record; |
| |
| let matchBanner = null; |
| |
| // ═══════════════════════════════════════════════ |
| // NETWORK SMOOTHING (NEW) |
| // ───────────────────────────────────────────── |
| // Goal: YOUR screen should look smooth even when the OPPONENT has a |
| // bad/unstable connection. Their packets may arrive late or with |
| // irregular gaps — we adapt how we reconstruct their motion based on |
| // the ACTUAL measured gap between their last two packets, instead of |
| // a fixed catch-up speed. We also keep extrapolating along their |
| // last known velocity between packets (dead-reckoning) so motion |
| // never visibly freezes, and we clamp the extrapolation distance so |
| // a long dropout doesn't fling them across the map. |
| // ═══════════════════════════════════════════════ |
| const NET_SMOOTH = { |
| // Minimum/maximum opponent packet interval we plan around (ms). |
| // Below this we trust packets are basically "live"; above this we |
| // treat the connection as laggy and lean more on extrapolation. |
| MIN_INTERVAL: 60, |
| MAX_INTERVAL: 1000, |
| // Maximum distance (px, virtual coords) we'll extrapolate the |
| // opponent forward without a fresh packet, to avoid them flying |
| // off if their connection drops entirely. |
| MAX_EXTRAPOLATE_DIST: 140, |
| // After this long with no packet at all, stop extrapolating and |
| // just hold position (still alive-looking, just paused). |
| FREEZE_AFTER_MS: 1200 |
| }; |
| |
| // Gemeinsame Paket-Verarbeitung für Online-Gegner UND Online TV (identisch sauber) |
| function feedNetPlayer(pl, st, stopMs) { |
| const now = Date.now(); |
| // Jedes Match-Update (Ball, anderer Spieler ...) liefert den Spielerstand erneut. |
| // Unveränderte Daten sind KEIN neues Paket -> ignorieren (sonst Geschwindigkeit=0 / Zittern). |
| if (pl.lastPacketAt && st.x === pl.baseTx && st.y === pl.baseTy && (st.alive !== false) === pl.alive) { |
| // Unveraenderte Daten laenger als stopMs = Spieler steht still -> Extrapolation beenden, auf echte Position zurueck |
| if (stopMs && now - pl.lastPacketAt > stopMs) { pl.netVX = 0; pl.netVY = 0; pl.tx = st.x; pl.ty = st.y; } |
| return; |
| } |
| if (pl.lastPacketAt) { |
| const interval = Math.min(NET_SMOOTH.MAX_INTERVAL, Math.max(30, now - pl.lastPacketAt)); |
| let vx = (st.x - pl.baseTx) / interval, vy = (st.y - pl.baseTy) / interval; |
| const sp = Math.hypot(vx, vy), MAXV = 0.5; // px/ms |
| if (sp > MAXV) { vx *= MAXV / sp; vy *= MAXV / sp; } |
| pl.netVX = 0.5 * (pl.netVX || 0) + 0.5 * vx; |
| pl.netVY = 0.5 * (pl.netVY || 0) + 0.5 * vy; |
| pl.netInterval = interval; |
| if (Math.hypot(st.x - pl.baseTx, st.y - pl.baseTy) > 400) { pl.x = st.x; pl.y = st.y; pl.netVX = 0; pl.netVY = 0; } |
| } else { |
| pl.netVX = 0; pl.netVY = 0; |
| pl.x = st.x; pl.y = st.y; |
| pl.netInterval = NET_SMOOTH.MIN_INTERVAL; |
| } |
| pl.baseTx = st.x; pl.baseTy = st.y; |
| pl.tx = st.x; pl.ty = st.y; |
| pl.alive = st.alive !== false; |
| pl.lastPacketAt = now; |
| pl.lastPacketTime = now; |
| } |
| |
| // Returns an exponential-smoothing alpha for a given dt (ms) and a |
| // target "time to mostly catch up" (ms). This makes the catch-up |
| // speed independent of actual frame rate AND adapts automatically: |
| // callers pass a longer catch-up window when the opponent's own |
| // packet interval is large (laggy), and a short one when packets are |
| // arriving quickly (good connection) — so a good connection still |
| // feels snappy, and a bad one still looks smooth instead of jumpy. |
| function smoothingAlpha(dtMs, catchUpMs) { |
| if (catchUpMs <= 0) return 1; |
| // 1 - e^(-dt/tau): standard framerate-independent exponential smoothing. |
| return 1 - Math.exp(-dtMs / catchUpMs); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // HOME / EXIT |
| // ═══════════════════════════════════════════════ |
| // Eigener Dialog statt window.confirm() (wird in iOS-Webviews/Home-Screen-Apps oft blockiert) |
| function confirmHome() { |
| if (document.getElementById("homeConfirm")) return; |
| const ov = document.createElement("div"); |
| ov.id = "homeConfirm"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);"; |
| ov.innerHTML = '<div style="background:#14141e;border:1px solid rgba(255,255,255,0.15);border-radius:14px;padding:22px 26px;text-align:center;color:#f0f0f5;font-family:var(--font-display);">' + |
| '<div style="margin-bottom:16px;font-size:17px;">Return to main menu?</div>' + |
| '<button id="homeYes" style="margin:4px 6px;">Yes</button><button id="homeNo" style="margin:4px 6px;">Cancel</button></div>'; |
| document.body.appendChild(ov); |
| document.getElementById("homeYes").onclick = () => { ov.remove(); exitToMenu(); }; |
| document.getElementById("homeNo").onclick = () => ov.remove(); |
| } |
| |
| // Einladungs-Link: teilt die Spiel-URL (Handy: Teilen-Menue, sonst in die Zwischenablage kopieren) |
| async function inviteFriends() { |
| const url = (location.protocol === "file:") ? location.href : location.origin + location.pathname; |
| const text = "Play DarkOnBalls with me!"; |
| try { |
| if (navigator.share) { await navigator.share({ title: "DarkOnBalls", text, url }); return; } |
| } catch (e) { if (e && e.name === "AbortError") return; } |
| try { |
| await navigator.clipboard.writeText(url); |
| leagueToast("🔗 " + tr("Invite link copied!"), "#00ff88"); |
| } catch (e) { prompt(tr("Copy this link and send it to your friends:"), url); } |
| } |
| |
| function exitToMenu() { |
| try { cleanupOnline(); } catch (e) { console.warn(e); } |
| setTimeout(() => { |
| window.location.reload(); |
| }, 150); |
| } |
| |
| |
| function clearAllTimers() { |
| if (spawnTimeoutId) { clearTimeout(spawnTimeoutId); spawnTimeoutId = null; } |
| if (survivalIntervalId) { clearInterval(survivalIntervalId); survivalIntervalId = null; } |
| if (coinIntervalId) { clearInterval(coinIntervalId); coinIntervalId = null; } |
| if (onlineBotMatchTimeout) { clearTimeout(onlineBotMatchTimeout); onlineBotMatchTimeout = null; } |
| if (typeof streakCleanup === "function") streakCleanup(); |
| if (typeof campWarn === "function") campWarn(false); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // SCREEN ROUTER |
| // ═══════════════════════════════════════════════ |
| const ALL_SCREENS = [ |
| "menu","settings","offlineSettings","accountScreen","leaderboardScreen","leagueScreen", |
| "onlineWait","vsScreen","winnerOverlay","gameOver" |
| ]; |
| |
| function showMenuPage(id) { |
| ["menuMain","menuOnline","menuOffline"].forEach(p => { const el = document.getElementById(p); if (el) el.style.display = (p === id) ? "flex" : "none"; }); |
| } |
| function updateChatLock() { |
| const inp = document.getElementById("chatInput"), btn = document.getElementById("chatSendBtn"), note = document.getElementById("chatLockNote"); |
| if (!inp) return; |
| const locked = !onlineAccount; |
| inp.disabled = locked; |
| if (btn) btn.disabled = locked; |
| inp.placeholder = locked ? "Account required to write…" : "Message…"; |
| if (note) note.style.display = locked ? "block" : "none"; |
| if (locked) inp.value = ""; |
| } |
| setInterval(updateChatLock, 1000); |
| function updateAccountBtn() { |
| updateChatLock(); |
| const b = document.getElementById("accountBtn"); |
| if (b) b.textContent = onlineAccount ? leagueByTier(onlineAccount.leagueTier).icon + " " + dn(onlineAccount.name) : "👤 Log in / Sign up"; |
| } |
| setTimeout(updateAccountBtn, 0); |
| window.showScreen = function(id) { |
| if (id === "onlineFlow") { showScreen_onlineFlow(); return; } |
| ALL_SCREENS.forEach(s => { |
| const el = document.getElementById(s); |
| if (el) el.style.display = (s === id) ? "flex" : "none"; |
| }); |
| document.body.classList.remove("ingame", "mode-online", "mode-tv"); |
| if (id === "menu") updateAccountBtn(); |
| const hideHome = ["menu","settings","offlineSettings","accountScreen","leaderboardScreen","leagueScreen"].includes(id); |
| document.getElementById("homeBtn").style.display = hideHome ? "none" : "block"; |
| }; |
| |
| // ═══════════════════════════════════════════════ |
| // ELO ANIMATION |
| // ═══════════════════════════════════════════════ |
| function showEloAnimation(change) { |
| const el = document.getElementById("eloResult"); |
| const won = change > 0; |
| el.style.animation = "none"; |
| el.offsetHeight; |
| el.style.color = won ? "#00ff88" : "#ff4444"; |
| el.style.textShadow = `0 0 40px ${won ? "#00ff88" : "#ff4444"}`; |
| el.innerHTML = (won ? "✓ +" : "✗ ") + Math.round(Math.abs(change)) + " ELO<br>" |
| + (won ? '<span style="font-size:40px">WIN</span>' : '<span style="font-size:40px">LOST</span>'); |
| el.style.animation = "eloPopIn 2.5s ease forwards"; |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // LEADERBOARD |
| // CHANGED: reads the players collection (now uid-keyed) and treats |
| // each value's `.name` field as display name; "isMe" compares uid, |
| // not name. Clicking a row passes the UID through to the profile |
| // viewer (read-only) instead of a name-as-key lookup. |
| // ═══════════════════════════════════════════════ |
| async function loadLeaderboard() { |
| const lbTable = document.getElementById("lbTable"); |
| lbTable.innerHTML = '<div class="lb-row"><span>Rank</span><span>Name</span><span>ELO</span><span>W/L</span></div>'; |
| if (!fbReady) { lbTable.innerHTML += '<div class="lb-row"><span>Firebase not connected</span></div>'; return; } |
| try { |
| const data = await dbGet("players"); |
| if (!data) { lbTable.innerHTML += '<div class="lb-row"><span>No players</span></div>'; return; } |
| |
| // Holt alle Einträge und zeigt absolut jeden Account (inkl. Bot & älterer Accounts), |
| // der mindestens eine Partie (Sieg oder Niederlage) absolviert hat. |
| // Dedupe: gleicher Name = ein Eintrag (eigene UID hat Vorrang, sonst der mit den meisten Partien) |
| const _lbSeen = new Map(); |
| Object.entries(data) |
| .map(([uid, p]) => ({ uid, ...p })) |
| .filter(p => { |
| const totalGames = (Number(p.wins) || 0) + (Number(p.losses) || 0); |
| return totalGames >= 1; |
| }) |
| .forEach(p => { |
| const key = String(p.name || "").toLowerCase(); |
| const prev = _lbSeen.get(key); |
| const games = q => (Number(q.wins) || 0) + (Number(q.losses) || 0); |
| const better = !prev || p.uid === myUid || (prev.uid !== myUid && games(p) > games(prev)); |
| if (better) _lbSeen.set(key, p); |
| }); |
| const activePlayers = [..._lbSeen.values()] |
| .sort((a, b) => (Number(b.elo) || 1500) - (Number(a.elo) || 1500)); |
| |
| if (activePlayers.length === 0) { |
| lbTable.innerHTML += '<div class="lb-row"><span style="grid-column: span 4; text-align: center;">No active players yet</span></div>'; |
| return; |
| } |
| |
| activePlayers.forEach((p, i) => { |
| const isMe = myUid && p.uid === myUid; |
| lbTable.innerHTML += `<div class="lb-row${isMe ? " lb-me" : ""}" style="cursor:pointer;" onclick="openOnlineProfile('${p.uid}')"> |
| <span>#${i+1}</span><span>${leagueByTier(p.leagueTier).icon} ${escapeHtml(dn(p.name))}</span> |
| <span>${Math.round(p.elo || 1500)}</span> |
| <span>${p.wins||0}/${p.losses||0}</span></div>`; |
| }); |
| } catch(e) { lbTable.innerHTML += '<div class="lb-row"><span>Error while loading</span></div>'; } |
| } |
| |
| |
| |
| |
| |
| // CHANGED: now takes a uid (not a name) and reads players/{uid} |
| // directly — read-only, no write capability here at all. |
| // Platzierungen (wie in den Ranglisten berechnet) fuer das Profil: Elo, Survival, Streak, Daily Wins |
| async function profileRanks(uid) { |
| const out = { elo: "", surv: "", streak: "", dw: "" }; |
| const tag = i => i >= 0 ? ' <span style="color:#ffd700;font-size:.85em;">#' + (i + 1) + '</span>' : ""; |
| try { |
| const [pl, sv, sb, dw] = await Promise.all([dbGet("players"), dbGet("survival").catch(() => null), dbGet("streakBoard").catch(() => null), dbGet("dailyWins").catch(() => null)]); |
| if (!pl) return out; |
| const nk = n => String(dn(n)).trim().toLowerCase(); |
| // Elo (gleiche Regeln wie Elo-Rangliste: min. 1 Partie, ein Eintrag pro Name) |
| const seen = new Map(), games = q => (Number(q.wins) || 0) + (Number(q.losses) || 0); |
| Object.entries(pl).map(([u, q]) => ({ uid: u, ...q })).filter(q => games(q) >= 1).forEach(q => { |
| const k = String(q.name || "").toLowerCase(), prev = seen.get(k); |
| if (!prev || q.uid === myUid || (prev.uid !== myUid && games(q) > games(prev))) seen.set(k, q); |
| }); |
| out.elo = tag([...seen.values()].sort((a, b) => (Number(b.elo) || 1500) - (Number(a.elo) || 1500)).findIndex(q => q.uid === uid)); |
| // Survival |
| const sr = Object.entries(sv || {}).filter(([u]) => pl[u]).map(([u, r]) => ({ uid: u, time: Number(r && r.time) || 0, name: pl[u].name || (r && r.name) || "?" })) |
| .filter(r => r.time > 0).sort((a, b) => b.time - a.time).filter((r, i, arr) => arr.findIndex(x => nk(x.name) === nk(r.name)) === i); |
| out.surv = tag(sr.findIndex(r => r.uid === uid)); |
| // Streak |
| const kr = Object.entries(sb || {}).filter(([u]) => pl[u]).map(([u, r]) => ({ uid: u, level: Number(r && r.level) || 0, ts: Number(r && r.ts) || 0 })) |
| .filter(r => r.level > 0).sort((a, b) => b.level - a.level || a.ts - b.ts); |
| out.streak = tag(kr.findIndex(r => r.uid === uid)); |
| // Daily Wins |
| const wr = Object.entries(dw || {}).filter(([u]) => pl[u]).map(([u, d]) => ({ uid: u, wins: Object.keys(d || {}).length })).filter(r => r.wins > 0).sort((a, b) => b.wins - a.wins); |
| out.dw = tag(wr.findIndex(r => r.uid === uid)); |
| } catch (e) { console.warn("profile ranks failed", e); } |
| return out; |
| } |
| async function openOnlineProfile(uid) { |
| if (!fbReady) return; |
| try { |
| const p = await dbGet("players/" + uid); |
| if (!p) { |
| alert("Profile not found."); |
| return; |
| } |
| |
| // Bot: "Elo gewonnen" nie kleiner als Netto-Elo + verlorene Elo (Start 1500) |
| if (uid === ONLINE_BOT_UID && (p.wins ?? 0) > 0) { |
| const minGained = Math.max(0, Math.round((p.elo ?? 1500) - 1500 + (p.totalEloLost ?? 0))); |
| if (!(p.totalEloGained >= minGained)) p.totalEloGained = Math.max(minGained, p.wins); |
| } |
| let svRec = 0, skRec = 0; |
| try { |
| const [sv, sk] = await Promise.all([dbGet("survival/" + uid).catch(() => null), dbGet("streakBoard/" + uid).catch(() => null)]); |
| svRec = (sv && Number(sv.time)) || 0; skRec = (sk && Number(sk.level)) || 0; |
| } catch (e) {} |
| const rk = await profileRanks(uid); |
| let dailyWinsN = 0; |
| try { const dw = await dbGet("dailyWins/" + uid); dailyWinsN = dw ? Object.keys(dw).length : 0; } catch (e) {} |
| const wins = p.wins ?? 0; |
| const losses = p.losses ?? 0; |
| const totalGames = wins + losses; |
| const winrate = totalGames > 0 ? Math.round((wins / totalGames) * 100) : 0; |
| |
| document.getElementById("profileName").innerHTML = |
| `<span style="color:${p.name === "Online Bot" ? "#ffaa00" : "#00ff88"}">${escapeHtml(dn(p.name))}</span>`; |
| |
| document.getElementById("profileStats").innerHTML = ` |
| <div class="prof-row"><span class="prof-label">Elo</span><span class="prof-val">${Math.round(p.elo)}${rk.elo}</span></div> |
| <div class="prof-row"><span class="prof-label">League</span><span class="prof-val">${leagueBadgeHTML(p.leagueTier)}</span></div> |
| <div class="prof-row"><span class="prof-label">🏝 Survival Record</span><span class="prof-val" style="color:#00ff88">${svRec > 0 ? survFmt(svRec) + rk.surv : "–"}</span></div> |
| <div class="prof-row"><span class="prof-label">🔥 Streaks Record</span><span class="prof-val" style="color:#00ff88">${skRec > 0 ? skRec + rk.streak : "–"}</span></div> |
| <div class="prof-row"><span class="prof-label">👑 Daily Challenge Wins</span><span class="prof-val">${dailyWinsN}${dailyWinsN > 0 ? rk.dw : ""}</span></div> |
| <div style="height:1px;background:rgba(255,255,255,0.18);margin:10px 0;"></div> |
| <div class="prof-row"><span class="prof-label">Total Games</span><span class="prof-val">${totalGames}</span></div> |
| <div class="prof-row"><span class="prof-label">Wins</span><span class="prof-val" style="color:#00ff88">${wins}</span></div> |
| <div class="prof-row"><span class="prof-label">Losses</span><span class="prof-val" style="color:#ff4444">${losses}</span></div> |
| <div class="prof-row"><span class="prof-label">Winrate</span><span class="prof-val">${winrate}%</span></div> |
| <div class="prof-row"><span class="prof-label">Highest Elo</span><span class="prof-val" style="color:#00ff88">${Math.round(p.highestElo ?? p.elo)}</span></div> |
| <div class="prof-row"><span class="prof-label">Lowest Elo</span><span class="prof-val" style="color:#ff4444">${Math.round(p.lowestElo ?? p.elo)}</span></div> |
| <div class="prof-row"><span class="prof-label">Highest League</span><span class="prof-val">${leagueBadgeHTML(p.leagueBest ?? p.leagueTier)}</span></div> |
| <div class="prof-row"><span class="prof-label">Longest Winstreaks</span><span class="prof-val">${p.bestStreak ?? 0}</span></div> |
| <div class="prof-row"><span class="prof-label">Current Winstreaks</span><span class="prof-val">${p.currentStreak ?? 0}</span></div> |
| <div class="prof-row"><span class="prof-label">Best Win</span><span class="prof-val" style="color:#00ff88">${p.bestWinChange ?? 0} ELO vs ${escapeHtml(p.bestWinOpp || "-")}</span></div> |
| <div class="prof-row"><span class="prof-label">Worst Loss</span><span class="prof-val" style="color:#ff4444">${p.worstLossChange ?? 0} ELO vs ${escapeHtml(p.worstLossOpp || "-")}</span></div> |
| <div class="prof-row"><span class="prof-label">Total Elo Won</span><span class="prof-val" style="color:#00ff88">+${p.totalEloGained ?? 0}</span></div> |
| <div class="prof-row"><span class="prof-label">Total Elo Lost</span><span class="prof-val" style="color:#ff4444">-${p.totalEloLost ?? 0}</span></div> |
| <div class="prof-row"><span class="prof-label">🏝 Survival Runs</span><span class="prof-val">${Math.max(p.survPlayed ?? 0, svRec > 0 ? 1 : 0)}</span></div> |
| <div class="prof-row"><span class="prof-label">🔥 Streaks Runs</span><span class="prof-val">${Math.max(p.streakPlayed ?? 0, skRec > 0 ? 1 : 0)}</span></div> |
| `; |
| |
| document.getElementById("profileModal").style.display = "flex"; |
| } catch(e) { |
| console.warn("Error while loading profile", e); |
| alert("Error while loading profile."); |
| } |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // ONLINE FLOW |
| // ═══════════════════════════════════════════════ |
| function showScreen_onlineFlow() { |
| if (!onlineAccount) showCreateAccount(); |
| else enterOnlineQueue(); |
| } |
| |
| // CHANGED: accounts are now real username+password accounts (backed |
| // by Firebase Auth's email/password provider — see the top of the |
| // file for why). showCreateAccount() is kept as a thin wrapper for |
| // old call sites; the actual UI now has a Register / Log In toggle, |
| // plus a logged-in status screen with a Log Out button. |
| function showCreateAccount() { showAccountAuthScreen("register"); } |
| |
| function showAccountAuthScreen(mode) { |
| showScreen("accountScreen"); |
| // Lokal "eingeloggt", aber keine Passwort-Sitzung bei Firebase (Sitzung verloren/noch nicht geladen): |
| // direkt zum Login mit vorausgefuelltem Namen statt der verwirrenden "Geraet hat schon einen Account"-Seite |
| if (mode !== "login" && onlineAccount && deviceHasAccount() && !(window._dobAuth && window._dobAuth.isPasswordAccount())) { |
| showAccountAuthScreen("login"); |
| const u = document.getElementById("authUsername"); if (u) u.value = onlineAccount.name; |
| const i = document.getElementById("accountInfo"); if (i) { i.textContent = "Session expired – please log in again."; i.style.color = "#ffd700"; } |
| return; |
| } |
| if (mode === "login") { |
| document.getElementById("accountContent").innerHTML = ` |
| <p style="color:#aaa;margin-bottom:18px;font-size:16px;">Log in to your account</p> |
| <input type="text" id="authUsername" placeholder="Username" maxlength="20" autocomplete="username"> |
| <br> |
| <input type="password" id="authPassword" placeholder="Password" autocomplete="current-password"> |
| <br> |
| <div id="accountInfo"></div> |
| <br> |
| <button onclick="loginAccount()">Log In</button> |
| <button onclick="showAccountAuthScreen('register')">Need an account? Create one</button> |
| <button onclick="showScreen('menu')">← Back</button> |
| `; |
| } else if (deviceHasAccount()) { |
| document.getElementById("accountContent").innerHTML = ` |
| <p style="color:#ffd700;font-size:18px;font-weight:700;margin-bottom:8px;">🔒 ${tr("This device already has an account")}</p> |
| <p style="color:#cfd6e4;font-size:13px;max-width:340px;margin:0 auto 16px;line-height:1.5;">${tr("Only one account per player is allowed. Log in with your existing account, or delete it first to create a new one.")}</p> |
| <button onclick="showAccountAuthScreen('login')">${tr("Log In")}</button> |
| <button onclick="showScreen('menu')">← Back</button> |
| `; |
| } else { |
| document.getElementById("accountContent").innerHTML = ` |
| <p style="color:#aaa;margin-bottom:18px;font-size:16px;">Create your account</p> |
| <input type="text" id="authUsername" placeholder="Username" maxlength="20" autocomplete="username"> |
| <br> |
| <input type="password" id="authPassword" placeholder="Password (min 6 characters)" autocomplete="new-password"> |
| <br> |
| <input type="password" id="authPasswordConfirm" placeholder="Confirm password" autocomplete="new-password"> |
| <br> |
| <div id="accountInfo"></div> |
| <br> |
| <button onclick="createAccount()">Create Account</button> |
| <button onclick="showAccountAuthScreen('login')">Already have an account? Log In</button> |
| <button onclick="showScreen('menu')">← Back</button> |
| `; |
| } |
| } |
| |
| function showLoggedInAccountScreen() { |
| showScreen("accountScreen"); |
| document.getElementById("accountContent").innerHTML = ` |
| <p style="color:#aaa;margin-bottom:8px;font-size:16px;">Logged in as</p> |
| <p style="color:#00ff88;font-size:22px;font-weight:700;margin-bottom:10px;">${escapeHtml(dn(onlineAccount.name))}</p> |
| <div style="font-size:20px;margin-bottom:14px;">${leagueBadgeHTML(onlineAccount.leagueTier)} <span style="color:#8b93a6;font-size:14px;">· ${Math.round(onlineAccount.elo)} ELO</span></div> |
| <button onclick="showLeagueScreen()">🏅 Weekly League</button> |
| <p style="color:#8b93a6;font-size:12px;max-width:340px;margin:6px auto 14px;line-height:1.5;">${tr("One account per player. To use a different account you have to delete this one first.")}</p> |
| <button onclick="showDeleteAccountScreen()" style="border-color:rgba(255,68,68,.5);color:#ff6666;">🗑 ${tr("Delete Account")}</button> |
| <button onclick="showScreen('menu')">← Back</button> |
| `; |
| } |
| |
| function showDeleteAccountScreen() { |
| showScreen("accountScreen"); |
| document.getElementById("accountContent").innerHTML = ` |
| <p style="color:#ff6666;font-size:20px;font-weight:700;margin-bottom:8px;">⚠ ${tr("Delete account permanently?")}</p> |
| <p style="color:#cfd6e4;font-size:13px;max-width:340px;margin:0 auto 14px;line-height:1.5;">${tr("Your name, ELO, stats and league progress will be deleted and cannot be restored. Afterwards you can create a new account.")}</p> |
| <input type="password" id="deletePassword" placeholder="${tr("Password")}" autocomplete="current-password"> |
| <br> |
| <div id="accountInfo"></div> |
| <br> |
| <button id="deleteAccountBtn" onclick="deleteAccount()" style="background:#a22;border-color:#f44;color:#fff;">${tr("Delete permanently")}</button> |
| <button onclick="showLoggedInAccountScreen()">${tr("Cancel")}</button> |
| `; |
| } |
| |
| // Entry point for the new "👤 Account" menu button: shows the right |
| // screen depending on whether we're already logged into a real |
| // (password-backed) account. |
| function openAccountScreen() { |
| if (onlineAccount && window._dobAuth && window._dobAuth.isPasswordAccount()) { |
| showLoggedInAccountScreen(); |
| } else { |
| showAccountAuthScreen("register"); |
| } |
| } |
| |
| // CHANGED: registration now requires a password. The uniqueness check |
| // still looks up the `usernames` index first (fast, friendly error), |
| // but the real guarantee comes from Firebase Auth itself refusing a |
| // duplicate email (auth/email-already-in-use) — the two can't |
| // disagree for long since both are written together. |
| async function createAccount() { |
| const name = document.getElementById("authUsername").value.trim(); |
| const password = document.getElementById("authPassword").value; |
| const passwordConfirm = document.getElementById("authPasswordConfirm").value; |
| const info = document.getElementById("accountInfo"); |
| |
| if (deviceHasAccount()) { showAccountAuthScreen("register"); return; } // one account per device |
| if (!name || name.length < 2) { info.textContent = "Name must be at least 2 characters."; info.style.color="#f44"; return; } |
| if (!/^[a-zA-Z0-9_-]+$/.test(name)) { info.textContent = "Only letters, numbers, _ and - allowed."; info.style.color="#f44"; return; } |
| if (nameIsBlocked(name)) { info.textContent = "This name is not allowed."; info.style.color="#f44"; return; } |
| if (!password || password.length < 6) { info.textContent = "Password must be at least 6 characters."; info.style.color="#f44"; return; } |
| if (password !== passwordConfirm) { info.textContent = "Passwords do not match."; info.style.color="#f44"; return; } |
| if (!fbReady || !window._dobAuth) { info.textContent = "Still connecting — please try again in a moment."; info.style.color="#f44"; return; } |
| |
| info.style.color = "#aaa"; |
| info.textContent = "Creating account…"; |
| |
| try { |
| const existingUid = await dbGet("usernames/" + name.toLowerCase()); |
| if (existingUid) { info.textContent = "Name already taken."; info.style.color="#f44"; return; } |
| |
| const newUid = await window._dobAuth.register(name, password); |
| myUid = newUid; |
| |
| onlineAccount = sanitizeAccount({ name, elo: 1500, wins: 0, losses: 0 }); |
| markDeviceAccount(name); |
| saveAccountLocal(); |
| await pushAccountToFirebase(); |
| RPC.onDisconnectSetOwnStatus({ status: "offline" }); |
| await updatePlayerStatus("Idle in Menu"); |
| startMenuPresenceListener(); |
| |
| showScreen("menu"); |
| } catch(e) { |
| if (e && e.code === "auth/email-already-in-use") info.textContent = "Name already taken."; |
| else if (e && e.code === "auth/weak-password") info.textContent = "Password is too weak (min 6 characters)."; |
| else info.textContent = "Error: " + (e && (e.code || e.message)); |
| info.style.color = "#f44"; |
| console.warn("createAccount failed", e); |
| } |
| } |
| |
| // ADDED: log in with an existing username+password. On success we |
| // fetch the player's saved data from Firebase (NOT from localStorage |
| // — this is what makes it work on a brand-new device/browser) and |
| // use that as the account. |
| async function loginAccount() { |
| const name = document.getElementById("authUsername").value.trim(); |
| const password = document.getElementById("authPassword").value; |
| const info = document.getElementById("accountInfo"); |
| |
| if (!name || !password) { info.textContent = "Enter your username and password."; info.style.color="#f44"; return; } |
| if (!fbReady || !window._dobAuth) { info.textContent = "Still connecting — please try again in a moment."; info.style.color="#f44"; return; } |
| |
| info.style.color = "#aaa"; |
| info.textContent = "Logging in…"; |
| |
| try { |
| const uid = await window._dobAuth.login(name, password); |
| myUid = uid; |
| |
| const data = await dbGet("players/" + uid); |
| if (data) { |
| onlineAccount = sanitizeAccount(data); |
| } else { |
| // Password account exists but has no player record yet (edge |
| // case) — start it fresh under this uid. |
| onlineAccount = sanitizeAccount({ name, elo: 1500, wins: 0, losses: 0 }); |
| await pushAccountToFirebase(); |
| } |
| markDeviceAccount(onlineAccount.name); |
| saveAccountLocal(); |
| RPC.onDisconnectSetOwnStatus({ status: "offline" }); |
| await updatePlayerStatus("Idle in Menu"); |
| startMenuPresenceListener(); |
| |
| showScreen("menu"); |
| } catch(e) { |
| if (e && ["auth/invalid-credential","auth/wrong-password","auth/user-not-found"].includes(e.code)) { |
| info.textContent = "Incorrect username or password."; |
| } else { |
| info.textContent = "Error: " + (e && (e.code || e.message)); |
| } |
| info.style.color = "#f44"; |
| console.warn("loginAccount failed", e); |
| } |
| } |
| |
| // ── One account per device ── |
| // Best effort on the client: a marker in localStorage remembers that this browser already has an account. |
| // (It cannot stop someone who clears their browser data or uses another device – that would need a backend.) |
| function deviceHasAccount() { |
| if (onlineAccount && window._dobAuth && window._dobAuth.isPasswordAccount()) return true; |
| try { return !!localStorage.getItem("dobDeviceAccount"); } catch (e) { return false; } |
| } |
| function markDeviceAccount(name) { try { localStorage.setItem("dobDeviceAccount", String(name || "1")); } catch (e) {} } |
| |
| // Deletes the account for good (password required). Log out no longer exists. |
| async function deleteAccount() { |
| const pw = document.getElementById("deletePassword").value; |
| const info = document.getElementById("accountInfo"); |
| const btn = document.getElementById("deleteAccountBtn"); |
| if (!pw) { info.textContent = tr("Enter your password."); info.style.color = "#f44"; return; } |
| if (!fbReady || !window._dobAuth || !onlineAccount || !myUid) { info.textContent = tr("Still connecting — please try again in a moment."); info.style.color = "#f44"; return; } |
| btn.disabled = true; |
| info.style.color = "#aaa"; info.textContent = tr("Deleting…"); |
| try { |
| await window._dobAuth.reauth(pw); // wrong password -> error, nothing is deleted |
| } catch (e) { |
| btn.disabled = false; |
| info.textContent = (e && ["auth/invalid-credential", "auth/wrong-password"].includes(e.code)) ? tr("Incorrect password.") |
| : (e && e.code === "auth/too-many-requests") ? tr("Too many attempts. Try again later.") : "Error: " + (e && (e.code || e.message)); |
| info.style.color = "#f44"; |
| return; |
| } |
| const name = onlineAccount.name, week = onlineAccount.leagueWeek, grp = onlineAccount.leagueGroup, tier = onlineAccount.leagueTier || 0; |
| try { cleanupOnline(); } catch (e) {} |
| const tasks = [ |
| RPC.removeOwnQueueEntry(), RPC.removeOwnInvite(), |
| RPC.removeUsernameIndex(name) |
| ]; |
| if (week && grp) tasks.push(RPC.removeLeagueMember(week, tier, grp)); |
| for (const e of leagueHistoryGet()) tasks.push(RPC.removeLeagueMember(e.w, e.t, e.g)); |
| await Promise.allSettled(tasks); |
| onlineAccount = null; // stop presence / pushes from re-creating the player node |
| try { await RPC.removeOwnPlayer(); } catch (e) { console.warn("player delete failed", e); } |
| try { |
| await window._dobAuth.deleteAuthUser(); |
| } catch (e) { |
| console.warn("auth delete failed", e); |
| info.textContent = "Error: " + (e && (e.code || e.message)); info.style.color = "#f44"; btn.disabled = false; |
| return; |
| } |
| Object.keys(localStorage).filter(k => k === "dobAccount" || k === "dobDeviceAccount" || k === "dobLeagueHistory" || k === "dobLeagueSeen" || k === "dobActiveMatch" || k === "onlineBotElo" || k.startsWith("h2h_bot_")) |
| .forEach(k => localStorage.removeItem(k)); |
| window.location.reload(); // clean state, new account can be created now |
| } |
| |
| function enterOnlineQueue() { |
| if (!onlineAccount) { showCreateAccount(); return; } |
| showScreen("onlineWait"); |
| document.getElementById("onlineStatus").textContent = "Searching for opponent…"; |
| startSearchingOnline(); |
| } |
| |
| function cancelOnline() { |
| cleanupOnline(); |
| showScreen("menu"); |
| } |
| |
| // CHANGED: queue/invite cleanup now keyed by myUid instead of name. |
| function cleanupOnline() { |
| try { stopViewerCount(); } catch (e) {} |
| if (_skLiveOn) { _skLiveOn = false; try { if (fbReady && myUid) RPC.removeSurvivalLive().catch(() => {}); } catch (e) {} } |
| if (onlineBallIv) { clearInterval(onlineBallIv); onlineBallIv = null; } |
| if (onlineSearchInterval) { clearInterval(onlineSearchInterval); onlineSearchInterval = null; } |
| if (onlineBotMatchTimeout) { clearTimeout(onlineBotMatchTimeout); onlineBotMatchTimeout = null; } |
| const unsubs = [...onlineUnsubscribes]; |
| onlineUnsubscribes = []; |
| unsubs.forEach(fn => { try { fn(); } catch(e) {} }); |
| |
| if (fbReady && myUid) { |
| RPC.removeOwnQueueEntry().catch(() => {}); |
| RPC.removeOwnInvite().catch(() => {}); |
| } |
| if (fbReady && onlineMatchId) { |
| const mk = readActiveMatchMarker(); |
| const leftMidMatch = !onlineVsBot && !onlineResultHandled && mk && mk.id === onlineMatchId; |
| disarmOnlineDisconnect(); |
| if (leftMidMatch) { |
| clearActiveMatchMarker(); |
| onlineResultHandled = true; |
| const loss = forfeitChange(false); |
| applyForfeitLoss(loss); |
| pushAccountToFirebase().catch(() => {}); |
| RPC.updateMatch(onlineMatchId, { status: "abandoned", forfeitBy: myUid, eloChange: loss, postChat: null }).catch(() => {}); |
| } else { |
| RPC.updateMatch(onlineMatchId, onlineVsBot ? { status: "abandoned" } : { status: "abandoned", postChat: null }).catch(() => {}); |
| } |
| } |
| onlineMatchId = null; |
| onlineRole = null; |
| onlineOpponent = null; |
| onlineResultHandled = false; |
| onlineIsHost = false; |
| onlineVsBot = false; |
| document.getElementById("pingDisplay").style.display = "none"; |
| document.getElementById("onlineHUD").style.display = "none"; |
| if (onlineTvUnsubscribe) { |
| try { onlineTvUnsubscribe(); } catch(e) {} |
| onlineTvUnsubscribe = null; |
| } |
| if (onlineTvInnerUnsubscribe) { |
| try { onlineTvInnerUnsubscribe(); } catch(e) {} |
| onlineTvInnerUnsubscribe = null; |
| } |
| onlineTvCurrentMatchId = null; |
| } |
| |
| |
| |
| |
| async function cleanStaleMatches() { |
| if (!fbReady) return; |
| try { |
| const allMatches = await dbGet("onlineMatches"); |
| if (!allMatches) return; |
| const now = Date.now(); |
| for (const [id, m] of Object.entries(allMatches)) { |
| if (m.status !== "done" && m.status !== "abandoned" && (now - m.createdAt) > 90000) { |
| // Best-effort cleanup; only succeeds if rules permit (e.g. we |
| // are a listed participant, or rules allow removing stale |
| // matches generally — see RULES.txt). |
| await RPC.removeMatch(id).catch(() => {}); |
| } |
| } |
| } catch(e) {} |
| } |
| |
| // CHANGED: invite payload now carries the opponent's UID (not just |
| // name), and we read the opponent's uid-keyed record. |
| async function acceptInvite(data) { |
| if (onlineMatchId) return; |
| const matchData = await RPC.getMatch(data.matchId); |
| if (!matchData) return; |
| |
| if (onlineSearchInterval) { clearInterval(onlineSearchInterval); onlineSearchInterval = null; } |
| if (onlineBotMatchTimeout) { clearTimeout(onlineBotMatchTimeout); onlineBotMatchTimeout = null; } |
| |
| const opp = { uid: data.oppUid, name: data.opp, elo: data.oppElo }; |
| |
| await RPC.removeOwnInvite(); |
| await RPC.removeOwnQueueEntry(); |
| |
| joinMatch(data.matchId, opp, matchData); |
| } |
| |
| // CHANGED: queue entries are now keyed by uid; invite payloads carry |
| // uid + name + elo for the opponent. |
| async function startSearchingOnline() { |
| if (!fbReady || !myUid) { |
| document.getElementById("onlineStatus").textContent = "Firebase not connected. Cannot play online."; |
| return; |
| } |
| await cleanStaleMatches(); |
| await pullAccountFromServer(); |
| onlineSearchStartTime = Date.now(); |
| |
| await RPC.setOwnQueueEntry({ |
| uid: myUid, name: onlineAccount.name, elo: onlineAccount.elo, ts: Date.now(), |
| aspect: screenAspect() |
| }); |
| |
| const queueUnsub = dbOn("queue/" + myUid + "_invite", (data) => { |
| if (data && data.matchId && !onlineMatchId) { |
| acceptInvite(data); |
| } |
| }); |
| onlineUnsubscribes.push(queueUnsub); |
| |
| const anim = ["⏳","⌛"]; |
| let ai = 0; |
| onlineSearchInterval = setInterval(async () => { |
| document.getElementById("onlineSearchAnim").textContent = anim[ai++ % 2]; |
| try { await tryMatchmake(); } catch(e) { console.warn("matchmake error:", e); } |
| }, 2000); |
| setTimeout(() => tryMatchmake(), 600); |
| |
| onlineBotMatchTimeout = setTimeout(() => { |
| if (!onlineMatchId) startBotMatch(); |
| }, 30000); |
| } |
| // Knopf waehrend der Suche: sofort gegen den Online Bot spielen |
| function playWithBotNow() { |
| if (onlineMatchId) return; |
| const b = document.getElementById("playBotBtn"); if (b) b.disabled = true; |
| startBotMatch().finally(() => { if (b) b.disabled = false; }); |
| } |
| |
| // CHANGED: matchmaking now operates on uid-keyed queue entries. |
| // matchId is derived from sorted UIDs (not names) so it can't collide |
| // across two different people who happen to pick the same display |
| // name. The match doc stores BOTH p1/p2 names (for display) and |
| // p1uid/p2uid (for security-rule enforcement and as the real |
| // identity key). |
| async function tryMatchmake() { |
| if (onlineMatchId || !myUid) return; |
| const queue = await dbGet("queue"); |
| if (!queue) return; |
| const others = Object.entries(queue) |
| .map(([uid, p]) => ({ uid, ...p })) |
| .filter(p => |
| p.uid && p.uid !== myUid && p.ts && (Date.now() - p.ts < 60000) && !String(p.uid).endsWith("_invite") |
| ); |
| if (!others.length) return; |
| |
| others.sort((a,b) => Math.abs(a.elo - onlineAccount.elo) - Math.abs(b.elo - onlineAccount.elo)); |
| const opp = others[0]; |
| const matchId = [myUid, opp.uid].sort().join("__VS__"); |
| |
| const existing = await RPC.getMatch(matchId); |
| if (existing && existing.status === "waiting") { |
| if (onlineMatchId) return; |
| await RPC.removeOwnQueueEntry(); |
| joinMatch(matchId, { uid: opp.uid, name: opp.name, elo: opp.elo }, existing); |
| return; |
| } |
| if (existing && existing.status !== "done" && existing.status !== "abandoned") return; |
| |
| const isP1 = myUid < opp.uid; |
| const p1Uid = isP1 ? myUid : opp.uid; |
| const p2Uid = isP1 ? opp.uid : myUid; |
| const p1Name = isP1 ? onlineAccount.name : opp.name; |
| const p2Name = isP1 ? opp.name : onlineAccount.name; |
| |
| const matchData = { |
| p1uid: p1Uid, p2uid: p2Uid, |
| p1: p1Name, p2: p2Name, |
| p1elo: isP1 ? onlineAccount.elo : opp.elo, |
| p2elo: isP1 ? opp.elo : onlineAccount.elo, |
| status: "waiting", |
| winner: null, |
| createdAt: Date.now(), |
| // Arena-Seitenverhältnis: der schmalere Bildschirm bestimmt, der andere bekommt nur kleine Ränder |
| arena: Math.min(screenAspect(), Number(opp.aspect) || 16 / 9), |
| seed: Math.floor(Math.random() * 1000000) |
| }; |
| try { |
| const existingCheck = await RPC.getMatch(matchId); |
| if (!existingCheck || existingCheck.status === "done" || existingCheck.status === "abandoned") { |
| await RPC.createMatch(matchId, matchData); |
| } else { |
| return; |
| } |
| |
| await RPC.sendInvite(opp.uid, { |
| matchId: matchId, |
| oppUid: myUid, |
| opp: onlineAccount.name, |
| oppElo: onlineAccount.elo |
| }); |
| |
| await RPC.removeOwnQueueEntry(); |
| // Note: we cannot remove the OPPONENT's queue entry ourselves |
| // anymore (that would be writing to someone else's data). The |
| // opponent's own client removes its queue entry in acceptInvite() |
| // once it sees the invite. This is a deliberate behavior change |
| // required by the security model — see RULES.txt. |
| |
| joinMatch(matchId, { uid: opp.uid, name: opp.name, elo: opp.elo }, matchData); |
| } catch(e) { |
| console.warn("No response or match creation race:", e); |
| } |
| } |
| |
| // CHANGED: bot match no longer needs a name-keyed lookup; bot stats |
| // are stored under a fixed, well-known synthetic uid so console users |
| // can't impersonate it via a guessable name path more easily than |
| // before (this was already somewhat true, but it's now consistent |
| // with the uid model). |
| const ONLINE_BOT_UID = "bot__online_bot"; |
| |
| async function startBotMatch() { |
| if (onlineMatchId || !myUid) return; |
| if (onlineSearchInterval) { |
| clearInterval(onlineSearchInterval); |
| onlineSearchInterval = null; |
| } |
| if (onlineBotMatchTimeout) { |
| clearTimeout(onlineBotMatchTimeout); |
| onlineBotMatchTimeout = null; |
| } |
| |
| RPC.removeOwnQueueEntry().catch(() => {}); |
| |
| onlineVsBot = true; |
| onlineMatchId = "BOT__" + Date.now(); |
| onlineRole = "p1"; |
| onlineIsHost = true; |
| |
| onlineOpponent = { |
| uid: ONLINE_BOT_UID, |
| name: "Online Bot", |
| elo: onlineBotElo, |
| wins: 0, |
| losses: 0, |
| skill: 0.35 |
| }; |
| |
| onlineResultHandled = false; |
| |
| if (fbReady) { |
| try { |
| const data = await dbGet("players/" + ONLINE_BOT_UID); |
| if (data) { |
| onlineOpponent = sanitizeAccount(data); |
| onlineOpponent.uid = ONLINE_BOT_UID; |
| onlineOpponent.name = "Online Bot"; |
| onlineOpponent.skill = 0.30; |
| } else { |
| onlineOpponent = { |
| uid: ONLINE_BOT_UID, |
| name: "Online Bot", |
| elo: onlineBotElo, |
| wins: 0, |
| losses: 0, |
| skill: 0.30 |
| }; |
| onlineOpponent = sanitizeAccount(onlineOpponent); |
| } |
| } catch(e) { |
| onlineOpponent = sanitizeAccount(onlineOpponent); |
| } |
| onlineBotElo = onlineOpponent.elo; |
| localStorage.setItem("onlineBotElo", onlineBotElo); |
| } else { |
| onlineOpponent = sanitizeAccount(onlineOpponent); |
| } |
| |
| if (fbReady) { |
| RPC.createMatch(onlineMatchId, { |
| p1uid: myUid, p2uid: ONLINE_BOT_UID, |
| p1: onlineAccount.name, |
| p2: "Online Bot", |
| p1elo: onlineAccount.elo, |
| p2elo: onlineOpponent.elo, |
| status: "playing", |
| createdAt: Date.now(), |
| seed: Math.floor(Math.random() * 1000000) |
| }).then(() => { |
| try { syncBotH2H(JSON.parse(localStorage.getItem(`h2h_bot_${myUid}`) || '{"me":0,"bot":0}')); } catch (e) {} |
| }).catch(() => {}); |
| } |
| |
| showVSScreen( |
| onlineAccount.name, |
| onlineAccount.elo, |
| "Online Bot", |
| onlineOpponent.elo, |
| () => { |
| startOnlineGame({ seed: Math.floor(Math.random()*1000000), arena: screenAspect() }); |
| } |
| ); |
| } |
| |
| |
| // CHANGED: role is now derived from comparing myUid to matchData.p1uid |
| // (not from comparing names). |
| function joinMatch(matchId, opp, matchData) { |
| if (onlineMatchId) return; |
| if (onlineSearchInterval) { clearInterval(onlineSearchInterval); onlineSearchInterval = null; } |
| if (onlineBotMatchTimeout) { clearTimeout(onlineBotMatchTimeout); onlineBotMatchTimeout = null; } |
| onlineMatchId = matchId; |
| onlineOpponent = opp; |
| onlineResultHandled = false; |
| onlineVsBot = false; |
| |
| onlineRound = matchData.round || 0; |
| onlineMatchStamp = Number(matchData.createdAt) || 0; |
| onlineRole = (myUid === matchData.p1uid) ? "p1" : "p2"; |
| onlineIsHost = (onlineRole === "p1"); |
| |
| showVSScreen(onlineAccount.name, onlineAccount.elo, opp.name, opp.elo, () => { |
| startOnlineGame(matchData); |
| }); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // VS SCREEN |
| // ═══════════════════════════════════════════════ |
| // CHANGED: ranks now computed against uid-keyed records, matched by |
| // display name for presentation purposes only (rank lookup itself |
| // still works the same way conceptually — sorted by elo — just reads |
| // the new collection shape). |
| async function getPlayerRanks(p1Name, p2Name) { |
| if (!fbReady) return { r1: "?", r2: "?" }; |
| try { |
| const data = await dbGet("players"); |
| if (!data) return { r1: "?", r2: "?" }; |
| |
| const sorted = Object.values(data) |
| .filter(p => ((p.wins || 0) + (p.losses || 0)) > 0) |
| .sort((a, b) => b.elo - a.elo); |
| |
| const idx1 = sorted.findIndex(p => p.name === p1Name); |
| const idx2 = sorted.findIndex(p => p.name === p2Name); |
| |
| const all = Object.values(data); |
| const pl1 = all.find(p => p.name === p1Name), pl2 = all.find(p => p.name === p2Name); |
| return { |
| r1: idx1 !== -1 ? `#${idx1 + 1}` : "Unranked", |
| r2: idx2 !== -1 ? `#${idx2 + 1}` : "Unranked", |
| t1: pl1 ? (pl1.leagueTier || 0) : undefined, |
| t2: pl2 ? (pl2.leagueTier || 0) : undefined |
| }; |
| } catch(e) { |
| console.warn("Error loading ranks:", e); |
| return { r1: "?", r2: "?" }; |
| } |
| } |
| |
| function showVSScreen(n1, e1, n2, e2, callback) { |
| document.getElementById("vsName1").textContent = dn(n1); |
| document.getElementById("vsElo1").textContent = "ELO " + Math.round(e1); |
| document.getElementById("vsRank1").textContent = "Rank: Loading..."; |
| |
| document.getElementById("vsName2").textContent = dn(n2); |
| document.getElementById("vsElo2").textContent = "ELO " + Math.round(e2); |
| document.getElementById("vsRank2").textContent = "Rank: Loading..."; |
| |
| showScreen("vsScreen"); |
| |
| getPlayerRanks(n1, n2).then(ranks => { |
| document.getElementById("vsRank1").textContent = "Rank: " + ranks.r1; |
| document.getElementById("vsRank2").textContent = "Rank: " + ranks.r2; |
| if (ranks.t1 !== undefined && n1 !== "Online Bot") document.getElementById("vsElo1").textContent = "ELO " + Math.round(e1) + " · " + leagueLabel(ranks.t1); |
| if (ranks.t2 !== undefined && n2 !== "Online Bot") document.getElementById("vsElo2").textContent = "ELO " + Math.round(e2) + " · " + leagueLabel(ranks.t2); |
| |
| document.getElementById("onlineRankMe").textContent = `(${ranks.r1})`; |
| document.getElementById("onlineRankOpp").textContent = `(${ranks.r2})`; |
| }); |
| |
| let cd = 3; |
| document.getElementById("vsCountdown").textContent = "Starting in " + cd + "…"; |
| const iv = setInterval(() => { |
| cd--; |
| if (cd <= 0) { |
| clearInterval(iv); |
| document.getElementById("vsScreen").style.display = "none"; |
| callback(); |
| } else { |
| document.getElementById("vsCountdown").textContent = "Starting in " + cd + "…"; |
| } |
| }, 1000); |
| } |
| |
| |
| // ═══════════════════════════════════════════════ |
| // SEEDED RNG (deterministic across both clients) |
| // ═══════════════════════════════════════════════ |
| function makeLCG(seed) { |
| let s = seed >>> 0; |
| return function() { |
| s = (Math.imul(1664525, s) + 1013904223) >>> 0; |
| return s / 4294967296; |
| }; |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // ONLINE GAME |
| // ═══════════════════════════════════════════════ |
| function startOnlineGame(matchData) { |
| setOnlineAspect(matchData && matchData.arena); |
| hideAllScreensForGame(); |
| mode = "online"; |
| document.body.classList.add("mode-online"); |
| balls = []; |
| players = []; |
| eliminated = []; |
| running = true; |
| startTime = Date.now(); |
| onlineResultHandled = false; |
| campReset(); onlineMatchStatus = null; |
| onlineSimTick = 0; |
| myTrail = []; |
| |
| onlineRng = makeLCG(matchData.seed || 42); |
| onlineBallSpawnTimer = Date.now(); |
| |
| document.getElementById("mode").textContent = onlineVsBot ? "Online 1v1 (Bot)" : "Online 1v1"; |
| |
| const myColor = myBallColor; |
| const oppColor = myBallColor.toLowerCase() === "#ff8844" ? "#00c8ff" : "#ff8844"; |
| const myName = onlineAccount.name; |
| const oppName = onlineOpponent.name; |
| const scoreHUD = document.getElementById("onlineScoreHUD"); |
| if (scoreHUD) scoreHUD.textContent = "0 - 0"; |
| |
| // CHANGED: head-to-head is now namespaced by UID pair, not name pair. |
| if (!onlineVsBot && fbReady) { |
| getHeadToHeadScore(myUid, onlineOpponent.uid).then(score => { |
| const myWins = score[myUid] || 0; |
| const oppWins = score[onlineOpponent.uid] || 0; |
| if (scoreHUD) scoreHUD.textContent = `${myWins} - ${oppWins}`; |
| }); |
| } else if (onlineVsBot) { |
| const botScoreKey = `h2h_bot_${myUid}`; |
| const botScore = JSON.parse(localStorage.getItem(botScoreKey) || '{"me":0,"bot":0}'); |
| if (scoreHUD) scoreHUD.textContent = `${botScore.me} - ${botScore.bot}`; |
| } |
| |
| const p1x = W() * 0.3, p2x = W() * 0.7, py = H() / 2; |
| const meX = onlineRole === "p1" ? p1x : p2x; |
| const oppX = onlineRole === "p1" ? p2x : p1x; |
| |
| const mePlayer = createPlayer(meX, py, myColor, myName); |
| const oppPlayer = createPlayer(oppX, py, oppColor, oppName); |
| |
| oppPlayer.tx = oppX; |
| oppPlayer.ty = py; |
| // NEW: network-smoothing bookkeeping for the opponent's reconstructed |
| // motion. lastPacketAt / prevPacketAt let us measure the real gap |
| // between their last two updates so the catch-up speed can adapt. |
| oppPlayer.lastPacketAt = 0; |
| oppPlayer.prevPacketAt = 0; |
| oppPlayer.netVX = 0; |
| oppPlayer.netVY = 0; |
| |
| if (onlineVsBot) { |
| const s = 0.20; |
| oppPlayer.skill = { aim:s, reaction:s, movement:s, awareness:s, aggression:s }; |
| oppPlayer.speed = 2.5 + s * 8; |
| oppPlayer.reaction = 2 + s * 6; |
| oppPlayer.awareness = 80 + s * 100; |
| oppPlayer.dodgePower = 0.5 + s * 3.5; |
| oppPlayer.randomness = (1 - s) * 4; |
| } |
| |
| players.push(mePlayer, oppPlayer); |
| |
| document.getElementById("onlineNameMe").textContent = dn(myName); |
| document.getElementById("onlineNameOpp").textContent = dn(oppName); |
| document.getElementById("onlineHUD").style.display = "flex"; |
| document.getElementById("pingDisplay").style.display = "block"; |
| document.getElementById("onlineStatusHUD").textContent = onlineVsBot ? "Practice match" : ""; |
| |
| if (onlineVsBot) { |
| if (onlineIsHost) { |
| { clearInterval(onlineBallIv); onlineBallIv = setInterval(pushBallState, 100); } |
| } |
| // Bot-Matches sind fuer Zuschauer sichtbar -> Verbindungsabbruch muss das Match beenden (sonst "Zombie"-Match im TV) |
| armOnlineDisconnect(false); |
| if (window._pingIvOld) clearInterval(window._pingIvOld); |
| const botPingId = onlineMatchId; |
| const botPingIv = window._pingIvOld = setInterval(() => { |
| if (!running || mode !== "online" || onlineMatchId !== botPingId) { clearInterval(botPingIv); return; } |
| RPC.setMatchPath(botPingId, "ping_" + onlineRole, Date.now()).catch(() => {}); |
| }, 2500); |
| return; |
| } |
| |
| |
| if (onlineIsHost) { |
| { clearInterval(onlineBallIv); onlineBallIv = setInterval(pushBallState, 100); } |
| } |
| |
| // ── Disconnect protection (real players only) ── |
| setActiveMatchMarker(onlineMatchId, forfeitChange(false)); |
| armOnlineDisconnect(true); |
| const wdOppRole = onlineRole === "p1" ? "p2" : "p1"; |
| const wdMatchId = onlineMatchId; |
| let wdStatus = null, wdSig = "", wdLastBeat = Date.now(); |
| const wdIv = setInterval(() => { |
| if (onlineMatchId !== wdMatchId || onlineResultHandled) { clearInterval(wdIv); return; } |
| if (mode !== "online" || !running) return; |
| const limit = wdStatus === "playing" ? OPP_TIMEOUT_PLAYING_MS : OPP_TIMEOUT_WAITING_MS; |
| if (Date.now() - wdLastBeat > limit) { clearInterval(wdIv); forfeitOpponent(); } |
| }, 500); |
| onlineUnsubscribes.push(() => clearInterval(wdIv)); |
| |
| // CHANGED: listens on onlineMatches/{matchId} via the guarded RPC. |
| // Both p1 and p2 may write to this doc (per the security model — see |
| // RULES.txt), since both are listed participant uids. |
| const matchUnsub = RPC.onMatch(onlineMatchId, (data) => { |
| if (!data) return; |
| |
| // Opponent heartbeat: any change of their ping / position / (as host) ball counts as "alive" |
| onlineMatchStatus = data.status; |
| if (data.dq === "camp" && data.winner && data.winner !== myName && !document.getElementById("campMsg")) |
| campBanner("campMsg", "🚫 " + tr("Corner camping is not allowed – you lost the match."), "#ff4444", 8000); |
| if (data.status !== wdStatus) { wdStatus = data.status; wdLastBeat = Date.now(); } |
| const wdNow = String(data["ping_" + wdOppRole]) + "|" + String(data[wdOppRole + "Ready"]) + "|" + JSON.stringify(data[wdOppRole + "State"] || null) + "|" + (!onlineIsHost ? data.ballState : ""); |
| if (wdNow !== wdSig) { wdSig = wdNow; wdLastBeat = Date.now(); } |
| |
| if (onlineIsHost && data.status === "waiting" && data.p1Ready && data.p2Ready) { |
| RPC.updateMatch(onlineMatchId, { status: "playing" }).catch(() => {}); |
| } |
| |
| const oppRole = onlineRole === "p1" ? "p2" : "p1"; |
| |
| const oppState = data[oppRole + "State"]; |
| if (oppState) { |
| const op = players.find(p => p.name !== myName); |
| if (op) { |
| feedNetPlayer(op, oppState); |
| } |
| } |
| |
| if (!onlineIsHost && data.ballState) { |
| try { |
| const bs = JSON.parse(data.ballState); |
| applyBallCorrection(bs); |
| } catch(e) {} |
| } |
| |
| if (data.status === "done" && data.winner && !onlineResultHandled) { |
| handleOnlineResult(data.winner, data.eloChange || 10); |
| } |
| if (data.status === "abandoned" && !onlineResultHandled) { |
| running = false; |
| if (data.forfeitBy && data.forfeitBy === myUid) handleOnlineResult(oppName, data.eloChange || forfeitChange(false)); // I was the one who dropped out |
| else handleOnlineResult(myName, data.eloChange || forfeitChange(true)); // opponent left / timed out |
| } |
| if (data.status === "waiting") { |
| document.getElementById("onlineStatusHUD").textContent = "Waiting for opponent…"; |
| } else { |
| document.getElementById("onlineStatusHUD").textContent = ""; |
| } |
| }); |
| onlineUnsubscribes.push(matchUnsub); |
| |
| RPC.updateMatch(onlineMatchId, { |
| [onlineRole + "Ready"]: true |
| }).catch(() => {}); |
| |
| const pingHistory = []; |
| if (window._pingIvOld) clearInterval(window._pingIvOld); |
| const pingIv = window._pingIvOld = setInterval(async () => { |
| if (!running || mode !== "online") { clearInterval(pingIv); return; } |
| const t0 = Date.now(); |
| try { |
| await RPC.setMatchPath(onlineMatchId, "ping_" + onlineRole, Date.now()); |
| const rtt = Date.now() - t0; |
| pingHistory.push(rtt); |
| if (pingHistory.length > 3) pingHistory.shift(); |
| const sorted = [...pingHistory].sort((a,b)=>a-b); |
| const median = sorted[Math.floor(sorted.length/2)]; |
| document.getElementById("pingDisplay").textContent = "📶 " + median + " ms"; |
| } catch(e) {} |
| }, 1000); |
| RPC.setMatchPath(onlineMatchId, "ping_" + onlineRole, Date.now()).catch(() => {}); // sofortiges erstes Lebenszeichen |
| } |
| |
| function makeOnlineBall() { |
| const rng = onlineRng || Math.random; |
| const r = 15 + rng() * 20; |
| let x, y, valid = false, tries = 0; |
| const minDistance = 220; |
| |
| while (!valid && tries++ < 100) { |
| x = r + rng() * (W() - r * 2); |
| y = r + rng() * (H() - r * 2); |
| valid = true; |
| for (const p of players) { |
| if (!p.alive) continue; |
| const dx = p.x - x, dy = p.y - y; |
| if (Math.sqrt(dx*dx + dy*dy) < p.radius + r + minDistance) { valid = false; break; } |
| } |
| } |
| const spd = 5 + rng() * 6; |
| const ang = rng() * Math.PI * 2; |
| return { |
| x, y, radius: r, |
| vx: Math.cos(ang) * spd * ONLINE_BALL_SPEED, |
| vy: Math.sin(ang) * spd * ONLINE_BALL_SPEED |
| }; |
| } |
| |
| // CHANGED: ball correction now scales the lerp factor by how long it's |
| // been since the previous correction, instead of a flat 0.15 snap |
| // every time a correction arrives. With irregular host packet timing |
| // (host has bad ping) this avoids visible "snapping" — small/frequent |
| // corrections stay gentle, while a correction after a longer gap pulls |
| // harder so we don't drift indefinitely out of sync. |
| let tvFreezeBalls = false; |
| // Streak-TV: Baelle per ID abgleichen (Verfolger laufen ab / neue kommen dazu, Indizes verschieben sich sonst) |
| function applyStreakTvBalls(arr) { |
| if (!arr.length || arr[0].i === undefined) { applyBallCorrection(arr, 0.35); return; } |
| const old = new Map(); |
| for (const b of balls) if (b.id !== undefined) old.set(b.id, b); |
| const out = []; |
| for (const a of arr) { |
| let b = old.get(a.i); |
| if (b) { const pull = b.chase ? 0.6 : 0.35; b.x += (a.x - b.x) * pull; b.y += (a.y - b.y) * pull; } |
| else b = { id: a.i, x: a.x, y: a.y }; |
| b.vx = a.vx; b.vy = a.vy; b.radius = a.radius; |
| if (a.ch !== undefined) { b.chase = true; b.born = Date.now() - a.ch; } else delete b.chase; |
| out.push(b); |
| } |
| balls = out; |
| } |
| let lastBallCorrectionAt = 0; |
| function applyBallCorrection(authoritative, minPull) { |
| const now = Date.now(); |
| const gap = lastBallCorrectionAt ? Math.min(NET_SMOOTH.MAX_INTERVAL, now - lastBallCorrectionAt) : 120; |
| lastBallCorrectionAt = now; |
| // Roughly: short gap -> gentle ~0.15 pull (matches old feel when |
| // connection is good); long gap -> stronger pull so we catch up |
| // without a hard teleport. |
| const pull = Math.max(minPull || 0, Math.min(0.6, 0.15 * (gap / 120))); |
| |
| for (let i = 0; i < authoritative.length; i++) { |
| const a = authoritative[i]; |
| if (!balls[i]) { |
| balls[i] = { x: a.x, y: a.y, vx: a.vx, vy: a.vy, radius: a.radius }; |
| continue; |
| } |
| const b = balls[i]; |
| b.x += (a.x - b.x) * pull; |
| b.y += (a.y - b.y) * pull; |
| b.vx = a.vx; |
| b.vy = a.vy; |
| b.radius = a.radius; |
| } |
| if (balls.length > authoritative.length) balls.length = authoritative.length; |
| } |
| |
| let lastPosPush = 0; |
| let lastSentX = -999; |
| let lastSentY = -999; |
| let lastSentAlive = true; |
| |
| // CHANGED: writes to onlineMatches/{matchId}/p1State or p2State via |
| // the guarded RPC (allowed because both participant uids may write to |
| // a match they're listed in — see RULES.txt). We no longer write |
| // using a name-derived key. |
| function pushMyPosition() { |
| if (!fbReady || !onlineMatchId || !running || mode !== "online") return; |
| const now = Date.now(); |
| if (now - lastPosPush < 75) return; |
| |
| const me = players.find(p => p.name === myPlayName()); |
| if (!me) return; |
| |
| const roundedX = Math.round(me.x); |
| const roundedY = Math.round(me.y); |
| const aliveState = me.alive; |
| |
| if (!onlineVsBot && roundedX === lastSentX && roundedY === lastSentY && aliveState === lastSentAlive) { |
| return; |
| } |
| |
| lastPosPush = now; |
| lastSentX = roundedX; |
| lastSentY = roundedY; |
| lastSentAlive = aliveState; |
| |
| const stateKey = onlineRole === "p1" ? "p1State" : "p2State"; |
| const stateData = { x: roundedX, y: roundedY, alive: aliveState }; |
| |
| RPC.setMatchPath(onlineMatchId, stateKey, stateData).catch(() => {}); |
| |
| if (onlineVsBot) { |
| const bot = players.find(p => p.name !== myPlayName()); |
| if (bot) { |
| const botData = { x: Math.round(bot.x), y: Math.round(bot.y), alive: bot.alive }; |
| RPC.setMatchPath(onlineMatchId, "p2State", botData).catch(() => {}); |
| } |
| } |
| } |
| |
| |
| |
| let lastBallPush = 0; |
| function pushBallState() { |
| if (!onlineIsHost || !fbReady || !onlineMatchId || !running) return; |
| const now = Date.now(); |
| if (now - lastBallPush < 120) return; |
| lastBallPush = now; |
| |
| const bs = JSON.stringify(balls.map(b => ({ |
| x: Math.round(b.x), y: Math.round(b.y), |
| vx: parseFloat(b.vx.toFixed(2)), vy: parseFloat(b.vy.toFixed(2)), |
| radius: b.radius |
| }))); |
| |
| RPC.setMatchPath(onlineMatchId, "ballState", bs).catch(() => {}); |
| } |
| |
| |
| // CHANGED: ELO is now applied through clampEloChange() before being |
| // written (soft mitigation — see header comment). Head-to-head and |
| // bot-stat writes are now uid-keyed. The player's own profile write |
| // still goes only to players/{myUid} via pushAccountToFirebase(). |
| async function handleOnlineResult(winnerName, change) { |
| if (onlineResultHandled) return; |
| onlineResultHandled = true; |
| stopViewerCount(); |
| clearActiveMatchMarker(); |
| armOnlineDisconnect(false); |
| campWarn(false); |
| running = false; |
| clearAllTimers(); |
| document.getElementById("onlineHUD").style.display = "none"; |
| |
| const won = winnerName === onlineAccount.name; |
| const eloChange = clampEloChange(Math.round(change)); |
| const oppName = onlineOpponent ? onlineOpponent.name : "Online Bot"; |
| const oppUid = onlineOpponent ? onlineOpponent.uid : ONLINE_BOT_UID; |
| |
| if (!onlineVsBot && fbReady) { |
| const winnerUid = won ? myUid : oppUid; |
| const loserUid = won ? oppUid : myUid; |
| // FIX: BOTH players record the result (idempotent via roundKey), so a host that leaves |
| // right after the match no longer loses the score. Errors are logged instead of swallowed. |
| // Rundenschluessel direkt aus dem Match-Dokument auf dem Server holen (statt aus lokalem Zustand): |
| // Hat ein Client nach einem Rematch die neue Runde verpasst, bekam er einen anderen Schluessel als der Gegner |
| // und beide Clients haben denselben Sieg gezaehlt (6-0 -> 8-0). |
| const _rkMid = onlineMatchId, _rkLocal = onlineMatchId + ":" + onlineMatchStamp + ":" + onlineRound; |
| const roundKeyP = (async () => { |
| try { const md = await RPC.getMatch(_rkMid); if (md) return _rkMid + ":" + (Number(md.createdAt) || 0) + ":" + (md.round || 0); } catch (e) {} |
| return _rkLocal; |
| })(); |
| const showH2H = (score) => { |
| const scoreHUD = document.getElementById("onlineScoreHUD"); |
| if (scoreHUD) scoreHUD.textContent = `${score[myUid] || 0} - ${score[oppUid] || 0}`; |
| }; |
| roundKeyP.then(roundKey => recordHeadToHeadWin(winnerUid, loserUid, roundKey)).then(showH2H).catch(e => { |
| console.warn("h2h write failed", e); |
| getHeadToHeadScore(myUid, oppUid).then(showH2H).catch(() => {}); |
| }); |
| } else if (onlineVsBot) { |
| const botScoreKey = `h2h_bot_${myUid}`; |
| const botScore = JSON.parse(localStorage.getItem(botScoreKey) || '{"me":0,"bot":0}'); |
| if (won) { |
| botScore.me++; |
| } else { |
| botScore.bot++; |
| } |
| localStorage.setItem(botScoreKey, JSON.stringify(botScore)); |
| syncBotH2H(botScore); |
| if (fbReady && onlineMatchId) RPC.updateMatch(onlineMatchId, { status: "done", winner: winnerName }).catch(() => {}); |
| const scoreHUD = document.getElementById("onlineScoreHUD"); |
| if (scoreHUD) scoreHUD.textContent = `${botScore.me} - ${botScore.bot}`; |
| } |
| |
| const myOriginalElo = Math.round(onlineAccount.elo); |
| const oppOriginalElo = onlineOpponent ? Math.round(onlineOpponent.elo) : 1500; |
| |
| onlineAccount = sanitizeAccount(onlineAccount); |
| |
| if (won) { |
| onlineAccount.elo += eloChange; |
| onlineAccount.wins++; |
| onlineAccount.currentStreak++; |
| if (onlineAccount.currentStreak > onlineAccount.bestStreak) { |
| onlineAccount.bestStreak = onlineAccount.currentStreak; |
| } |
| onlineAccount.totalEloGained += eloChange; |
| |
| if (onlineAccount.elo > onlineAccount.highestElo) { |
| onlineAccount.highestElo = onlineAccount.elo; |
| } |
| |
| if (oppOriginalElo > (onlineAccount.bestWinChange || 0)) { |
| onlineAccount.bestWinChange = oppOriginalElo; |
| onlineAccount.bestWinOpp = oppName; |
| } |
| } else { |
| onlineAccount.elo = Math.max(100, onlineAccount.elo - eloChange); |
| onlineAccount.losses++; |
| onlineAccount.currentStreak = 0; |
| onlineAccount.totalEloLost += eloChange; |
| |
| if (onlineAccount.elo < onlineAccount.lowestElo) { |
| onlineAccount.lowestElo = onlineAccount.elo; |
| } |
| |
| if (onlineAccount.worstLossChange === 0 || oppOriginalElo < onlineAccount.worstLossChange) { |
| onlineAccount.worstLossChange = oppOriginalElo; |
| onlineAccount.worstLossOpp = oppName; |
| } |
| } |
| |
| if (!onlineVsBot && onlineOpponent && Number.isFinite(Number(onlineOpponent.elo))) { |
| onlineOpponent.elo = won ? Math.max(100, onlineOpponent.elo - eloChange) : onlineOpponent.elo + eloChange; |
| } |
| saveAccountLocal(); |
| updateAccountBtn(); |
| lastLeagueNote = ""; |
| if (fbReady) await pushAccountToFirebase(); // writes ONLY players/{myUid} |
| if (won && fbReady) leagueRecordWin().catch(e => console.warn("league win failed", e)); |
| |
| showEloAnimation(won ? eloChange : -eloChange); |
| setTimeout(() => showWinnerOverlay(winnerName, won ? eloChange : -eloChange), 2600); |
| |
| // Bot stats: written to players/{ONLINE_BOT_UID}. This is the ONE |
| // place a client writes to a uid that isn't its own — acceptable |
| // because the bot is a synthetic system account, not a real player's |
| // identity, and should be locked down in Rules to only allow writes |
| // that keep it looking like a bot record (see RULES.txt note on |
| // "players/bot__online_bot"). If you want zero exceptions, move this |
| // to a Cloud Function trigger instead. |
| if (onlineVsBot && onlineOpponent) { |
| onlineOpponent = sanitizeAccount(onlineOpponent); |
| |
| if (won) { |
| onlineOpponent.elo = Math.max(100, onlineOpponent.elo - eloChange); |
| onlineOpponent.losses = (onlineOpponent.losses ?? 0) + 1; |
| onlineOpponent.currentStreak = 0; |
| onlineOpponent.totalEloLost = (onlineOpponent.totalEloLost ?? 0) + eloChange; |
| |
| if (onlineOpponent.elo < (onlineOpponent.lowestElo ?? onlineOpponent.elo)) { |
| onlineOpponent.lowestElo = onlineOpponent.elo; |
| } |
| |
| if (onlineOpponent.worstLossChange === 0 || myOriginalElo < onlineOpponent.worstLossChange) { |
| onlineOpponent.worstLossChange = myOriginalElo; |
| onlineOpponent.worstLossOpp = onlineAccount.name; |
| } |
| } else { |
| onlineOpponent.elo += eloChange; |
| onlineOpponent.totalEloGained = (onlineOpponent.totalEloGained ?? 0) + eloChange; // fehlte -> Bot hatte nie "Elo gewonnen" |
| onlineOpponent.wins = (onlineOpponent.wins ?? 0) + 1; |
| onlineOpponent.currentStreak = (onlineOpponent.currentStreak ?? 0) + 1; |
| if (onlineOpponent.currentStreak > (onlineOpponent.bestStreak ?? 0)) { |
| onlineOpponent.bestStreak = onlineOpponent.currentStreak; |
| } |
| if (onlineOpponent.elo > (onlineOpponent.highestElo ?? onlineOpponent.elo)) { |
| onlineOpponent.highestElo = onlineOpponent.elo; |
| } |
| |
| if (onlineOpponent.bestWinChange === 0 || myOriginalElo > onlineOpponent.bestWinChange) { |
| onlineOpponent.bestWinChange = myOriginalElo; |
| onlineOpponent.bestWinOpp = onlineAccount.name; |
| } |
| } |
| |
| // Reparatur für alte Daten: Netto-Elo = gewonnen - verloren (Start 1500) -> fehlenden Wert nachrechnen |
| if ((onlineOpponent.wins ?? 0) > 0) { |
| const minGained = Math.max(0, Math.round(onlineOpponent.elo - 1500 + (onlineOpponent.totalEloLost ?? 0))); |
| if (!(onlineOpponent.totalEloGained >= minGained)) onlineOpponent.totalEloGained = Math.max(minGained, onlineOpponent.wins); |
| } |
| |
| onlineBotElo = onlineOpponent.elo; |
| localStorage.setItem("onlineBotElo", onlineBotElo); |
| |
| if (fbReady) { |
| await dbSetBotPlayer({ |
| uid: ONLINE_BOT_UID, |
| name: "Online Bot", |
| elo: onlineBotElo, |
| wins: onlineOpponent.wins ?? 0, |
| losses: onlineOpponent.losses ?? 0, |
| highestElo: onlineOpponent.highestElo ?? onlineBotElo, |
| lowestElo: onlineOpponent.lowestElo ?? onlineBotElo, |
| currentStreak: onlineOpponent.currentStreak ?? 0, |
| bestStreak: onlineOpponent.bestStreak ?? 0, |
| totalEloGained: onlineOpponent.totalEloGained ?? 0, |
| totalEloLost: onlineOpponent.totalEloLost ?? 0, |
| bestWinChange: onlineOpponent.bestWinChange ?? 0, |
| bestWinOpp: onlineOpponent.bestWinOpp ?? "", |
| worstLossChange: onlineOpponent.worstLossChange ?? 0, |
| worstLossOpp: onlineOpponent.worstLossOpp ?? "", |
| updatedAt: Date.now() |
| }); |
| } |
| } |
| |
| if (fbReady && onlineMatchId && onlineVsBot) { |
| RPC.updateMatch(onlineMatchId, { |
| status: "done", |
| winner: winnerName |
| }).catch(() => {}); |
| } |
| } |
| |
| // Helper used only for the synthetic bot account write above. Kept as |
| // its own tiny function (rather than extending the guarded RPC's |
| // "own player only" rule) so the single intentional exception is easy |
| // to find, audit, and remove later if bot-stat updates are moved to a |
| // Cloud Function instead. |
| async function dbSetBotPlayer(data) { |
| if (!RPC) return; |
| const { ref, set } = await import("https://www.gstatic.com/firebasejs/10.12.0/firebase-database.js"); |
| // We deliberately do NOT use RPC.setOwnPlayer here since the target |
| // is not our own uid. This path MUST be restricted server-side (see |
| // RULES.txt) to only allow writes that match the bot's expected |
| // schema, since it's the one place this client touches a uid that |
| // isn't its own. |
| try { |
| const dbInst = (await import("https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js")).getApp(); |
| const { getDatabase } = await import("https://www.gstatic.com/firebasejs/10.12.0/firebase-database.js"); |
| const db = getDatabase(dbInst); |
| await set(ref(db, "players/" + ONLINE_BOT_UID), data); |
| } catch(e) { |
| console.warn("Bot stat write failed (expected if Rules restrict it):", e); |
| } |
| } |
| |
| |
| function showWinnerOverlay(winnerName, eloChange) { |
| document.getElementById("winnerText").innerHTML = |
| `🏆 Winner: <span style="color:gold">${escapeHtml(dn(winnerName))}</span>`; |
| document.getElementById("winnerEloChange").textContent = |
| `Your ELO: ${Math.round(onlineAccount.elo)} (${eloChange >= 0 ? "+" : ""}${Math.round(eloChange)})`; |
| updateWinnerLeagueLine(); |
| |
| const btns = document.getElementById("winnerButtons"); |
| btns.innerHTML = ""; |
| rematchSent = false; |
| const hideOverlay = () => { document.getElementById("winnerOverlay").style.display = "none"; }; |
| const mk = (label, fn) => { const b = document.createElement("button"); b.textContent = label; b.onclick = fn; btns.appendChild(b); return b; }; |
| mk("🔄 Rematch", requestRematch).id = "rematchBtn"; |
| mk("🔍 New Opponent", () => { hideOverlay(); cleanupOnline(); enterOnlineQueue(); }); |
| mk("🏠 Home", () => { hideOverlay(); exitToMenu(); }); |
| |
| document.getElementById("postChat").style.display = onlineVsBot ? "none" : "flex"; |
| document.getElementById("postChatMsgs").innerHTML = ""; |
| document.getElementById("winnerOverlay").style.display = "flex"; |
| |
| if (!onlineVsBot && fbReady && onlineMatchId) { |
| if (postUnsub) { try { postUnsub(); } catch(e) {} } |
| postUnsub = RPC.onMatch(onlineMatchId, handlePostMatch); |
| onlineUnsubscribes.push(postUnsub); |
| } |
| } |
| |
| function requestRematch() { |
| if (onlineVsBot) { |
| document.getElementById("winnerOverlay").style.display = "none"; |
| cleanupOnline(); |
| startBotMatch(); |
| return; |
| } |
| if (!onlineMatchId || rematchSent) return; |
| rematchSent = true; |
| RPC.updateMatch(onlineMatchId, { ["rematch_" + onlineRole]: true }).catch(() => {}); |
| document.getElementById("rematchBtn").textContent = "⏳ Waiting for opponent…"; |
| } |
| |
| function handlePostMatch(d) { |
| if (!d) return; |
| const box = document.getElementById("postChatMsgs"); |
| box.innerHTML = ""; |
| const list = d.postChat ? Object.values(d.postChat).sort((x, y) => (x.ts || 0) - (y.ts || 0)) : []; |
| for (const m of list) { |
| const row = document.createElement("div"); |
| const nm = document.createElement("b"); |
| nm.setAttribute("translate", "no"); |
| nm.textContent = dn(m.name) + ": "; |
| nm.style.color = (m.name === onlineAccount.name) ? "#00ff88" : "#ff8844"; |
| const tx = document.createElement("span"); |
| tx.setAttribute("translate", "no"); |
| tx.textContent = m.text; |
| tx.style.cssText = "color:#eaf2fb;word-break:break-word;"; |
| row.append(nm, tx); |
| box.appendChild(row); |
| } |
| box.scrollTop = box.scrollHeight; |
| |
| const rb = document.getElementById("rematchBtn"); |
| const oppRole = onlineRole === "p1" ? "p2" : "p1"; |
| if (rb) { |
| if (d.status === "abandoned") { rb.textContent = "Opponent left"; rb.disabled = true; } |
| else if (d["rematch_" + onlineRole]) rb.textContent = "⏳ Waiting for opponent…"; |
| else if (d["rematch_" + oppRole]) rb.textContent = "🔄 Accept Rematch"; |
| } |
| if (d.status === "abandoned") return; |
| |
| // Beide wollen Rematch -> Host setzt das Match zurück (neuer Seed) |
| if (onlineIsHost && d.rematch_p1 && d.rematch_p2) { |
| RPC.updateMatch(onlineMatchId, { |
| status: "waiting", winner: null, eloChange: null, |
| p1State: null, p2State: null, ballState: null, p1Ready: null, p2Ready: null, |
| rematch_p1: null, rematch_p2: null, postChat: null, |
| seed: Math.floor(Math.random() * 1000000), round: (d.round || 0) + 1, |
| createdAt: Date.now() |
| }).catch(() => {}); |
| } |
| if ((d.round || 0) > onlineRound && d.status === "waiting") { |
| onlineRound = d.round; |
| onlineMatchStamp = Number(d.createdAt) || 0; |
| const u = [...onlineUnsubscribes]; onlineUnsubscribes = []; postUnsub = null; |
| u.forEach(f => { try { f(); } catch(e) {} }); |
| document.getElementById("winnerOverlay").style.display = "none"; |
| onlineResultHandled = false; |
| const _go = () => showVSScreen(onlineAccount.name, onlineAccount.elo, onlineOpponent.name, onlineOpponent.elo, () => startOnlineGame(d)); |
| if (fbReady && onlineOpponent && onlineOpponent.uid) { |
| dbGet("players/" + onlineOpponent.uid).then(pp => { |
| if (pp && Number.isFinite(Number(pp.elo))) onlineOpponent.elo = Number(pp.elo); |
| }).catch(() => {}).then(_go); |
| } else _go(); |
| } |
| } |
| |
| function sendPostChat() { |
| const inp = document.getElementById("postChatInput"); |
| const text = inp.value.trim(); |
| if (!text || !fbReady || !onlineMatchId || onlineVsBot || Date.now() - lastPostChat < 600) return; |
| lastPostChat = Date.now(); |
| inp.value = ""; |
| RPC.setMatchPath(onlineMatchId, "postChat/" + Date.now() + "_" + onlineRole, |
| { name: onlineAccount.name, text: text.slice(0, 200), ts: Date.now() }).catch(() => {}); |
| } |
| document.getElementById("postChatInput").addEventListener("keydown", e => { if (e.key === "Enter") sendPostChat(); }); |
| |
| // ═══════════════════════════════════════════════ |
| // AI |
| // ═══════════════════════════════════════════════ |
| function getSkillValue(p) { |
| if (typeof p.skill === "number") return p.skill; |
| const s = p.skill || {}; |
| return ((s.aim||0.5)+(s.reaction||0.5)+(s.movement||0.5)+(s.awareness||0.5)+(s.aggression||0.5)) / 5; |
| } |
| |
| function updateAI(p, activeBalls) { |
| if (!p.alive) return; |
| if (mode === "coin" && p.stunnedUntil && Date.now() < p.stunnedUntil) return; |
| |
| let danger = false; |
| |
| for (const b of activeBalls) { |
| const dx = p.x - b.x, dy = p.y - b.y; |
| if (Math.sqrt(dx*dx + dy*dy) < p.awareness) { danger = true; break; } |
| } |
| |
| if (!danger) { |
| if (mode === "coin" && coins.length > 0) { |
| let nearestCoin = null; |
| let minDist = Infinity; |
| for (const c of coins) { |
| const dx = c.x - p.x, dy = c.y - p.y; |
| const dist = Math.sqrt(dx*dx + dy*dy); |
| if (dist < minDist) { |
| minDist = dist; |
| nearestCoin = c; |
| } |
| } |
| if (nearestCoin) { |
| p.targetX = nearestCoin.x; |
| p.targetY = nearestCoin.y; |
| } |
| } else { |
| if (p.targetX === null || p.targetY === null || Math.random() < 0.02) { |
| p.targetX = Math.random() * W(); |
| p.targetY = Math.random() * H(); |
| } |
| } |
| } |
| |
| let moveX = (p.targetX || W()/2) - p.x; |
| let moveY = (p.targetY || H()/2) - p.y; |
| const moveDist = Math.sqrt(moveX*moveX + moveY*moveY) || 1; |
| moveX /= moveDist; moveY /= moveDist; |
| let avoidX = 0, avoidY = 0; |
| |
| const wallMargin = (mode === "coin") ? 0 : 140; |
| |
| if (wallMargin > 0) { |
| if (p.x < wallMargin) avoidX += (wallMargin - p.x) * 0.03; |
| if (p.x > W()-wallMargin) avoidX -= (p.x - (W()-wallMargin)) * 0.03; |
| if (p.y < wallMargin) avoidY += (wallMargin - p.y) * 0.03; |
| if (p.y > H()-wallMargin) avoidY -= (p.y - (H()-wallMargin)) * 0.03; |
| } |
| |
| for (const b of activeBalls) { |
| const dx = p.x - b.x, dy = p.y - b.y; |
| const dist = Math.sqrt(dx*dx + dy*dy) || 1; |
| if (dist < p.awareness) { |
| const strength = Math.max(0, (p.awareness - dist) / p.awareness); |
| const force = strength * p.dodgePower * 2; |
| avoidX += (dx / dist) * force; |
| avoidY += (dy / dist) * force; |
| } |
| } |
| let vx = moveX * 4 + avoidX * 10; |
| let vy = moveY * 4 + avoidY * 10; |
| vx += (Math.random() - 0.5) * p.randomness; |
| vy += (Math.random() - 0.5) * p.randomness; |
| const len = Math.sqrt(vx*vx + vy*vy) || 1; |
| p.x += (vx / len) * p.speed; |
| p.y += (vy / len) * p.speed; |
| p.x = Math.max(p.radius, Math.min(W()-p.radius, p.x)); |
| p.y = Math.max(p.radius, Math.min(H()-p.radius, p.y)); |
| p.lastVX = vx; p.lastVY = vy; |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // PLAYER FACTORY |
| // ═══════════════════════════════════════════════ |
| function createPlayer(x, y, color, name) { |
| const skill = { |
| aim: Math.random(), |
| reaction: Math.random(), |
| movement: Math.random(), |
| awareness: Math.random(), |
| aggression: Math.random() |
| }; |
| return { |
| x, y, |
| tx: x, ty: y, |
| radius: playerRadiusSetting, color, name, |
| points: 0, elo: 1500, lastChange: 0, alive: true, games: 0, |
| skill, |
| speed: 2 + skill.movement * 8 + skill.aggression * 2, |
| reaction: 0.3 + skill.reaction * 4, |
| awareness: 60 + skill.awareness * 140, |
| randomness: (1 - skill.movement) * 5, |
| dodgePower: 0.5 + skill.aim * 3 + skill.reaction * 1.5, |
| targetX: null, targetY: null, |
| lastVX: 0, lastVY: 0, |
| aiVX: 0, aiVY: 0, |
| arenaScore: 0, deathTime: 0, |
| // NEW: network-smoothing bookkeeping (only meaningful for the |
| // remote-controlled opponent in "online" mode; harmless no-ops |
| // for every other player/bot since nothing else reads them). |
| lastPacketAt: 0, prevPacketAt: 0, netVX: 0, netVY: 0, netInterval: 100, |
| stats: { |
| wins:0, |
| losses:0, |
| totalEloGain:0, |
| totalEloLoss:0, |
| matchesPlayed:0, |
| bestStreak:0, |
| currentStreak:0, |
| |
| bestWin:0, |
| bestWinOpp:"", |
| worstLoss:0, |
| worstLossOpp:"" |
| } |
| }; |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // BALLS (non-online) |
| // ═══════════════════════════════════════════════ |
| function makeBallAt(playersArr) { |
| const r = 15 + Math.random() * 20; |
| let x, y, valid = false, tries = 0; |
| while (!valid && tries++ < 50) { |
| x = r + Math.random() * (W() - r * 2); |
| y = r + Math.random() * (H() - r * 2); |
| valid = true; |
| for (const p of playersArr) { |
| if (!p.alive) continue; |
| const dx = p.x - x, dy = p.y - y; |
| if (Math.sqrt(dx*dx + dy*dy) < p.radius + r + 80) { valid = false; break; } |
| } |
| } |
| const spd = 5 + Math.random() * 6; |
| const ang = Math.random() * Math.PI * 2; |
| return { x, y, radius: r, |
| vx: Math.cos(ang) * spd * ballSpeedMultiplier, |
| vy: Math.sin(ang) * spd * ballSpeedMultiplier }; |
| } |
| |
| function createBall() { balls.push(makeBallAt(players)); } |
| function createBallForMatch(match) { match.balls.push(makeBallAt(match.players)); } |
| |
| function spawnLoop() { |
| if (!running) return; |
| spawnTimeoutId = setTimeout(() => { |
| if (running) { createBall(); spawnLoop(); } |
| }, spawnDelay); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // COIN MODE |
| // ═══════════════════════════════════════════════ |
| function spawnCoin() { |
| const r = 14; |
| coins.push({ |
| x: r + Math.random() * (W() - r * 2), |
| y: r + Math.random() * (H() - r * 2), |
| r, value: Math.random() < 0.15 ? 3 : 1 |
| }); |
| } |
| |
| function startCoinMode() { |
| mode = "coin"; coins = []; coinScorePlayer = 0; coinTimeLeft = 60; |
| document.getElementById("coinHUD").style.display = "block"; |
| document.getElementById("coinScore").textContent = 0; |
| document.getElementById("coinTime").textContent = 60; |
| |
| players = []; |
| players.push(createPlayer(W()/2, H()/2, myBallColor, "Player")); |
| for (let i = 0; i < aiBotCount; i++) |
| players.push(createPlayer(Math.random()*W(), Math.random()*H(), |
| `hsl(${i*360/aiBotCount},100%,50%)`, "Bot "+(i+1))); |
| |
| for (const p of players) { |
| p.coinsCollected = 0; |
| } |
| myTrail = []; |
| |
| for (let i = 0; i < 8; i++) spawnCoin(); |
| for (let i = 0; i < startBallCount; i++) createBall(); |
| spawnLoop(); |
| |
| coinIntervalId = setInterval(() => { |
| if (!running) { clearInterval(coinIntervalId); coinIntervalId = null; return; } |
| coinTimeLeft--; |
| document.getElementById("coinTime").textContent = coinTimeLeft; |
| |
| if (coinTimeLeft <= 0) { |
| clearInterval(coinIntervalId); coinIntervalId = null; |
| endCoinMode(); |
| } |
| }, 1000); |
| } |
| |
| |
| |
| // ═══════════════════════════════════════════════ |
| // SURVIVAL |
| // ═══════════════════════════════════════════════ |
| function startSurvivalMode() { |
| mode = "survival"; survivalWave = 1; |
| players = []; |
| players.push(createPlayer(W()/2, H()/2, myBallColor, "Player")); |
| for (let i = 0; i < aiBotCount; i++) |
| players.push(createPlayer(Math.random()*W(), Math.random()*H(), |
| `hsl(${i*360/aiBotCount},100%,50%)`, "Bot "+(i+1))); |
| myTrail = []; |
| for (let i = 0; i < startBallCount; i++) createBall(); |
| survivalIntervalId = setInterval(() => { |
| if (!running) { clearInterval(survivalIntervalId); survivalIntervalId = null; return; } |
| survivalWave++; |
| const count = Math.floor(survivalWave / 2) + 1; |
| for (let i = 0; i < count; i++) createBall(); |
| }, 10000); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // MIRROR DUEL |
| // ═══════════════════════════════════════════════ |
| |
| // ═══════════════════════════════════════════════ |
| // STREAK MODE (komplett eigener Modus: nur Bots, kein Elo, keine Online-Daten) |
| // Du spielst 1v1 gegen Bots. Jeder Sieg = +1 Streak und die Runde wird schwerer |
| // (Bot besser, Baelle schneller, ab Level 4 verfolgende Baelle). Eine Niederlage beendet die Serie. |
| // Alle Zahlen zum Feintunen stehen in STREAK_CFG. |
| // ═══════════════════════════════════════════════ |
| const STREAK_CFG = { |
| botSkillStart: 0.10, botSkillStep: 0.065, botSkillMax: 0.97, // Bot-Faehigkeit 0..1 pro Level |
| // Level 1 = EXAKT wie im Online-Modus (ONLINE_START_BALLS, ONLINE_SPAWN_MS, ONLINE_BALL_SPEED, Groesse 15-35, Abstand 220). |
| // Danach steigt nur das Tempo der Baelle (Faktor) und es kommen verfolgende Baelle dazu. |
| ballSpeedStart: 1.0, ballSpeedStep: 0.04, ballSpeedMax: 2.0, |
| totalBallsMax: 200, // reine Sicherheitsgrenze |
| chaserFromLevel: 4, chaserEvery: 3, chaserMax: 4, chaserLifeMs: 9000, chaserSpawnMs: 1500, // verfolgende Baelle |
| introMs: 1600, betweenMs: 1500 |
| }; |
| let streak = null; |
| // Streak LIVE (fuer Online TV): gleicher Knoten wie Survival (survivalLive/{uid}), erkennbar am Feld k:"streak" -> keine neuen DB-Regeln noetig |
| let _skLiveAt = 0, _skTick = 0, _skLiveOn = false; |
| function streakPushLive(done) { |
| if (!fbReady || !myUid || !onlineAccount || !streak) return; |
| const now = Date.now(); |
| if (!done && now - _skLiveAt < 120) return; |
| _skLiveAt = now; _skTick++; |
| const me = players.find(p => p.name === "Player"), bot = players.find(p => p.name === "Bot"); |
| if (!me) return; |
| const d = { k: "streak", name: onlineAccount.name, c: myBallColor, elo: Math.round(onlineAccount.elo || 1500), r: me.radius, |
| x: Math.round(me.x), y: Math.round(me.y), ma: !!me.alive, |
| bx: bot ? Math.round(bot.x) : 0, by: bot ? Math.round(bot.y) : 0, ba: bot ? !!bot.alive : false, |
| t: _skTick, w: streak.wins, L: streakLevel(), rd: streak.rd || 0, ph: streak.phase, done: !!done }; |
| if (done) d.time = streak.wins; |
| d.b = JSON.stringify(balls.map(b => { const o = { i: b.id, x: Math.round(b.x), y: Math.round(b.y), vx: parseFloat(b.vx.toFixed(2)), vy: parseFloat(b.vy.toFixed(2)), radius: b.radius }; if (b.chase) o.ch = Math.max(0, now - b.born); return o; })); |
| RPC.setSurvivalLive(d).catch(() => {}); |
| } |
| let _skBallId = 0; |
| const streakSrv = { level: null, last: 0, busy: false, target: 0 }; // Stand in der Bestenliste (Server) |
| const streakSleep = ms => new Promise(r => setTimeout(r, ms)); |
| // Der Server erlaubt +1 Level pro Schreibvorgang und mindestens 6s Abstand. Darum wird Level fuer Level hochgezaehlt. |
| async function streakFlush() { |
| if (streakSrv.busy) return; |
| streakSrv.busy = true; |
| try { |
| while (fbReady && myUid && onlineAccount) { |
| if (streakSrv.level === null) { |
| const cur = await dbGet("streakBoard/" + myUid); |
| streakSrv.level = cur ? (Number(cur.level) || 0) : 0; |
| } |
| if (streakSrv.level >= streakSrv.target) break; |
| const wait = streakSrv.last + 6300 - Date.now(); |
| if (wait > 0) await streakSleep(wait); |
| await RPC.setStreakBoard({ name: onlineAccount.name, level: streakSrv.level + 1 }); |
| streakSrv.level++; streakSrv.last = Date.now(); |
| } |
| } catch (e) { console.warn("streak board sync failed", e && (e.code || e.message)); } |
| streakSrv.busy = false; |
| } |
| const streakBestKey = "dobStreakBest"; |
| function streakReadBest() { try { return parseInt(localStorage.getItem(streakBestKey) || "0", 10) || 0; } catch (e) { return 0; } } |
| function streakSetOverlay(html) { |
| let o = document.getElementById("streakOverlay"); |
| if (!html) { if (o) o.remove(); return null; } |
| if (!o) { o = document.createElement("div"); o.id = "streakOverlay"; document.body.appendChild(o); } |
| o.className = ""; o.innerHTML = html; |
| return o; |
| } |
| function streakCleanup() { |
| if (streak && streak.timers) { streak.timers.forEach(clearTimeout); streak.timers = []; } |
| const o = document.getElementById("streakOverlay"); if (o) o.remove(); |
| } |
| function streakLevel() { return streak ? streak.wins + 1 : 1; } |
| function streakHud() { |
| const ui = document.getElementById("ui"); if (!ui || !streak) return; |
| ui.innerHTML = `🔥 ${tr("Streaks")}: <b id="sk_w">${streak.wins}</b><br>${tr("Best")}: <span id="sk_b">${Math.max(streak.best, streak.wins)}</span><br>${tr("Round")}: <span id="sk_r">${streakLevel()}</span><br>${tr("Bot level")}: <span id="sk_l">${streakLevel()}</span>`; |
| } |
| function streakChaserTarget(L) { |
| const C = STREAK_CFG; |
| return L < C.chaserFromLevel ? 0 : Math.min(C.chaserMax, 1 + Math.floor((L - C.chaserFromLevel) / C.chaserEvery)); |
| } |
| // Normaler Ball: gleiche Formel wie makeOnlineBall(), nur mit Tempo-Faktor pro Level |
| function streakMakeBall(L, chase) { |
| const C = STREAK_CFG; |
| const mul = Math.min(C.ballSpeedMax, C.ballSpeedStart + C.ballSpeedStep * (L - 1)); |
| const r = 15 + Math.random() * 20; |
| let x, y, valid = false, tries = 0; |
| const minDistance = 220; |
| while (!valid && tries++ < 100) { |
| x = r + Math.random() * (W() - r * 2); |
| y = r + Math.random() * (H() - r * 2); |
| valid = true; |
| for (const p of players) { |
| if (!p.alive) continue; |
| const dx = p.x - x, dy = p.y - y; |
| if (Math.sqrt(dx * dx + dy * dy) < p.radius + r + minDistance) { valid = false; break; } |
| } |
| } |
| const spd = 5 + Math.random() * 6, ang = Math.random() * Math.PI * 2; |
| const b = { id: ++_skBallId, x, y, radius: r, vx: Math.cos(ang) * spd * ONLINE_BALL_SPEED * mul, vy: Math.sin(ang) * spd * ONLINE_BALL_SPEED * mul }; |
| if (chase) { |
| b.chase = true; b.born = Date.now(); |
| b.radius = 13 + Math.random() * 6; |
| b.cspd = Math.hypot(b.vx, b.vy) * 0.85; |
| b.turn = Math.min(0.06, 0.018 + 0.0018 * L); // max. Drehung pro Tick (rad) -> wird schwerer |
| } |
| return b; |
| } |
| function streakStartRound() { |
| const C = STREAK_CFG, L = streakLevel(); |
| const sk = Math.min(C.botSkillMax, C.botSkillStart + C.botSkillStep * (L - 1)); |
| players = []; balls = []; myTrail = []; eliminated = []; |
| const me = createPlayer(W() * 0.25, H() / 2, myBallColor, "Player"); |
| me.label = (onlineAccount && onlineAccount.name) ? dn(onlineAccount.name) : "Player"; // Anzeige: Account-Name |
| const bot = createPlayer(W() * 0.75, H() / 2, "#ff88aa", "Bot"); |
| bot.label = "Online Bot"; |
| bot.skill = { aim: sk, reaction: sk, movement: sk, awareness: sk, aggression: sk }; |
| bot.speed = 2.2 + sk * 5.5; bot.awareness = 55 + sk * 130; |
| bot.dodgePower = 0.4 + sk * 2.6; bot.randomness = (1 - sk) * 5; |
| players.push(me, bot); |
| for (let i = 0; i < ONLINE_START_BALLS; i++) balls.push(streakMakeBall(L, false)); |
| const nc = streakChaserTarget(L); |
| for (let i = 0; i < nc; i++) balls.push(streakMakeBall(L, true)); |
| streak.rd = (streak.rd || 0) + 1; |
| streak.phase = "intro"; streak.phaseUntil = Date.now() + C.introMs; |
| campGenReset(); |
| streakHud(); |
| streakSetOverlay(`<div class="sk-title">${tr("ROUND")} ${L}</div><div class="sk-sub">${tr("Streaks")}: ${streak.wins}${nc ? " · 🟠 " + tr("chasing balls") : ""}</div>`); |
| } |
| function startStreak() { |
| if (!guestGate("streak")) return; |
| countModeRun("streakPlayed"); |
| clearAllTimers(); |
| waitingQueue = []; matches = []; playerMatchMap.clear(); |
| spectateModeActive = false; spectateMatchId = null; |
| followMode = "none"; targetName = null; humanRemovedFromQueue = false; |
| coins = []; mirrorBot = null; myTrail = []; |
| ["coinHUD", "opponentInfo", "spectatorInfo", "profileModal", "winnerOverlay", "onlineHUD"].forEach(id => { const e = document.getElementById(id); if (e) e.style.display = "none"; }); |
| mode = "streak"; |
| document.body.classList.remove("mode-online", "mode-tv"); |
| const et = document.getElementById("eloTable"); if (et) et.style.display = "none"; |
| streak = { wins: 0, best: streakReadBest(), phase: "intro", phaseUntil: 0, lastSpawn: 0, nextChase: 0, timers: [] }; |
| _skTick = 0; _skLiveAt = 0; |
| try { if (fbReady && myUid && onlineAccount) { RPC.armSurvivalLiveDisconnect().catch(() => {}); _skLiveOn = true; } } catch (e) {} |
| hideAllScreensForGame(); |
| balls = []; players = []; eliminated = []; |
| running = true; startTime = Date.now(); |
| streakStartRound(); |
| } |
| function streakHit(p) { |
| for (const b of balls) { |
| const dx = p.x - b.x, dy = p.y - b.y; |
| if (Math.sqrt(dx * dx + dy * dy) < p.radius + b.radius) return true; |
| } |
| return false; |
| } |
| function updateStreak() { |
| if (!streak) return; |
| const C = STREAK_CFG, now = Date.now(); |
| if (fbReady && myUid && onlineAccount) { |
| if (_viewKey !== "surv_" + myUid) watchViewerCount("surv_" + myUid, false); |
| streakPushLive(false); |
| } |
| if (streak.phase === "intro") { |
| if (now >= streak.phaseUntil) { streak.phase = "play"; streak.lastSpawn = now; streak.nextChase = now + C.chaserSpawnMs; streakSetOverlay(null); } |
| return; |
| } |
| if (streak.phase !== "play") return; |
| const L = streakLevel(); |
| const me = players.find(p => p.name === "Player"), bot = players.find(p => p.name === "Bot"); |
| |
| if (bot && bot.alive) updateAI(bot, balls); |
| for (const p of players) { |
| p.x = Math.max(p.radius, Math.min(W() - p.radius, p.x)); |
| p.y = Math.max(p.radius, Math.min(H() - p.radius, p.y)); |
| } |
| |
| // Spawn-Takt wie online: alle ONLINE_SPAWN_MS ein neuer Ball (gleiche Pruefung wie im Online-Update) |
| if (now - streak.lastSpawn > ONLINE_SPAWN_MS) { |
| streak.lastSpawn = now; |
| if (balls.length < C.totalBallsMax) balls.push(streakMakeBall(L, false)); |
| } |
| // verfolgende Baelle zusaetzlich / als Ersatz fuer abgelaufene |
| if (now >= streak.nextChase && balls.filter(b => b.chase).length < streakChaserTarget(L)) { |
| streak.nextChase = now + C.chaserSpawnMs; |
| balls.push(streakMakeBall(L, true)); |
| } |
| |
| // Verfolger lenken zum Spieler und laufen nach einer Weile ab |
| for (let i = balls.length - 1; i >= 0; i--) { |
| const b = balls[i]; |
| if (!b.chase) continue; |
| if (now - b.born > C.chaserLifeMs) { balls.splice(i, 1); continue; } |
| if (me && me.alive) { |
| const want = Math.atan2(me.y - b.y, me.x - b.x), cur = Math.atan2(b.vy, b.vx); |
| let d = want - cur; |
| while (d > Math.PI) d -= Math.PI * 2; |
| while (d < -Math.PI) d += Math.PI * 2; |
| const na = cur + Math.max(-b.turn, Math.min(b.turn, d)); |
| b.vx = Math.cos(na) * b.cspd; b.vy = Math.sin(na) * b.cspd; |
| } |
| } |
| |
| for (const b of balls) { |
| b.x += b.vx; b.y += b.vy; |
| if (b.x - b.radius < 0) { b.vx = Math.abs(b.vx); b.x = b.radius; } |
| if (b.x + b.radius > W()) { b.vx = -Math.abs(b.vx); b.x = W() - b.radius; } |
| if (b.y - b.radius < 0) { b.vy = Math.abs(b.vy); b.y = b.radius; } |
| if (b.y + b.radius > H()) { b.vy = -Math.abs(b.vy); b.y = H() - b.radius; } |
| } |
| |
| // Beide gleichzeitig getroffen = Niederlage fuer dich (keine Streak-Rettung durch Gleichstand) |
| const meHit = me && me.alive && streakHit(me), botHit = bot && bot.alive && streakHit(bot); |
| if (meHit) { me.alive = false; streakEnd(); return; } |
| if (botHit) { bot.alive = false; streakRoundWon(); } |
| } |
| function streakRoundWon() { |
| streak.phase = "won"; streak.wins++; |
| streakSrv.target = Math.max(streakSrv.target, streak.wins); streakFlush(); |
| if (streak.wins > streak.best) { streak.best = streak.wins; try { localStorage.setItem(streakBestKey, String(streak.best)); } catch (e) {} } |
| streakHud(); |
| streakSetOverlay(`<div class="sk-title" style="color:#00e676">✓ ${tr("ROUND WON")}</div><div class="sk-sub">${tr("Streaks")}: ${streak.wins}</div>`); |
| streak.timers.push(setTimeout(() => { if (mode === "streak" && streak && streak.phase === "won") streakStartRound(); }, STREAK_CFG.betweenMs)); |
| } |
| function streakEnd() { |
| if (!streak || streak.phase === "over") return; // darf nur einmal pro Run laufen (Kollision + Camping konnten beide ausloesen) |
| streak.phase = "over"; running = false; |
| campWarn(false); |
| try { streakPushLive(true); stopViewerCount(); } catch (e) {} // TV-Zuschauer sehen das Ende |
| streakFlush(); |
| const isNew = streak.wins > 0 && streak.wins >= streak.best; |
| streakHud(); |
| const o = streakSetOverlay(`<div class="sk-title" style="color:#ff4455">${tr("STREAKS ENDED")}</div> |
| <div class="sk-sub">${streak.wins} ${tr("wins in a row")} · ${tr("Best")}: ${streak.best}</div> |
| ${isNew ? `<div class="sk-new">★ ${tr("NEW BEST")} ★</div>` : ""} |
| <div class="sk-btns"><button id="skAgain">${tr("Try again")}</button><button id="skHome">🏠 ${tr("Home")}</button></div>`); |
| if (o) { |
| o.className = "sk-over"; |
| o.querySelector("#skAgain").addEventListener("click", () => startStreak()); |
| o.querySelector("#skHome").addEventListener("click", () => exitToMenu()); |
| } |
| } |
| |
| function startMirrorMode() { |
| mode = "mirror"; players = []; mirrorBot = null; myTrail = []; |
| const me = createPlayer(W() * 0.3, H() / 2, myBallColor, "YOU"); |
| mirrorBot = createPlayer(W() * 0.7, H() / 2, "#ff88aa", "Mirror"); |
| const s = 0.5 + Math.random() * 0.4; |
| mirrorBot.skill = { aim:s, reaction:s, movement:s, awareness:s, aggression:s }; |
| mirrorBot.speed = 1.0 + s * 1.2; |
| mirrorBot.awareness = 70 + s * 90; |
| mirrorBot.dodgePower = 0.4 + s * 1.6; |
| mirrorBot.randomness = (1-s) * 4; |
| players.push(me, mirrorBot); |
| for (let i = 0; i < startBallCount; i++) createBall(); |
| spawnLoop(); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // START GAME |
| // ═══════════════════════════════════════════════ |
| function startGame(selectedMode) { |
| if (!guestGate(selectedMode)) return; |
| clearAllTimers(); |
| waitingQueue = []; matches = []; playerMatchMap.clear(); |
| spectateModeActive = false; spectateMatchId = null; |
| followMode = "none"; targetName = null; humanRemovedFromQueue = false; |
| coins = []; mirrorBot = null; myTrail = []; |
| |
| document.getElementById("coinHUD").style.display = "none"; |
| document.getElementById("opponentInfo").style.display = "none"; |
| document.getElementById("spectatorInfo").style.display = "none"; |
| document.getElementById("profileModal").style.display = "none"; |
| document.getElementById("winnerOverlay").style.display = "none"; |
| document.getElementById("onlineHUD").style.display = "none"; |
| |
| mode = selectedMode; |
| document.body.classList.remove("mode-online", "mode-tv"); |
| document.getElementById("eloTable").style.display = (mode === "tournament") ? "block" : "none"; |
| |
| if (mode === "tournament") { |
| document.getElementById("ui").innerHTML = |
| `ELO: <span id="time">1500</span><br>Rank: <span id="record">1</span><br>Players: <span id="balls">0</span><br>Mode: <span id="mode">tournament</span>`; |
| } else { |
| document.getElementById("ui").innerHTML = |
| `Time: <span id="time">0</span>s<br>Record: <span id="record">${record}</span>s<br>Balls: <span id="balls">0</span><br>Mode: <span id="mode">${mode}</span>`; |
| } |
| |
| hideAllScreensForGame(); |
| balls = []; players = []; eliminated = []; |
| running = true; startTime = Date.now(); |
| |
| if (mode !== "multi" && mode !== "tournament") { |
| if (ranking.length === 0) { |
| for (let i = 0; i < aiBotCount; i++) |
| ranking.push(createPlayer(0, 0, `hsl(${i*360/aiBotCount},100%,50%)`, "Bot "+(i+1))); |
| } |
| } |
| |
| if (mode === "single") { |
| const rankedMe = ranking.find(x => x.name === "Player"); |
| if (!rankedMe) ranking.push({ name:"Player", color:myBallColor, points:0, elo:1500, lastChange:0 }); |
| else rankedMe.color = myBallColor; |
| players.push(createPlayer(W()/2, H()/2, myBallColor, "Player")); |
| for (const bot of ranking) { |
| if (bot.name === "Player") continue; |
| const nb = createPlayer(Math.random()*W(), Math.random()*H(), bot.color, bot.name); |
| nb.elo = bot.elo; nb.points = bot.points; nb.lastChange = bot.lastChange; |
| players.push(nb); |
| } |
| for (let i = 0; i < startBallCount; i++) createBall(); |
| spawnLoop(); |
| |
| } else if (mode === "multi") { |
| players.push( |
| createPlayer(200, 200, myBallColor, "Player 1"), |
| createPlayer(400, 200, "#4488ff", "Player 2") |
| ); |
| for (let i = 0; i < aiBotCount; i++) |
| players.push(createPlayer(Math.random()*W(), Math.random()*H(), |
| `hsl(${i*360/aiBotCount},100%,50%)`, "Bot "+(i+1))); |
| for (let i = 0; i < startBallCount; i++) createBall(); |
| spawnLoop(); |
| |
| } else if (mode === "tournament") { |
| startTournament(); return; |
| |
| } else if (mode === "ai") { |
| for (const bot of ranking) { |
| const nb = createPlayer(Math.random()*W(), Math.random()*H(), bot.color, bot.name); |
| nb.elo = bot.elo; nb.points = bot.points; nb.lastChange = bot.lastChange; |
| players.push(nb); |
| } |
| for (let i = 0; i < startBallCount; i++) createBall(); |
| spawnLoop(); |
| |
| } else if (mode === "coin") { startCoinMode(); return; |
| } else if (mode === "survival") { startSurvivalMode(); return; |
| } else if (mode === "mirror") { startMirrorMode(); return; |
| } |
| |
| if (["single","ai","multi"].includes(mode)) updateRanking(); |
| } |
| |
| function hideAllScreensForGame() { |
| ALL_SCREENS.forEach(s => { |
| const el = document.getElementById(s); |
| if (el) el.style.display = "none"; |
| }); |
| document.getElementById("homeBtn").style.display = "block"; |
| document.body.classList.add("ingame"); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // TOURNAMENT (fully offline / local — no DB access, unaffected by the |
| // security rewrite, kept as-is) |
| // ═══════════════════════════════════════════════ |
| function startTournament() { |
| running = true; matches = []; participants = []; waitingQueue = []; myTrail = []; |
| humanRemovedFromQueue = false; recentOpponents.clear(); |
| |
| const me = createPlayer(W()/2, H()/2, myBallColor, "YOU"); |
| participants.push(me); |
| |
| const skillList = Array.from({length: aiBotCount}, (_, i) => (i+1)/aiBotCount); |
| for (let i = skillList.length-1; i > 0; i--) { |
| const j = Math.floor(Math.random() * (i+1)); |
| [skillList[i], skillList[j]] = [skillList[j], skillList[i]]; |
| } |
| for (let i = 0; i < aiBotCount; i++) { |
| const s = skillList[i]; |
| const bot = createPlayer(Math.random()*W(), Math.random()*H(), |
| `hsl(${i*360/aiBotCount},100%,50%)`, "Bot "+(i+1)); |
| bot.skill = { aim:s, reaction:s, movement:s, awareness:s, aggression:s }; |
| bot.speed = 1.5 + s * 8; |
| bot.reaction = 2 + s * 6; |
| bot.awareness = 60 + s * 100; |
| bot.dodgePower = 0.5 + s * 3.5; |
| bot.randomness = (1-s) * 4; |
| participants.push(bot); |
| } |
| waitingQueue = [...participants]; |
| seededMatchmaking(); |
| } |
| |
| function seededMatchmaking() { |
| if (waitingQueue.length < 2) return; |
| const used = new Set(); |
| function findBestOpponent(p1, list) { |
| let best = null, bestDiff = Infinity; |
| for (const p2 of list) { |
| if (p1 === p2 || used.has(p2.name)) continue; |
| if (recentOpponents.get(p1.name) === p2.name) continue; |
| const diff = Math.abs(p1.elo - p2.elo); |
| if (diff < bestDiff) { bestDiff = diff; best = p2; } |
| } |
| return best; |
| } |
| const queue = [...waitingQueue]; |
| waitingQueue = []; |
| for (const p1 of queue) { |
| if (used.has(p1.name)) continue; |
| const p2 = findBestOpponent(p1, queue); |
| if (p2) { |
| used.add(p1.name); used.add(p2.name); |
| recentOpponents.set(p1.name, p2.name); |
| recentOpponents.set(p2.name, p1.name); |
| const match = createMatch(p1, p2); |
| setupMatch(match); |
| matches.push(match); |
| playerMatchMap.set(p1.name, match); |
| playerMatchMap.set(p2.name, match); |
| } else { |
| waitingQueue.push(p1); |
| } |
| } |
| } |
| |
| function createMatch(p1, p2) { |
| return { id: crypto.randomUUID(), players:[p1,p2], balls:[], running:true, startTime:Date.now(), lastSpawn:Date.now(), spawnDelay }; |
| } |
| |
| function resetAIState(p) { |
| p.targetX = null; p.targetY = null; |
| p.deathTime = 0; p.alive = true; |
| p.lastVX = 0; p.lastVY = 0; |
| p.aiVX = 0; p.aiVY = 0; |
| } |
| |
| function setupMatch(match) { |
| const [p1, p2] = match.players; |
| for (const p of match.players) { resetAIState(p); p.alive = true; } |
| p1.x = W()/2 - W()*0.25; p1.y = H()*0.5; |
| p2.x = W()/2 + W()*0.25; p2.y = H()*0.5; |
| match.balls = []; match.lastSpawn = Date.now(); match.spawnDelay = spawnDelay; |
| for (let i = 0; i < startBallCount; i++) createBallForMatch(match); |
| if (!spectateModeActive) { |
| if (match.players.some(p => p.name === "YOU")) spectateMatchId = match.id; |
| } |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // GAME LOOP (FIXED TIMESTEP) |
| // ═══════════════════════════════════════════════ |
| let lastTime = performance.now(); |
| const timestep = 1000 / 60; |
| let accumulator = 0; |
| |
| function loop() { |
| const now = performance.now(); |
| const dt = now - lastTime; |
| lastTime = now; |
| |
| accumulator += Math.min(dt, 250); |
| |
| while (accumulator >= timestep) { |
| update(); |
| accumulator -= timestep; |
| } |
| |
| draw(); |
| requestAnimationFrame(loop); |
| } |
| requestAnimationFrame(loop); |
| |
| |
| function restartGame() { |
| clearAllTimers(); |
| const kr = currentRound; |
| startGame(mode); |
| currentRound = kr; |
| } |
| |
| function gameOver() { |
| running = false; |
| clearAllTimers(); |
| const survived = Math.floor((Date.now() - startTime) / 1000); |
| if (survived > record) { |
| record = survived; |
| localStorage.setItem("fingerDodgeRecord", record); |
| } |
| document.getElementById("finalTime").textContent = survived; |
| const recEl = document.getElementById("record"); |
| if (recEl) recEl.textContent = record; |
| showScreen("gameOver"); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // UPDATE |
| // ═══════════════════════════════════════════════ |
| function update() { |
| if (!running) return; |
| campGuardTick(); // Anti-Corner-Camping fuer jeden Modus |
| if (!running) return; // Disqualifikation kann das Spiel beendet haben |
| if (mode === "tournament") { updateTournament(); return; } |
| |
| const el = document.getElementById("time"); |
| if (el) el.textContent = mode === "online_tv" ? tvClockSec() : Math.max(0, Math.floor((Date.now() - startTime) / 1000)); |
| const bel = document.getElementById("balls"); |
| if (bel) bel.textContent = balls.length; |
| if (mode === "online_tv") { |
| const tvNow = Date.now(); |
| for (const p of players) { |
| if (p.tx === undefined || p.ty === undefined) continue; |
| const since = tvNow - (p.lastPacketAt || tvNow); |
| // Dead-Reckoning: Ziel entlang der letzten Netz-Geschwindigkeit weiterschieben |
| if (p.alive !== false && since > 0 && since < NET_SMOOTH.FREEZE_AFTER_MS) { |
| const sx = (p.netVX || 0) * timestep, sy = (p.netVY || 0) * timestep; |
| const drift = Math.hypot(p.tx - (p.baseTx ?? p.tx), p.ty - (p.baseTy ?? p.ty)); |
| if ((sx || sy) && drift < NET_SMOOTH.MAX_EXTRAPOLATE_DIST) { p.tx += sx; p.ty += sy; } |
| } |
| // Zeitbasierte (framerate-unabhängige) Glättung, passend zur Paketrate |
| const iv = Math.min(NET_SMOOTH.MAX_INTERVAL, Math.max(NET_SMOOTH.MIN_INTERVAL, p.netInterval || NET_SMOOTH.MIN_INTERVAL)); |
| const a = smoothingAlpha(timestep, 90 + (iv - NET_SMOOTH.MIN_INTERVAL) * 0.25); |
| p.x += (p.tx - p.x) * a; |
| p.y += (p.ty - p.y) * a; |
| } |
| if (!tvFreezeBalls) for (const b of balls) { |
| b.x += b.vx; b.y += b.vy; |
| if (b.x - b.radius < 0) { b.vx = Math.abs(b.vx); b.x = b.radius; } |
| if (b.x + b.radius > W()) { b.vx = -Math.abs(b.vx); b.x = W()-b.radius; } |
| if (b.y - b.radius < 0) { b.vy = Math.abs(b.vy); b.y = b.radius; } |
| if (b.y + b.radius > H()) { b.vy = -Math.abs(b.vy); b.y = H()-b.radius; } |
| } |
| return; |
| } |
| |
| if (mode === "online") { |
| if (survivalActive && _dailyKey && _dailyCdUntil > Date.now()) return; // Daily-Countdown: Baelle und Zeit stehen still |
| { const vk = survivalActive ? (myUid ? "surv_" + myUid : null) : onlineMatchId; if (vk && vk !== _viewKey) watchViewerCount(vk, false); } |
| campTick(); |
| if (survivalActive) survivalTick(); |
| pushMyPosition(); |
| onlineSimTick++; |
| |
| const _isDaily = survivalActive && !!_dailyKey; |
| if (_isDaily) { dailyTick(); dailySteer(); } |
| if (onlineIsHost && !_isDaily) { |
| if (Date.now() - onlineBallSpawnTimer > ONLINE_SPAWN_MS) { |
| onlineBallSpawnTimer = Date.now(); |
| const nb = makeOnlineBall(); balls.push(nb); |
| if (survivalActive) survRec.s.push([survTick, nb.x, nb.y, nb.vx, nb.vy, nb.radius]); |
| } |
| } |
| |
| for (const b of balls) { |
| if (_isDaily) dailyAdv(b); |
| b.x += b.vx; b.y += b.vy; |
| if (b.x - b.radius < 0) { b.vx = Math.abs(b.vx); b.x = b.radius; } |
| if (b.x + b.radius > W()) { b.vx = -Math.abs(b.vx); b.x = W()-b.radius; } |
| if (b.y - b.radius < 0) { b.vy = Math.abs(b.vy); b.y = b.radius; } |
| if (b.y + b.radius > H()) { b.vy = -Math.abs(b.vy); b.y = H()-b.radius; } |
| } |
| |
| if (onlineIsHost) pushBallState(); |
| |
| if (onlineVsBot) { |
| const bot = players.find(p => p.name !== myPlayName()); |
| if (bot && bot.alive) { |
| updateAI(bot, balls); |
| checkBotCollision(bot); |
| } |
| } else { |
| // CHANGED (network smoothing): replaced the fixed-rate nudge |
| // ( opp.x += (opp.tx - opp.x) * 0.22 every frame, with a fixed |
| // 0.15 extrapolation window of 100-1500ms ) with an adaptive |
| // version. We use TWO ingredients together: |
| // 1) Dead-reckoning: keep moving opp.tx/opp.ty forward along |
| // their last known network velocity (netVX/netVY, computed |
| // from REAL elapsed time between their last two packets), |
| // capped so a long dropout can't fling them off-screen. |
| // 2) Adaptive smoothing: the visual position (opp.x/opp.y) |
| // chases the (possibly extrapolated) target with a |
| // catch-up speed that scales with how laggy their packets |
| // have been recently (op.netInterval) — laggy connection |
| // => smoother/slower chase so it doesn't look like |
| // teleporting; good connection => snappier chase. |
| // This means YOUR experience stays smooth regardless of the |
| // OPPONENT's connection quality, which is exactly the lag you |
| // described (good ping for you, choppy because of them). |
| const opp = players.find(p => p.name !== myPlayName()); |
| if (opp && opp.tx !== undefined && opp.ty !== undefined) { |
| const nowMs = Date.now(); |
| const sinceLastPacket = nowMs - (opp.lastPacketAt || nowMs); |
| |
| // Dead-reckon the *target* point forward using their last |
| // known velocity, but only while the gap is still plausible |
| // (not a dead connection) and only up to a max distance. |
| if (sinceLastPacket > 0 && sinceLastPacket < NET_SMOOTH.FREEZE_AFTER_MS) { |
| const dtFrame = timestep; // ~16.6ms, one fixed sim step |
| let stepX = (opp.netVX || 0) * dtFrame; |
| let stepY = (opp.netVY || 0) * dtFrame; |
| const stepDist = Math.sqrt(stepX*stepX + stepY*stepY); |
| const totalExtrapolated = Math.sqrt( |
| Math.pow(opp.tx - (opp.baseTx ?? opp.tx), 2) + |
| Math.pow(opp.ty - (opp.baseTy ?? opp.ty), 2) |
| ); |
| if (stepDist > 0 && totalExtrapolated < NET_SMOOTH.MAX_EXTRAPOLATE_DIST) { |
| opp.tx += stepX; |
| opp.ty += stepY; |
| } |
| } |
| |
| // Adaptive catch-up: the worse (larger) their recent packet |
| // interval has been, the longer we give ourselves to close |
| // the gap, trading a little positional accuracy for smooth |
| // motion. Good connections (small netInterval) stay snappy. |
| const recentInterval = Math.min( |
| NET_SMOOTH.MAX_INTERVAL, |
| Math.max(NET_SMOOTH.MIN_INTERVAL, opp.netInterval || NET_SMOOTH.MIN_INTERVAL) |
| ); |
| // catchUpMs scales from ~70ms (great connection, near-instant |
| // chase) up to ~260ms (bad connection, gentle glide) — tuned |
| // so it never feels sluggish even at the high end. |
| const catchUpMs = 70 + (recentInterval - NET_SMOOTH.MIN_INTERVAL) * 0.2; |
| const alpha = smoothingAlpha(timestep, catchUpMs); |
| |
| opp.x += (opp.tx - opp.x) * alpha; |
| opp.y += (opp.ty - opp.y) * alpha; |
| } |
| } |
| |
| const me = players.find(p => p.name === myPlayName()); |
| if (me) { |
| me.x = Math.max(me.radius, Math.min(W()-me.radius, me.x)); |
| me.y = Math.max(me.radius, Math.min(H()-me.radius, me.y)); |
| } |
| |
| if (me && me.alive) checkOnlineCollision(me); |
| return; |
| } |
| if (mode === "streak") { updateStreak(); return; } |
| if (mode === "multi") { |
| const p1 = players[0], p2 = players[1]; |
| if (p1 && p1.alive) { |
| if (keys["w"]) p1.y -= 5; if (keys["s"]) p1.y += 5; |
| if (keys["a"]) p1.x -= 5; if (keys["d"]) p1.x += 5; |
| } |
| if (p2 && p2.alive) { |
| if (keys["arrowup"]) p2.y -= 5; if (keys["arrowdown"]) p2.y += 5; |
| if (keys["arrowleft"]) p2.x -= 5; if (keys["arrowright"]) p2.x += 5; |
| } |
| } |
| |
| for (const p of players) { |
| if (!p.alive) continue; |
| const humanNames = ["Player","Player 1","Player 2","YOU"]; |
| if (!humanNames.includes(p.name)) updateAI(p, balls); |
| } |
| |
| for (const p of players) { |
| p.x = Math.max(p.radius, Math.min(W()-p.radius, p.x)); |
| p.y = Math.max(p.radius, Math.min(H()-p.radius, p.y)); |
| } |
| |
| for (const b of balls) { |
| b.x += b.vx; b.y += b.vy; |
| if (b.x - b.radius < 0) { b.vx = Math.abs(b.vx); b.x = b.radius; } |
| if (b.x + b.radius > W()) { b.vx = -Math.abs(b.vx); b.x = W()-b.radius; } |
| if (b.y - b.radius < 0) { b.vy = Math.abs(b.vy); b.y = b.radius; } |
| if (b.y + b.radius > H()) { b.vy = -Math.abs(b.vy); b.y = H()-b.radius; } |
| } |
| |
| if (mode === "coin") { |
| for (const p of players) { |
| if (!p.alive) continue; |
| for (let i = coins.length - 1; i >= 0; i--) { |
| const c = coins[i]; |
| const dx = p.x - c.x; |
| const dy = p.y - c.y; |
| |
| if (Math.sqrt(dx * dx + dy * dy) < p.radius + c.r) { |
| p.coinsCollected = (p.coinsCollected || 0) + c.value; |
| |
| if (p.name === "Player") { |
| coinScorePlayer = p.coinsCollected; |
| document.getElementById("coinScore").textContent = coinScorePlayer; |
| } |
| |
| coins.splice(i, 1); |
| spawnCoin(); |
| } |
| } |
| } |
| } |
| |
| |
| for (const p of players) checkCollision(p); |
| } |
| |
| function checkOnlineCollision(me) { |
| for (const b of balls) { |
| const dx = me.x - b.x; |
| const dy = me.y - b.y; |
| if (Math.sqrt(dx*dx + dy*dy) < me.radius + b.radius) { |
| me.alive = false; |
| if (survivalActive) { endSurvival(false); return; } |
| const opp = players.find(p => p.name !== me.name); |
| |
| if (onlineVsBot) { |
| const oppElo = onlineOpponent?.elo || 1500; |
| const diff = onlineAccount.elo - oppElo; |
| const change = Math.max(1, Math.min(15, Math.round(5 + diff / 100))); |
| |
| handleOnlineResult(opp ? opp.name : "", change); |
| } else { |
| reportOnlineResult(opp ? opp.name : "", me.name); |
| } |
| return; |
| } |
| } |
| } |
| |
| function checkBotCollision(bot) { |
| for (const b of balls) { |
| const dx = bot.x - b.x; |
| const dy = bot.y - b.y; |
| if (Math.sqrt(dx*dx + dy*dy) < bot.radius + b.radius) { |
| bot.alive = false; |
| const me = players.find(p => p.name === myPlayName()); |
| |
| if (me && me.alive) { |
| const oppElo = onlineOpponent?.elo || 1500; |
| |
| const diff = oppElo - onlineAccount.elo; |
| const change = Math.max(1, Math.min(15, Math.round(5 + diff / 100))); |
| |
| handleOnlineResult(me.name, change); |
| } |
| return; |
| } |
| } |
| } |
| |
| |
| function updateTournament() { |
| for (const match of [...matches]) { |
| if (match.running) updateMatch(match); |
| } |
| matches = matches.filter(m => m.running); |
| updateEloTable(); |
| autoFollowSpectate(); |
| } |
| |
| function updateMatch(match) { |
| if (!match.running) return; |
| for (const p of match.players) { |
| if (p.alive && p.name !== "YOU") updateAI(p, match.balls); |
| } |
| for (const b of match.balls) { |
| b.x += b.vx; b.y += b.vy; |
| if (b.x - b.radius < 0) { b.vx = Math.abs(b.vx); b.x = b.radius; } |
| if (b.x + b.radius > W()) { b.vx = -Math.abs(b.vx); b.x = W()-b.radius; } |
| if (b.y - b.radius < 0) { b.vy = Math.abs(b.vy); b.y = b.radius; } |
| if (b.y + b.radius > H()) { b.vy = -Math.abs(b.vy); b.y = H()-b.radius; } |
| } |
| const now = Date.now(); |
| if (now - match.lastSpawn >= match.spawnDelay) { |
| createBallForMatch(match); match.lastSpawn = now; |
| } |
| for (const p of match.players) { |
| if (!p.alive) continue; |
| for (const b of match.balls) { |
| const dx = p.x - b.x, dy = p.y - b.y; |
| if (Math.sqrt(dx*dx + dy*dy) < p.radius + b.radius) { |
| p.alive = false; |
| const alive = match.players.filter(x => x.alive); |
| if (alive.length <= 1) { |
| match.running = false; |
| finishMatch(match, alive[0] || match.players.find(x => x !== p), p); |
| return; |
| } |
| } |
| } |
| } |
| } |
| |
| function checkCollision(p) { |
| if (!p.alive) return; |
| |
| if (mode === "coin" && p.invulnerableUntil && Date.now() < p.invulnerableUntil) return; |
| |
| if (mode === "coin") { |
| for (const b of balls) { |
| const dx = p.x - b.x, dy = p.y - b.y; |
| if (Math.sqrt(dx*dx + dy*dy) < p.radius + b.radius) { |
| p.stunnedUntil = Date.now() + 5000; |
| p.invulnerableUntil = Date.now() + 6000; |
| return; |
| } |
| } |
| } else { |
| for (const b of balls) { |
| const dx = p.x - b.x, dy = p.y - b.y; |
| if (Math.sqrt(dx*dx + dy*dy) < p.radius + b.radius) { |
| arenaEliminate(p); |
| return; |
| } |
| } |
| } |
| } |
| // Spieler in einem Arena-Modus ausscheiden lassen (Ball getroffen ODER Corner-Camping-Disqualifikation) |
| function arenaEliminate(p) { |
| if (!p.alive) return; |
| p.alive = false; |
| p.deathTime = Date.now(); |
| eliminated.push(p); |
| const alive = players.filter(x => x.alive); |
| if (alive.length <= 1) { |
| if (alive.length === 1 && !eliminated.includes(alive[0])) { |
| alive[0].deathTime = Infinity; |
| eliminated.push(alive[0]); |
| } |
| finishArena(); |
| } |
| } |
| |
| // CHANGED: result reporting now uses the guarded RPC and clamps the |
| // elo delta before writing, instead of an unrestricted dbUpdate on a |
| // name-derived path. |
| async function reportOnlineResult(winnerName, loserName) { |
| if (onlineResultHandled) return; |
| |
| const me = onlineAccount; |
| const opp = onlineOpponent || { elo: 1500 }; |
| |
| const winnerElo = (winnerName === me.name) ? me.elo : opp.elo; |
| const loserElo = (loserName === me.name) ? me.elo : opp.elo; |
| |
| const diff = loserElo - winnerElo; |
| const change = clampEloChange(Math.max(1, Math.min(15, Math.round(5 + diff / 100)))); |
| |
| // Bot-Match: es gibt keinen Match-Listener, der "done" verarbeitet -> Ergebnis direkt behandeln |
| // (sonst laeuft das Spiel nach der Camping-Disqualifikation einfach weiter) |
| if (onlineVsBot) { handleOnlineResult(winnerName, change); return; } |
| |
| if (fbReady && onlineMatchId) { |
| try { |
| await RPC.updateMatch(onlineMatchId, { |
| status: "done", winner: winnerName, eloChange: change |
| }); |
| const _mid = onlineMatchId; |
| setTimeout(() => { if (!onlineResultHandled && onlineMatchId === _mid) handleOnlineResult(winnerName, change); }, 4000); |
| } catch(e) { |
| handleOnlineResult(winnerName, change); |
| } |
| } else { |
| handleOnlineResult(winnerName, change); |
| } |
| } |
| |
| |
| |
| // ═══════════════════════════════════════════════ |
| // TOURNAMENT FINISH (fully offline — no DB access) |
| // ═══════════════════════════════════════════════ |
| function finishMatch(match, winner, loser) { |
| match.running = false; |
| const diff = loser.elo - winner.elo; |
| const change = Math.max(1, Math.min(15, 5 + diff / 100)); |
| |
| if (winner.stats) { |
| if (loser.elo > (winner.stats.bestWin || 0)) { |
| winner.stats.bestWin = loser.elo; |
| winner.stats.bestWinOpp = loser.name; |
| } |
| } |
| |
| if (loser.stats) { |
| if ( |
| loser.stats.worstLoss === 0 || |
| winner.elo < loser.stats.worstLoss |
| ) { |
| loser.stats.worstLoss = winner.elo; |
| loser.stats.worstLossOpp = winner.name; |
| } |
| } |
| winner.elo += change; loser.elo -= change; |
| winner.lastChange = change; loser.lastChange = -change; |
| winner.alive = true; loser.alive = true; |
| winner.arenaScore = (winner.arenaScore || 0) + 1; |
| |
| if (winner.stats) { |
| winner.stats.wins++; winner.stats.matchesPlayed++; |
| winner.stats.totalEloGain += change; |
| winner.stats.currentStreak++; |
| if (winner.stats.currentStreak > winner.stats.bestStreak) |
| winner.stats.bestStreak = winner.stats.currentStreak; |
| } |
| if (loser.stats) { |
| loser.stats.losses++; loser.stats.matchesPlayed++; |
| loser.stats.totalEloLoss += change; |
| loser.stats.currentStreak = 0; |
| } |
| |
| matches = matches.filter(m => m.id !== match.id); |
| playerMatchMap.delete(winner.name); |
| playerMatchMap.delete(loser.name); |
| if (spectateMatchId === match.id && !spectateModeActive) spectateMatchId = null; |
| |
| if (winner.name === "YOU") showEloAnimation(change); |
| if (loser.name === "YOU") showEloAnimation(-change); |
| |
| if (spectateModeActive && spectateMatchId === match.id) { |
| matchBanner = { |
| text: "🏆 Winner: " + winner.name, |
| ts: Date.now(), |
| duration: 2500 |
| }; |
| } |
| |
| setTimeout(() => { |
| if (humanRemovedFromQueue) { |
| if (winner.name !== "YOU") waitingQueue.push(winner); |
| if (loser.name !== "YOU") waitingQueue.push(loser); |
| } else { |
| waitingQueue.push(winner); |
| waitingQueue.push(loser); |
| } |
| seededMatchmaking(); |
| }, 2500 + Math.random() * 1500); |
| } |
| |
| function finishArena() { |
| if (mode !== "tournament") running = false; |
| clearAllTimers(); |
| eliminated.sort((a, b) => { |
| const da = isFinite(a.deathTime) ? a.deathTime : Number.MAX_SAFE_INTEGER; |
| const db = isFinite(b.deathTime) ? b.deathTime : Number.MAX_SAFE_INTEGER; |
| return db - da; |
| }); |
| |
| if (rankingMode === "points") { |
| const total = eliminated.length; |
| for (let i = 0; i < eliminated.length; i++) { |
| const p = eliminated[i]; |
| const pts = total - i; |
| let r = ranking.find(x => x.name === p.name); |
| if (r) r.points += pts; |
| else ranking.push({ name:p.name, color:p.color, points:pts, elo:1500, lastChange:0 }); |
| } |
| } else { |
| const K = 24; |
| for (const b of ranking) b.lastChange = 0; |
| for (let i = 0; i < eliminated.length; i++) { |
| for (let j = i+1; j < eliminated.length; j++) { |
| let a = ranking.find(x => x.name === eliminated[i].name); |
| if (!a) { a = { name:eliminated[i].name, color:eliminated[i].color, points:0, elo:1500, lastChange:0 }; ranking.push(a); } |
| let b = ranking.find(x => x.name === eliminated[j].name); |
| if (!b) { b = { name:eliminated[j].name, color:eliminated[j].color, points:0, elo:1500, lastChange:0 }; ranking.push(b); } |
| const eA = 1 / (1 + Math.pow(10, (b.elo - a.elo) / 400)); |
| const cA = K * (1 - eA), cB = K * (0 - (1-eA)); |
| a.elo += cA; b.elo += cB; a.lastChange += cA; b.lastChange += cB; |
| } |
| } |
| } |
| |
| updateRanking(); |
| const win = eliminated[0]; |
| showScreen("gameOver"); |
| document.getElementById("finalTime").innerHTML = |
| `Round ${currentRound}/${tournamentRounds}<br>Winner: ${win?.name || "?"}`; |
| if (currentRound < tournamentRounds) { |
| currentRound++; |
| setTimeout(() => { |
| document.getElementById("gameOver").style.display = "none"; |
| startGame(mode); |
| }, 15000); |
| } else { |
| document.getElementById("finalTime").innerHTML += "<br><br><b>TOURNAMENT OVER</b>"; |
| } |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // RANKING / ELO TABLE (offline, unaffected) |
| // ═══════════════════════════════════════════════ |
| function updateRanking() { |
| if (!ranking.length) return; |
| const sorted = rankingMode === "elo" |
| ? [...ranking].sort((a,b) => b.elo - a.elo) |
| : [...ranking].sort((a,b) => b.points - a.points); |
| let html = "<b>TOURNAMENT RANKING</b><br><br>"; |
| for (let i = 0; i < sorted.length; i++) { |
| html += rankingMode === "elo" |
| ? `${i+1}. ${sorted[i].name} | ${Math.round(sorted[i].elo)} ELO (${sorted[i].lastChange>=0?"+":""}${Math.round(sorted[i].lastChange)})<br>` |
| : `${i+1}. ${sorted[i].name} | ${sorted[i].points} Points<br>`; |
| } |
| document.getElementById("ranking").innerHTML = html; |
| } |
| |
| let lastEloRender = 0; |
| function updateEloTable() { |
| if (Date.now() - lastEloRender < 400) return; |
| lastEloRender = Date.now(); |
| const sorted = [...participants].sort((a,b) => b.elo - a.elo); |
| let html = ""; |
| for (let i = 0; i < sorted.length; i++) { |
| const p = sorted[i]; |
| const active = matches.some(m => m.running && m.players.includes(p)); |
| const isSpec = p.name === "YOU" && humanRemovedFromQueue; |
| html += `<div data-pname="${p.name}" |
| style="display:flex;align-items:center;gap:8px;margin-bottom:8px;cursor:pointer;padding:3px;border-radius:6px;" |
| onmouseover="this.style.background='rgba(255,255,255,0.1)'" |
| onmouseout="this.style.background=''" |
| onclick="openProfile('${p.name}')"> |
| <div style="width:12px;height:12px;border-radius:50%;background:${isSpec?"#aaa":active?"lime":"red"};flex-shrink:0;"></div> |
| <div>${i+1}. <b style="color:${p.color||"white"}">${p.name}</b>${isSpec?" 👁":""}<br> |
| ${Math.round(p.elo)} <span style="color:${p.lastChange>=0?"lime":"red"}">(${p.lastChange>=0?"+":""}${Math.round(p.lastChange)})</span> |
| </div></div>`; |
| } |
| const me = participants.find(p => p.name === "YOU"); |
| if (me) { |
| const myRank = sorted.findIndex(p => p.name === "YOU") + 1; |
| const t = document.getElementById("time"), r = document.getElementById("record"), b = document.getElementById("balls"); |
| if (t) t.textContent = Math.round(me.elo); |
| if (r) r.textContent = myRank; |
| if (b) b.textContent = participants.length; |
| } |
| document.getElementById("eloList").innerHTML = html; |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // PROFILE MODAL (offline tournament profile — unaffected; online |
| // profile lookups are handled by openOnlineProfile(uid) above) |
| // ═══════════════════════════════════════════════ |
| function openProfile(name) { |
| const p = participants.find(x => x.name === name); |
| if (!p) return; |
| const sorted = [...participants].sort((a,b) => b.elo - a.elo); |
| const rank = sorted.findIndex(x => x.name === name) + 1; |
| const winrate = p.stats.matchesPlayed > 0 |
| ? Math.round(p.stats.wins / p.stats.matchesPlayed * 100) : 0; |
| const inMatch = matches.some(m => m.running && m.players.includes(p)); |
| const opp = inMatch |
| ? matches.find(m => m.running && m.players.includes(p))?.players.find(x => x.name !== name) |
| : null; |
| const skillVal = Math.round(getSkillValue(p) * 100); |
| |
| document.getElementById("profileName").innerHTML = `<span style="color:${p.color}">${p.name}</span>`; |
| document.getElementById("profileStats").innerHTML = ` |
| <div class="prof-row"><span class="prof-label">Rank</span><span class="prof-val">#${rank} / ${participants.length}</span></div> |
| <div class="prof-row"><span class="prof-label">ELO</span><span class="prof-val">${Math.round(p.elo)}</span></div> |
| <div class="prof-row"><span class="prof-label">Skill</span><span class="prof-val">${skillVal}%</span></div> |
| <div class="prof-row"><span class="prof-label">Matches</span><span class="prof-val">${p.stats.matchesPlayed}</span></div> |
| <div class="prof-row"><span class="prof-label">Wins</span><span class="prof-val" style="color:lime">${p.stats.wins}</span></div> |
| <div class="prof-row"><span class="prof-label">Losses</span><span class="prof-val" style="color:#ff4444">${p.stats.losses}</span></div> |
| <div class="prof-row"><span class="prof-label">Winrate</span><span class="prof-val">${winrate}%</span></div> |
| <div class="prof-row"><span class="prof-label">Best Streaks</span><span class="prof-val">${p.stats.bestStreak}</span></div> |
| <div class="prof-row"><span class="prof-label">ELO Gained</span><span class="prof-val" style="color:lime">+${Math.round(p.stats.totalEloGain)}</span></div> |
| <div class="prof-row"><span class="prof-label">ELO Lost</span><span class="prof-val" style="color:#ff4444">-${Math.round(p.stats.totalEloLoss)}</span></div> |
| <div class="prof-row"><span class="prof-label">Status</span><span class="prof-val">${inMatch?"⚔️ vs "+opp?.name:"⏳ Waiting"}</span></div> |
| <div class="prof-row"> |
| <span class="prof-label">Best Win</span> |
| <span class="prof-val" style="color:#00ff88"> |
| ${Math.round(p.stats.bestWin ?? 0)} ELO vs ${p.stats.bestWinOpp || "-"} |
| </span> |
| </div> |
| <div class="prof-row"> |
| <span class="prof-label">Worst Loss</span> |
| <span class="prof-val" style="color:#ff4444"> |
| ${Math.round(p.stats.worstLoss ?? 0)} ELO vs ${p.stats.worstLossOpp || "-"} |
| </span> |
| </div> |
| |
| <div class="prof-row"><span class="prof-label">Speed</span><span class="prof-val">${p.speed.toFixed(1)}</span></div> |
| <div class="prof-row"><span class="prof-label">Awareness</span><span class="prof-val">${Math.round(p.awareness)}px</span></div> |
| `; |
| document.getElementById("profileModal").style.display = "flex"; |
| } |
| function closeProfile() { document.getElementById("profileModal").style.display = "none"; } |
| |
| // ═══════════════════════════════════════════════ |
| // SPECTATOR (offline tournament — unaffected) |
| // ═══════════════════════════════════════════════ |
| function autoFollowSpectate() { |
| if (!spectateModeActive) return; |
| if (followMode === "best") { |
| const cur = matches.find(m => m.id === spectateMatchId && m.running); |
| if (!cur) pickBestMatch(); |
| } else if (followMode === "random") { |
| const cur = matches.find(m => m.id === spectateMatchId && m.running); |
| if (!cur) pickRandomMatch(); |
| } else if (followMode === "target" && targetName) { |
| const m = matches.find(m => m.running && m.players.some(p => p.name.toLowerCase() === targetName)); |
| if (m) spectateMatchId = m.id; |
| } |
| } |
| function pickRandomMatch() { |
| const live = matches.filter(m => m.running); |
| if (live.length) spectateMatchId = live[Math.floor(Math.random()*live.length)].id; |
| } |
| function pickBestMatch() { |
| let best = null, bestAvg = -1; |
| for (const m of matches) { |
| if (!m.running) continue; |
| const avg = (m.players[0].elo + m.players[1].elo) / 2; |
| if (avg > bestAvg) { bestAvg = avg; best = m; } |
| } |
| if (best) spectateMatchId = best.id; |
| } |
| |
| function setTVMode(m) { |
| followMode = m; |
| if (m === "target") { |
| const n = prompt("Enter bot name:"); |
| if (!n) return; |
| targetName = n.toLowerCase(); |
| } |
| if (!humanRemovedFromQueue) { |
| humanRemovedFromQueue = true; |
| waitingQueue = waitingQueue.filter(p => p.name !== "YOU"); |
| } |
| spectateModeActive = true; spectateMatchId = null; |
| autoFollowSpectate(); |
| document.getElementById("spectatorInfo").style.display = "block"; |
| } |
| function resumePlay() { |
| spectateModeActive = false; followMode = "none"; spectateMatchId = null; |
| document.getElementById("spectatorInfo").style.display = "none"; |
| if (humanRemovedFromQueue) { |
| humanRemovedFromQueue = false; |
| const me = participants.find(p => p.name === "YOU"); |
| if (me && !playerMatchMap.has("YOU")) { waitingQueue.push(me); seededMatchmaking(); } |
| } |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // DRAW |
| // ═══════════════════════════════════════════════ |
| function draw() { |
| ctx.clearRect(0, 0, W(), H()); |
| |
| if (mode === "tournament") { |
| let renderMatch = null; |
| if (spectateModeActive && spectateMatchId) |
| renderMatch = matches.find(m => m.id === spectateMatchId && m.running); |
| else if (!humanRemovedFromQueue) { |
| const dm = playerMatchMap.get("YOU"); |
| if (dm && dm.running) renderMatch = dm; |
| } |
| if (!renderMatch && spectateModeActive) renderMatch = matches.find(m => m.running); |
| |
| if (!renderMatch) { |
| ctx.fillStyle = "white"; ctx.font = "30px Arial"; ctx.textAlign = "center"; |
| ctx.fillText( |
| humanRemovedFromQueue && !spectateModeActive |
| ? tr("You are watching – choose a TV mode") |
| : tr("Waiting for next match…"), |
| W()/2, H()/2 |
| ); |
| document.getElementById("opponentInfo").style.display = "none"; |
| document.getElementById("spectatorInfo").style.display = "none"; |
| return; |
| } |
| |
| for (const b of renderMatch.balls) { |
| ctx.beginPath(); ctx.arc(b.x, b.y, b.radius, 0, Math.PI*2); |
| ctx.fillStyle = "#ff4444"; ctx.fill(); |
| } |
| for (const p of renderMatch.players) { |
| if (!p.alive) continue; |
| ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI*2); |
| ctx.fillStyle = p.color; ctx.fill(); |
| ctx.fillStyle = "white"; ctx.font = "14px Arial"; ctx.textAlign = "center"; |
| ctx.fillText(dn(p.name), p.x, p.y - p.radius - 10); |
| } |
| |
| if (matchBanner && Date.now() - matchBanner.ts < matchBanner.duration) { |
| const alpha = 1 - Math.max(0, (Date.now() - matchBanner.ts - 1800) / (matchBanner.duration - 1800)); |
| ctx.save(); ctx.globalAlpha = alpha; |
| ctx.fillStyle = "rgba(0,0,0,0.7)"; |
| ctx.beginPath(); ctx.roundRect(W()/2-220, H()/2-50, 440, 90, 16); ctx.fill(); |
| ctx.fillStyle = "#ffd700"; ctx.font = "bold 38px Arial"; ctx.textAlign = "center"; |
| ctx.fillText(tr(matchBanner.text), W()/2, H()/2+14); |
| ctx.restore(); |
| } else if (matchBanner && Date.now() - matchBanner.ts >= matchBanner.duration) { |
| matchBanner = null; |
| } |
| |
| const oppDiv = document.getElementById("opponentInfo"); |
| const specDiv = document.getElementById("spectatorInfo"); |
| if (!spectateModeActive && !humanRemovedFromQueue) { |
| const myMatch = playerMatchMap.get("YOU"); |
| if (myMatch && myMatch.running) { |
| const opp = myMatch.players.find(p => p.name !== "YOU"); |
| if (opp) { |
| const sorted = [...participants].sort((a,b) => b.elo - a.elo); |
| const oppRank = sorted.findIndex(p => p.name === opp.name) + 1; |
| oppDiv.innerHTML = `<b>Opponent: ${opp.name}</b><br>Rank: <b>#${oppRank}</b> of ${participants.length}<br>ELO: <b>${Math.round(opp.elo)}</b><br>Skill: <b>${Math.round(getSkillValue(opp)*100)}%</b>`; |
| |
| } |
| } else { oppDiv.style.display = "none"; } |
| specDiv.style.display = "none"; |
| } else { |
| oppDiv.style.display = "none"; |
| specDiv.style.display = "block"; |
| const [p1, p2] = renderMatch.players; |
| document.getElementById("spectatorText").innerHTML = ` |
| <div style="display:flex;align-items:center;justify-content:center;gap:14px;font-size:14px;line-height:1.2;"> |
| <div style="text-align:center;min-width:90px;"><b>${p1.name}</b><br>${Math.round(p1.elo)} ELO<br>${Math.round(getSkillValue(p1)*100)}%</div> |
| <div style="font-weight:bold;font-size:16px;opacity:0.8;">VS</div> |
| <div style="text-align:center;min-width:90px;"><b>${p2.name}</b><br>${Math.round(p2.elo)} ELO<br>${Math.round(getSkillValue(p2)*100)}%</div> |
| </div>`; |
| } |
| return; |
| } |
| |
| if (mode === "coin") { |
| for (const c of coins) { |
| ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI*2); |
| ctx.fillStyle = c.value > 1 ? "#ffd700" : "#ffcc44"; |
| ctx.shadowColor = "#ffaa00"; ctx.shadowBlur = c.value > 1 ? 18 : 8; |
| ctx.fill(); ctx.shadowBlur = 0; |
| if (c.value > 1) { |
| ctx.fillStyle = "#a06000"; ctx.font = "bold 11px Arial"; ctx.textAlign = "center"; |
| ctx.fillText("x3", c.x, c.y + 4); |
| } |
| } |
| } |
| |
| const _lme = (_dailyLight && _dailyKey && survivalActive) ? players[0] : null, _lOn = !!(_lme && _lme.alive); |
| if (_lOn) dailyLightFx(ctx, _dailyLight, _lme.x, _lme.y, survTick, W(), H()); |
| for (const b of balls) { |
| const _lv = _lOn ? dailyVis(_dailyLight, b, _lme.x, _lme.y, survTick) : 1; if (_lv <= 0) continue; |
| ctx.beginPath(); ctx.arc(b.x, b.y, b.radius, 0, Math.PI*2); |
| if (b.chase) { |
| // verfolgender Ball (Streak): orange mit Ring, blinkt kurz vor dem Verschwinden |
| const left = b.dch ? (b.exp - survTick) * (1000 / 60) : (b.born + STREAK_CFG.chaserLifeMs) - Date.now(); // Daily-Verfolger: Restzeit in Ticks |
| ctx.globalAlpha = left < 2000 ? (Math.floor(left / 120) % 2 ? 0.35 : 0.9) : 1; |
| ctx.fillStyle = "#ff9d00"; ctx.fill(); |
| ctx.lineWidth = 3; ctx.strokeStyle = "#fff6c8"; ctx.stroke(); |
| ctx.globalAlpha = 1; |
| } else { |
| let al = 1; |
| if (b.exp) { const left = (b.exp - survTick) * (1000 / 60); if (left < 2000) al = Math.floor(left / 120) % 2 ? 0.35 : 0.9; } // Daily: blinkt kurz vor dem Verschwinden |
| ctx.globalAlpha = al * _lv; ctx.fillStyle = b.col || "#ff4444"; ctx.fill(); ctx.globalAlpha = 1; |
| } |
| } |
| |
| if (ballTrailEnabled) { |
| const myP = players.find(isLocalPlayer); |
| if (myP && myP.alive) { |
| myTrail.push({ x: myP.x, y: myP.y }); |
| if (myTrail.length > 14) myTrail.shift(); |
| } else if (myTrail.length) { |
| myTrail.shift(); |
| } |
| for (let i = 0; i < myTrail.length; i++) { |
| const t = myTrail[i]; |
| const progress = (i + 1) / myTrail.length; |
| ctx.beginPath(); |
| ctx.arc(t.x, t.y, (myP ? myP.radius : 18) * 0.6 * progress, 0, Math.PI * 2); |
| ctx.fillStyle = myBallColor; |
| ctx.globalAlpha = 0.3 * progress; |
| ctx.fill(); |
| } |
| ctx.globalAlpha = 1; |
| } |
| |
| for (const p of players) { |
| if (!p.alive) continue; |
| |
| if (mode === "coin" && p.invulnerableUntil && Date.now() < p.invulnerableUntil) { |
| if (Math.floor(Date.now() / 100) % 2 === 0) { |
| continue; |
| } |
| } |
| |
| ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI*2); |
| ctx.fillStyle = p.color; ctx.fill(); |
| |
| if (mode === "coin" && p.stunnedUntil && Date.now() < p.stunnedUntil) { |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, p.radius + 6, 0, Math.PI * 2); |
| ctx.strokeStyle = "rgba(0, 191, 255, 0.8)"; |
| ctx.lineWidth = 4; |
| ctx.stroke(); |
| } |
| |
| if (mode === "mirror" && p.name === "Mirror") { |
| ctx.beginPath(); ctx.arc(p.x, p.y, p.radius+4, 0, Math.PI*2); |
| ctx.strokeStyle = "rgba(255,136,170,0.6)"; ctx.lineWidth = 2; |
| ctx.setLineDash([5,5]); ctx.stroke(); ctx.setLineDash([]); |
| } |
| |
| ctx.fillStyle = "white"; ctx.font = "14px Arial"; ctx.textAlign = "center"; |
| ctx.fillText(p.label || dn(p.name), p.x, p.y - p.radius - 10); |
| } |
| |
| if (mode === "online") { |
| const me = players.find(p => p.name === onlineAccount?.name); |
| if (me && !me.alive) { |
| ctx.fillStyle = "rgba(255,0,0,0.18)"; |
| ctx.fillRect(0, 0, W(), H()); |
| ctx.fillStyle = "white" ; ctx.font = "bold 48px Arial"; ctx.textAlign = "center"; |
| ctx.fillText(tr("💀 You died! Waiting for result…"), W()/2, H()/2); |
| } |
| } |
| } |
| |
| |
| |
| |
| |
| // ═══════════════════════════════════════════════ |
| // CONTROLS |
| // ═══════════════════════════════════════════════ |
| function moveSingle(x, y) { |
| if (spectateModeActive || humanRemovedFromQueue) return; |
| |
| const me = players.find(p => p.name === "Player" || p.name === "YOU" || p.name === "Player 1"); |
| if (mode === "coin" && me && me.stunnedUntil && Date.now() < me.stunnedUntil) return; |
| |
| if (mode === "tournament") { |
| const match = playerMatchMap.get("YOU"); |
| if (!match || !match.running) return; |
| const meMatch = match.players.find(p => p.name === "YOU"); |
| if (meMatch && meMatch.alive) { |
| meMatch.x = Math.max(meMatch.radius, Math.min(W()-meMatch.radius, x)); |
| meMatch.y = Math.max(meMatch.radius, Math.min(H()-meMatch.radius, y)); |
| } |
| return; |
| } |
| |
| if (mode === "online") { |
| const meOnline = survivalActive ? players[0] : players.find(p => p.name === onlineAccount?.name); // Survival/Daily: immer der eigene Ball |
| if (meOnline && meOnline.alive) { |
| meOnline.x = Math.max(meOnline.radius, Math.min(W()-meOnline.radius, x)); |
| meOnline.y = Math.max(meOnline.radius, Math.min(H()-meOnline.radius, y)); |
| } |
| return; |
| } |
| |
| if (players[0] && players[0].alive) { |
| players[0].x = Math.max(players[0].radius, Math.min(W()-players[0].radius, x)); |
| players[0].y = Math.max(players[0].radius, Math.min(H()-players[0].radius, y)); |
| } |
| } |
| |
| function endCoinMode() { |
| running = false; |
| clearAllTimers(); |
| document.getElementById("coinHUD").style.display = "none"; |
| |
| const sorted = [...players].sort((a, b) => (b.coinsCollected || 0) - (a.coinsCollected || 0)); |
| |
| let html = "<b>🪙 COIN HUNT RANKING</b><br><br>"; |
| for (let i = 0; i < sorted.length; i++) { |
| const p = sorted[i]; |
| const isMe = p.name === "Player" ? " (YOU)" : ""; |
| html += `${i+1}. <span style="color:${p.color}">${p.name}${isMe}</span>: <b>${p.coinsCollected || 0}</b><br>`; |
| } |
| document.getElementById("ranking").innerHTML = html; |
| |
| document.getElementById("finalTime").textContent = coinScorePlayer + " Coins"; |
| showScreen("gameOver"); |
| } |
| |
| // ═══════════════════════════════════════════════ |
| // ONLINE TV FUNCTION |
| // CHANGED: scans onlineMatches (uid-keyed participants now) via the |
| // guarded RPC; read-only, no write capability needed here at all. |
| // ═══════════════════════════════════════════════ |
| function watchPlayerTV(uid) { |
| if (!uid || !fbReady) return; |
| if (inMatchOrSurvival()) { challengeToast("You are in a match right now", "#ff8844"); return; } |
| startOnlineTV(uid); |
| } |
| async function startOnlineTV(targetUid) { |
| const tvTarget = (typeof targetUid === "string" && targetUid) ? targetUid : null; |
| cleanupOnline(); |
| clearAllTimers(); |
| |
| if (!fbReady) { |
| alert("Firebase is not connected yet!"); |
| return; |
| } |
| |
| hideAllScreensForGame(); |
| document.getElementById("spectatorInfo").style.display = "block"; |
| |
| mode = "online_tv"; |
| document.body.classList.add("mode-tv"); |
| tvClock = null; |
| const tvUiDefault = `Time: <span id="time">0</span>s<br>Balls: <span id="balls">0</span><br>Mode: <span id="mode">Online TV</span>`; |
| document.getElementById("ui").innerHTML = tvUiDefault; |
| { const md = document.getElementById("mode"); if (md) md.textContent = "Online TV"; } |
| running = true; |
| balls = []; |
| players = []; |
| startTime = Date.now(); |
| lastBallCorrectionAt = 0; |
| |
| const specText = document.getElementById("spectatorText"); |
| specText.innerHTML = "Waiting for an active match to start..."; |
| |
| const deadRemoved = new Set(); // Matches, für die schon ein Löschversuch lief |
| // Eingefrorene Matches dauerhaft (6h) im Browser merken, damit sie beim naechsten TV-Start nicht wieder auftauchen, |
| // auch wenn das Loeschen in der Datenbank scheitert. Schluessel = Match-ID + createdAt (Revanchen/neue Matches bleiben sichtbar). |
| const TV_IGN_KEY = "dobTvIgnore"; |
| const tvIgnLoad = () => { try { const o = JSON.parse(localStorage.getItem(TV_IGN_KEY) || "{}"), n = Date.now(); for (const k of Object.keys(o)) if (o[k] < n) delete o[k]; return o; } catch (e) { return {}; } }; |
| const tvIgnored = (k) => !!tvIgnLoad()[k]; |
| const tvIgnore = (k) => { try { const o = tvIgnLoad(); o[k] = Date.now() + 6 * 3600 * 1000; localStorage.setItem(TV_IGN_KEY, JSON.stringify(o)); } catch (e) {} }; |
| // Ein Match gilt als "gestartet", sobald irgendein Spieler- oder Ball-Status geschrieben wurde |
| const hasStarted = (m) => !!(m && (m.p1State || m.p2State || m.ballState)); |
| const TV_START_GRACE_MS = 15000; // so lange darf VS-Screen/Countdown dauern, danach gilt ein Match ohne Start als tot |
| const seenPlaying = new Map(); // id -> wann das TV das Match zuerst als "playing" OHNE Status gesehen hat |
| const badMatches = new Map(); // id -> Zeitpunkt, bis zu dem das Match übersprungen wird (läuft ab, wird später erneut versucht) |
| let gotPacket = false; |
| let lastMatchesData = null; |
| let lastPacketSeen = 0; |
| let curMeta = null; |
| |
| // Survival-Live: Liste der gerade spielenden Spieler (aktiv = Daten haben sich in den letzten 4s geaendert) |
| let lastSurvData = null; |
| const survSeen = new Map(); // uid -> { t, at } |
| const survActive = () => { |
| const now = Date.now(), out = []; |
| for (const [uid, d] of Object.entries(lastSurvData || {})) { |
| if (!d || d.done) continue; |
| const k = survSeen.get(uid); |
| if (!k || k.t !== d.t) survSeen.set(uid, { t: d.t, at: now }); |
| if (now - survSeen.get(uid).at < 4000) out.push([uid, d]); |
| } |
| return out; |
| }; |
| let holdUntil = 0, holdTimer = null; |
| // Home-Button im TV: in die Luecke zwischen #ui (oben links) und dem Zeit/Score-HUD (Mitte) setzen |
| const homeEl = document.getElementById("homeBtn"); |
| const placeHome = () => { |
| if (!homeEl || mode !== "online_tv") return; |
| const hud = document.getElementById("onlineHUD"), ui = document.getElementById("ui"); |
| const par = homeEl.offsetParent || document.body, pr = par.getBoundingClientRect(); |
| const hw = homeEl.offsetWidth, hh = homeEl.offsetHeight; |
| const hudOn = hud && hud.style.display !== "none" && hud.offsetWidth > 0; |
| const uiOn = ui && ui.offsetWidth > 0 && getComputedStyle(ui).display !== "none"; |
| const uiR = uiOn ? ui.getBoundingClientRect() : null; |
| if (!hudOn) { homeEl.style.left = ""; homeEl.style.top = ""; return; } |
| const hr = hud.getBoundingClientRect(); |
| const minLeft = (uiR ? uiR.right : pr.left + 10) + 8; |
| const left = hr.left - 8 - hw; |
| if (left >= minLeft) { // passt in die Luecke |
| homeEl.style.left = (left - pr.left) + "px"; |
| homeEl.style.top = (hr.top - pr.top + Math.max(0, (hr.height - hh) / 2)) + "px"; |
| } else if (uiR) { // zu eng: direkt unter die UI oben links |
| homeEl.style.left = (uiR.left - pr.left) + "px"; |
| homeEl.style.top = (uiR.bottom - pr.top + 6) + "px"; |
| } |
| }; |
| const homeIv = setInterval(placeHome, 400); |
| onlineUnsubscribes.push(() => { clearInterval(homeIv); if (homeEl) { homeEl.style.left = ""; homeEl.style.top = ""; } }); |
| const hideWinner = () => { const o = document.getElementById("tvWinnerOverlay"); if (o) o.remove(); const sb = document.getElementById("tvStallBanner"); if (sb) sb.remove(); }; |
| const showWinner = (winner, scoreTxt, survTime) => { |
| hideWinner(); |
| const o = document.createElement("div"); |
| o.id = "tvWinnerOverlay"; |
| o.style.cssText = "position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);z-index:150;text-align:center;pointer-events:none;" + |
| "background:linear-gradient(160deg,rgba(16,18,26,0.92),rgba(8,9,14,0.92));border:1px solid rgba(255,215,0,0.5);border-radius:16px;" + |
| "padding:22px 38px;color:#f0f0f5;font-family:var(--font-display);box-shadow:0 10px 40px rgba(0,0,0,0.6);backdrop-filter:blur(8px);"; |
| const title = document.createElement("div"); |
| title.style.cssText = "font-size:15px;letter-spacing:2px;opacity:.7;margin-bottom:8px;"; |
| title.textContent = survTime ? (String(survTime).indexOf("🔥") === 0 ? "STREAKS ENDED" : "SURVIVAL ENDED") : winner ? "MATCH FINISHED" : "MATCH ENDED"; |
| const big = document.createElement("div"); |
| big.style.cssText = "font-size:30px;font-weight:700;"; |
| if (survTime) { |
| big.append(document.createTextNode(String(survTime).indexOf("🔥") === 0 ? "🔥 " : "🏝 ")); |
| const nm = document.createElement("span"); nm.setAttribute("translate", "no"); nm.style.color = "gold"; nm.textContent = winner + " – " + survTime; |
| big.append(nm); |
| } else if (winner) { |
| big.append(document.createTextNode("🏆 Winner: ")); |
| const nm = document.createElement("span"); |
| nm.setAttribute("translate", "no"); |
| nm.style.color = "gold"; |
| nm.textContent = winner; |
| big.append(nm); |
| } else { |
| big.textContent = "No winner (opponent left)"; |
| } |
| o.append(title, big); |
| if (scoreTxt) { const s = document.createElement("div"); s.style.cssText = "margin-top:8px;font-size:15px;opacity:.8;"; s.setAttribute("translate", "no"); s.textContent = scoreTxt; o.append(s); } |
| document.body.appendChild(o); |
| }; |
| const setStall = (on) => { |
| let b = document.getElementById("tvStallBanner"); |
| if (!on) { if (b) b.remove(); return; } |
| if (!b) { |
| b = document.createElement("div"); |
| b.id = "tvStallBanner"; |
| b.style.cssText = "position:fixed;left:50%;top:30%;transform:translateX(-50%);z-index:140;pointer-events:none;padding:8px 18px;border-radius:12px;" + |
| "background:rgba(192,57,43,0.92);color:#fff;font:700 14px var(--font-body,sans-serif);box-shadow:0 6px 24px rgba(0,0,0,.5);"; |
| b.textContent = "⚠ Connection to match lost – waiting for data…"; |
| document.body.appendChild(b); |
| } |
| }; |
| const stopInner = () => { |
| setStall(false); |
| if (onlineTvInnerUnsubscribe) { try { onlineTvInnerUnsubscribe(); } catch(e) {} onlineTvInnerUnsubscribe = null; } |
| onlineTvCurrentMatchId = null; curMeta = null; tvClock = null; tvFreezeBalls = false; |
| { const u = document.getElementById("ui"); if (u && mode === "online_tv") u.innerHTML = tvUiDefault; } |
| stopViewerCount(); |
| players = []; balls = []; |
| const h = document.getElementById("onlineHUD"); if (h) h.style.display = "none"; |
| }; |
| |
| // Das Parent-Listener feuert bei JEDER Positionsänderung ALLER Matches. |
| // Deshalb nur speichern und die Auswahl selten (alle 1.5s) neu berechnen. |
| const pickMatch = () => { |
| if (Date.now() < holdUntil) return; // Gewinner-Anzeige läuft noch |
| // Laufendes Match/Survival wird NIE gewechselt: erst wenn es zu Ende ist (done/abandoned/Run vorbei) |
| // oder eingefroren (siehe Intervall unten), wird onlineTvCurrentMatchId geleert und neu gewählt. |
| if (onlineTvCurrentMatchId) return; |
| const matchesData = lastMatchesData || {}; |
| let bestId = null, bestElo = -1, best = null; |
| for (const [id, m] of Object.entries(matchesData)) { |
| if (m.status !== "playing") { seenPlaying.delete(id); continue; } |
| if (tvTarget && m.p1uid !== tvTarget && m.p2uid !== tvTarget) continue; |
| // Nie gestartete Matches (gepaart, aber nie losgegangen): nicht anzeigen, nach kurzer Zeit löschen |
| if (!hasStarted(m)) { |
| if (!seenPlaying.has(id)) seenPlaying.set(id, Date.now()); |
| else if (Date.now() - seenPlaying.get(id) > TV_START_GRACE_MS && !deadRemoved.has(id)) { |
| deadRemoved.add(id); |
| RPC.removeMatch(id).catch(() => {}); // klappt nur, wenn die Rules es erlauben (siehe RULES) |
| } |
| continue; |
| } |
| seenPlaying.delete(id); |
| if (tvIgnored(id + ":" + (m.createdAt || 0))) continue; |
| if (badMatches.has(id)) { if (Date.now() < badMatches.get(id)) continue; badMatches.delete(id); } |
| // Matches, die älter als 30 Min. sind, sind fast sicher Zombies |
| if (m.createdAt && Date.now() - m.createdAt > 30 * 60 * 1000) { |
| if (!deadRemoved.has(id)) { deadRemoved.add(id); RPC.removeMatch(id).catch(() => {}); } // Rules erlauben Loeschen toter Matches |
| continue; |
| } |
| // Zombie-Erkennung: die Spieler schreiben alle 2,5s einen Ping ins Match. Ist der letzte Ping >30s alt, |
| // ist der Spieler weg (Tab geschlossen / Verbindung weg), das Match blieb aber auf "playing" stehen. |
| { const pg = Math.max(Number(m.ping_p1) || 0, Number(m.ping_p2) || 0); |
| if (pg && Date.now() - pg > 30000) { |
| // >95s kein Lebenszeichen: Zombie-Match aufraeumen (Rules: Ping >90s alt -> Loeschen erlaubt) |
| if (Date.now() - pg > 95000 && !deadRemoved.has(id)) { deadRemoved.add(id); RPC.removeMatch(id).catch(() => {}); } |
| continue; |
| } } |
| const avg = ((m.p1elo || 1500) + (m.p2elo || 1500)) / 2; |
| if (avg > bestElo) { bestElo = avg; bestId = id; best = m; } |
| } |
| // Survival-Kandidat: Spieler mit der hoechsten ELO (1v1 zaehlt mit dem Durchschnitt beider ELOs) |
| let survId = null, survD = null, survElo = -1; |
| for (const [uid, d] of survActive()) { |
| const id = "surv:" + uid; |
| if (tvTarget && uid !== tvTarget) continue; |
| if (badMatches.has(id)) { if (Date.now() < badMatches.get(id)) continue; badMatches.delete(id); } |
| const e = Number(d.elo) || 1500; |
| if (e > survElo || (e === survElo && id === onlineTvCurrentMatchId)) { survId = id; survD = d; survElo = e; } |
| } |
| // Survival gewinnt nur bei hoeherer ELO als das beste 1v1 (bei Gleichstand bleibt das aktuelle) |
| if (survId && (!bestId || survElo > bestElo || (survElo === bestElo && survId === onlineTvCurrentMatchId))) { |
| if (survId === onlineTvCurrentMatchId) return; |
| // Ein bereits laufender Survival-Zuschauer bleibt beim selben Spieler, solange er aktiv ist |
| stopInner(); |
| onlineTvCurrentMatchId = survId; |
| watchViewerCount("surv_" + survId.slice(5), true); |
| lastPacketSeen = Date.now(); gotPacket = false; curMeta = null; |
| const suid = survId.slice(5); |
| const isSk = survD.k === "streak", isDl = survD.k === "daily"; |
| let skRd = -1; |
| const sp = createPlayer(isSk ? W() * 0.25 : W() / 2, H() / 2, survD.c || "#00ff88", survD.name); |
| const spBot = isSk ? createPlayer(W() * 0.75, H() / 2, "#ff88aa", "Bot") : null; |
| if (isSk) { sp.label = dn(survD.name); spBot.label = "Online Bot"; } |
| players = spBot ? [sp, spBot] : [sp]; balls = []; |
| const skUi = (d) => { |
| const u = document.getElementById("ui"); if (!u || mode !== "online_tv") return; |
| if (!document.getElementById("sk_w")) u.innerHTML = `🔥 ${tr("Streaks")}: <b id="sk_w">0</b><br>${tr("Round")}: <span id="sk_r">1</span><br>${tr("Bot level")}: <span id="sk_l">1</span>`; |
| const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; }; |
| set("sk_w", d.w || 0); set("sk_r", d.L || 1); set("sk_l", d.L || 1); |
| }; |
| if (isSk) skUi(survD); else tvClock = { base: (survD.t || 0) / 60, at: Date.now() }; |
| const hud = document.getElementById("onlineHUD"); |
| const setT = (id, t) => { const e = document.getElementById(id); if (e) e.textContent = t; }; |
| setT("onlineNameMe", dn(survD.name)); setT("onlineNameOpp", ""); setT("onlineRankMe", "(" + survElo + ")"); setT("onlineRankOpp", ""); |
| if (isSk) { setT("onlineStatusHUD", "📺 Live Streaks · " + tr("Round") + " " + (survD.L || 1)); setT("onlineScoreHUD", "🔥 " + (survD.w || 0)); } |
| else { setT("onlineStatusHUD", isDl ? "📺 Live Daily" : "📺 Live Survival"); setT("onlineScoreHUD", survFmt((survD.t || 0) / 60)); } |
| if (hud) hud.style.display = "flex"; |
| specText.innerHTML = `<div style="text-align:center;line-height:1.4;"><b style="color:gold;">📺 LIVE ${isSk ? "STREAKS" : isDl ? "DAILY" : "SURVIVAL"} (${survElo} ELO)</b><br><span style="color:#00ff88">${escapeHtml(dn(survD.name))}</span></div>`; |
| onlineTvInnerUnsubscribe = dbOn("survivalLive/" + suid, (d) => { |
| if (survId !== onlineTvCurrentMatchId) return; |
| if (!d) { badMatches.set(survId, Date.now() + 10000); stopInner(); return; } |
| if (d.done) { |
| if (onlineTvInnerUnsubscribe) { try { onlineTvInnerUnsubscribe(); } catch(e) {} onlineTvInnerUnsubscribe = null; } |
| tvFreezeBalls = true; |
| showWinner(dn(d.name), "", isSk ? "🔥 " + (Number(d.w) || 0) + " wins" : survFmt(Number(d.time) || (d.t || 0) / 60)); |
| badMatches.set(survId, Date.now() + 15000); |
| specText.innerHTML = (isSk ? "Streaks" : isDl ? "Daily run" : "Survival run") + " finished. Searching for next game..."; |
| holdUntil = Date.now() + 6000; |
| clearTimeout(holdTimer); |
| holdTimer = setTimeout(() => { hideWinner(); holdUntil = 0; stopInner(); pickMatch(); }, 6000); |
| return; |
| } |
| lastPacketSeen = Date.now(); gotPacket = true; |
| if (isSk) { |
| const rd = d.rd || 0; |
| if (rd !== skRd) { // neue Runde: alles hart auf den neuen Stand setzen statt hinzugleiten |
| skRd = rd; balls = []; |
| sp.lastPacketAt = 0; spBot.lastPacketAt = 0; |
| } |
| tvFreezeBalls = d.ph !== "play"; // Intro/Rundensieg: die Baelle stehen auch beim Spieler still |
| feedNetPlayer(sp, { x: d.x, y: d.y, alive: d.ma !== false }, 250); |
| feedNetPlayer(spBot, { x: d.bx, y: d.by, alive: d.ba !== false }, 250); |
| setT("onlineScoreHUD", "🔥 " + (d.w || 0)); setT("onlineStatusHUD", "📺 Live Streaks · " + tr("Round") + " " + (d.L || 1)); |
| skUi(d); |
| if (d.b) { try { applyStreakTvBalls(JSON.parse(d.b)); } catch(e) {} } |
| return; |
| } |
| feedNetPlayer(sp, { x: d.x, y: d.y, alive: true }); |
| setT("onlineScoreHUD", survFmt((d.t || 0) / 60)); |
| tvClock = { base: (d.t || 0) / 60, at: Date.now() }; |
| if (d.b) { try { applyBallCorrection(JSON.parse(d.b), 0.35); } catch(e) {} } |
| }); |
| onlineUnsubscribes.push(onlineTvInnerUnsubscribe); |
| return; |
| } |
| if (!bestId) { |
| specText.innerHTML = tvTarget ? "This player is not playing right now. Waiting for their next game..." : "No live match is currently running. Waiting for a game..."; |
| stopInner(); |
| return; |
| } |
| if (bestId === onlineTvCurrentMatchId) return; |
| |
| stopInner(); |
| onlineTvCurrentMatchId = bestId; |
| watchViewerCount(bestId, true); |
| lastPacketSeen = Date.now(); |
| gotPacket = false; |
| curMeta = best; |
| setOnlineAspect(best.arena); |
| |
| const p1 = createPlayer(W() * 0.3, H() / 2, "#00ff88", best.p1); |
| const p2 = createPlayer(W() * 0.7, H() / 2, "#ff8844", best.p2); |
| players = [p1, p2]; |
| |
| // Score-HUD oben für Zuschauer |
| { |
| const hud = document.getElementById("onlineHUD"); |
| const setT = (id, t) => { const e = document.getElementById(id); if (e) e.textContent = t; }; |
| setT("onlineNameMe", dn(best.p1)); setT("onlineNameOpp", dn(best.p2)); |
| setT("onlineRankMe", "(" + Math.round(best.p1elo || 1500) + ")"); |
| setT("onlineRankOpp", "(" + Math.round(best.p2elo || 1500) + ")"); |
| setT("onlineStatusHUD", "📺 Live"); |
| setT("onlineScoreHUD", "0 - 0"); |
| if (hud) hud.style.display = "flex"; |
| const tvId = bestId; |
| if (best.p1uid && best.p2uid) { |
| getHeadToHeadScore(best.p1uid, best.p2uid).then(sc => { |
| if (tvId !== onlineTvCurrentMatchId) return; |
| setT("onlineScoreHUD", (sc[best.p1uid] || 0) + " - " + (sc[best.p2uid] || 0)); |
| }).catch(() => {}); |
| } |
| } |
| specText.innerHTML = ` |
| <div style="text-align:center; line-height: 1.4;"> |
| <b style="color: gold;">📺 LIVE GAME TV (Average: ${Math.round(bestElo)} ELO)</b><br> |
| <span style="color:#00ff88">${escapeHtml(dn(p1.name))} (${Math.round(best.p1elo || 1500)})</span> vs |
| <span style="color:#ff8844">${escapeHtml(dn(p2.name))} (${Math.round(best.p2elo || 1500)})</span> |
| </div>`; |
| |
| const feed = feedNetPlayer; |
| |
| const myId = bestId; |
| onlineTvInnerUnsubscribe = RPC.onMatch(myId, (data) => { |
| if (myId !== onlineTvCurrentMatchId) return; |
| if (!data || data.status === "done" || data.status === "abandoned") { |
| // Gewinner anzeigen (Spielfeld bleibt ein paar Sekunden eingefroren), dann nächstes Match suchen |
| if (onlineTvInnerUnsubscribe) { try { onlineTvInnerUnsubscribe(); } catch(e) {} onlineTvInnerUnsubscribe = null; } |
| const winner = data && data.status === "done" && data.winner ? data.winner : null; |
| const sc = data && Number.isFinite(data.p1Score) && Number.isFinite(data.p2Score) ? data.p1Score + " - " + data.p2Score : ""; |
| showWinner(winner, sc); |
| if (winner && curMeta && curMeta.p1uid && curMeta.p2uid) { |
| const m0 = curMeta, hh = document.getElementById("onlineScoreHUD"); |
| getHeadToHeadScore(m0.p1uid, m0.p2uid).then(r => { if (hh) hh.textContent = (r[m0.p1uid] || 0) + " - " + (r[m0.p2uid] || 0); }).catch(() => {}); |
| } |
| specText.innerHTML = "Match finished. Searching for next game..."; |
| holdUntil = Date.now() + 6000; |
| clearTimeout(holdTimer); |
| holdTimer = setTimeout(() => { hideWinner(); holdUntil = 0; stopInner(); pickMatch(); }, 6000); |
| return; |
| } |
| if (data.p1State) { feed(p1, data.p1State); lastPacketSeen = Date.now(); gotPacket = true; } |
| if (data.p2State) { feed(p2, data.p2State); lastPacketSeen = Date.now(); gotPacket = true; } |
| if (data.ballState) { |
| try { |
| const bsArr = JSON.parse(data.ballState); |
| if (!tvClock) tvClock = { base: Math.max(0, bsArr.length - ONLINE_START_BALLS) * ONLINE_SPAWN_MS / 1000, at: Date.now() }; |
| applyBallCorrection(bsArr, 0.35); lastPacketSeen = Date.now(); gotPacket = true; |
| } catch(e) {} |
| } |
| }); |
| onlineUnsubscribes.push(onlineTvInnerUnsubscribe); |
| }; |
| |
| onlineTvUnsubscribe = dbOn("onlineMatches", (d) => { |
| const first = lastMatchesData === null; |
| lastMatchesData = d || {}; |
| if (first) pickMatch(); |
| }); |
| onlineUnsubscribes.push(onlineTvUnsubscribe); |
| const survUnsub = dbOn("survivalLive", (d) => { lastSurvData = d || {}; if (lastMatchesData === null) { lastMatchesData = {}; } }); |
| onlineUnsubscribes.push(survUnsub); |
| setTimeout(() => { if (mode !== "online_tv" || !running) return; if (lastMatchesData === null) lastMatchesData = {}; pickMatch(); }, 1200); |
| |
| // Auswahl periodisch neu bewerten + eingefrorene Matches (>8s ohne Daten) überspringen |
| const tvIv = setInterval(() => { |
| if (mode !== "online_tv" || !running) { clearInterval(tvIv); return; } |
| // Vor dem ersten Paket (VS-Screen/Countdown) mehr Geduld; danach 8s ohne Daten = eingefroren. |
| // Übersprungene Matches werden nach 10s erneut geprüft -> Zuschauen klappt vor UND während des Starts. |
| if (Date.now() < holdUntil) return; |
| // Nach 2s ohne Daten Hinweis zeigen (nur wenn schon Daten kamen und nicht gerade der Gewinner angezeigt wird) |
| setStall(!!(onlineTvCurrentMatchId && gotPacket && Date.now() - lastPacketSeen > 2000)); |
| if (onlineTvCurrentMatchId && Date.now() - lastPacketSeen > (gotPacket ? 5000 : 20000)) { |
| badMatches.set(onlineTvCurrentMatchId, Date.now() + 5 * 60 * 1000); // eingefroren: 5 Min. ueberspringen statt 10s |
| if (!String(onlineTvCurrentMatchId).startsWith("surv:")) tvIgnore(onlineTvCurrentMatchId + ":" + ((curMeta && curMeta.createdAt) || 0)); |
| // Zusaetzlich loeschen versuchen: die Rules erlauben es nur, wenn das Match wirklich tot ist (kein/alter Ping), |
| // ein lebendes, nur laggendes Match bleibt dadurch geschuetzt. |
| if (!String(onlineTvCurrentMatchId).startsWith("surv:") && !deadRemoved.has(onlineTvCurrentMatchId)) { |
| deadRemoved.add(onlineTvCurrentMatchId); |
| RPC.removeMatch(onlineTvCurrentMatchId).catch(e => console.warn("TV: cleanup of frozen match failed", e && (e.code || e.message))); |
| } |
| stopInner(); |
| } |
| pickMatch(); |
| }, 1500); |
| onlineUnsubscribes.push(() => { clearInterval(tvIv); clearTimeout(holdTimer); hideWinner(); }); |
| } |
| |
| |
| |
| // Browser-Zoom sperren (Strg+Mausrad, Strg +/-/0, Pinch am Trackpad). Das Browsermenü-Zoomen lässt sich nicht verhindern. |
| window.addEventListener("wheel", e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false }); |
| window.addEventListener("keydown", e => { |
| if ((e.ctrlKey || e.metaKey) && ["+", "-", "=", "_", "0", "add", "subtract"].includes(e.key.toLowerCase())) e.preventDefault(); |
| }, { capture: true }); |
| ["gesturestart", "gesturechange", "gestureend"].forEach(t => document.addEventListener(t, e => e.preventDefault())); |
| |
| document.addEventListener("keydown", e => { keys[e.key.toLowerCase()] = true; }); |
| document.addEventListener("keyup", e => { keys[e.key.toLowerCase()] = false; }); |
| |
| canvas.addEventListener("mousemove", e => { |
| if (running && ["single","tournament","coin","mirror","online","survival","streak"].includes(mode)) { |
| const pos = screenToVirtual(e.clientX, e.clientY); |
| moveSingle(pos.x, pos.y); |
| } |
| }); |
| |
| canvas.addEventListener("touchmove", e => { |
| e.preventDefault(); |
| if (!running) return; |
| if (!["single","tournament","coin","mirror","online","survival","streak"].includes(mode)) return; |
| const touch = e.touches[0]; |
| const pos = screenToVirtual(touch.clientX, touch.clientY); |
| moveSingle(pos.x, pos.y); |
| }, { passive: false }); |
| |
| canvas.addEventListener("touchstart", e => { |
| e.preventDefault(); |
| if (!running) return; |
| if (!["single","tournament","coin","mirror","online","survival","streak"].includes(mode)) return; |
| const touch = e.touches[0]; |
| const pos = screenToVirtual(touch.clientX, touch.clientY); |
| moveSingle(pos.x, pos.y); |
| }, { passive: false }); |
| </script> |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| <script> |
| |
| |
| |
| |
| |
| const LANGS = ["en", "de"]; |
| const DICT = { de: { |
| "🏠 Home":"🏠 Menü","Home":"Menü","Time:":"Zeit:","Record:":"Rekord:","Balls:":"Bälle:","Mode:":"Modus:","Players:":"Spieler:","Survived:":"Überlebt:", |
| "ELO LIVE":"ELO LIVE","BEST TV":"BESTES TV","RANDOM TV":"ZUFALLS-TV","TARGET TV":"ZIEL-TV","▶ RESUME":"▶ WEITER", |
| "👤 Log in / Sign up":"👤 Anmelden / Registrieren","💬 Global Chat":"💬 Globaler Chat","Connecting…":"Verbinde…", |
| "🔒 Create an account to be able to write in the chat.":"🔒 Erstelle einen Account, um im Chat schreiben zu können.", |
| "Create account":"Account erstellen","Send":"Senden","No messages yet.":"Noch keine Nachrichten.", |
| "🌐 Online":"🌐 Online","🎮 Offline":"🎮 Offline","⚙️ Settings":"⚙️ Einstellungen","⚔️ Online 1v1":"⚔️ Online 1v1", |
| "📺 Best Online TV":"📺 Bestes Online-TV","🏆 Leaderboard":"🏆 Rangliste","← Back":"← Zurück", |
| "Singleplayer":"Einzelspieler","Multiplayer (local)":"Mehrspieler (lokal)","AI Spectate":"KI zuschauen", |
| "1vs1 Tournament":"1vs1-Turnier","🪙 Coin Hunt":"🪙 Münzjagd","⚙️ Offline Settings":"⚙️ Offline-Einstellungen", |
| "🟢 Active Players:":"🟢 Aktive Spieler:","Connecting presence service...":"Verbinde mit Spielerliste…", |
| "No active players":"Keine aktiven Spieler","No other players online":"Keine anderen Spieler online", |
| "General – applies to all modes":"Allgemein – gilt für alle Modi","Your Ball Color":"Deine Ballfarbe", |
| "Ball Trail Effect ✨":"Ball-Spureffekt ✨","Language / Sprache 🌍":"Sprache / Language 🌍", |
| "🎮 Offline Settings":"🎮 Offline-Einstellungen","Only applies to offline modes (online uses fixed rules)":"Gilt nur für Offline-Modi (Online nutzt feste Regeln)", |
| "Start Balls":"Start-Bälle","Spawn Time (s)":"Spawn-Zeit (s)","Ball Speed":"Ballgeschwindigkeit","Slow":"Langsam","Normal":"Normal","Fast":"Schnell","Extreme":"Extrem", |
| "AI Bots":"KI-Bots","Ranking System":"Ranglistensystem","Points":"Punkte","Save":"Speichern", |
| "🌐 Online Account":"🌐 Online-Account","🏆 Online Leaderboard":"🏆 Online-Rangliste","Rank":"Rang","W/L":"S/N", |
| "Log in to your account":"Melde dich an","Create your account":"Erstelle deinen Account","Username":"Benutzername","Password":"Passwort", |
| "Password (min 6 characters)":"Passwort (mind. 6 Zeichen)","Confirm password":"Passwort bestätigen","Log In":"Anmelden", |
| "Need an account? Create one":"Noch keinen Account? Erstellen","Create Account":"Account erstellen", |
| "Already have an account? Log In":"Schon einen Account? Anmelden","Logged in as":"Angemeldet als","Log Out":"Abmelden", |
| "Name must be at least 2 characters.":"Der Name muss mindestens 2 Zeichen haben.","Only letters, numbers, _ and - allowed.":"Nur Buchstaben, Zahlen, _ und - erlaubt.", |
| "Password must be at least 6 characters.":"Das Passwort muss mindestens 6 Zeichen haben.","Passwords do not match.":"Die Passwörter stimmen nicht überein.", |
| "Still connecting — please try again in a moment.":"Verbinde noch – bitte gleich nochmal versuchen.","Creating account…":"Erstelle Account…", |
| "Name already taken.":"Name bereits vergeben.","This name is already taken!":"Dieser Name ist bereits vergeben!","Name successfully changed!":"Name erfolgreich geändert!", |
| "Password is too weak (min 6 characters).":"Passwort zu schwach (mind. 6 Zeichen).","Enter your username and password.":"Gib Benutzername und Passwort ein.", |
| "Logging in…":"Melde an…","Incorrect username or password.":"Benutzername oder Passwort falsch.","You need to create an account first!":"Du musst zuerst einen Account erstellen!", |
| "Profile not found.":"Profil nicht gefunden.","Error while loading profile.":"Fehler beim Laden des Profils.","Error while loading":"Fehler beim Laden","No players":"Keine Spieler","Firebase not connected":"Nicht verbunden", |
| "🌐 Online 1v1":"🌐 Online 1v1","Searching for opponent…":"Suche Gegner…", |
| "Matchmaking may take up to 30 seconds. If no opponent is available, you'll automatically be paired with our Online Bot.":"Die Suche kann bis zu 30 Sekunden dauern. Ist kein Gegner verfügbar, wirst du automatisch mit unserem Online-Bot gepaart.", |
| "Cancel":"Abbrechen","🤖 Play with Online Bot":"🤖 Mit Online-Bot spielen","Yes":"Ja","Return to main menu?":"Zurück zum Hauptmenü?","Firebase not connected. Cannot play online.":"Nicht verbunden. Online spielen nicht möglich.", |
| "Firebase is not connected yet!":"Noch nicht verbunden!","Rank: Loading...":"Rang: Lädt…","Waiting for opponent…":"Warte auf Gegner…","⏳ Waiting for opponent…":"⏳ Warte auf Gegner…", |
| "🏆 Winner!":"🏆 Gewinner!","🏆 Winner:":"🏆 Gewinner:","Winner:":"Gewinner:","💬 Chat with opponent":"💬 Chat mit Gegner","(not saved)":"(wird nicht gespeichert)", |
| "🔄 Rematch":"🔄 Revanche","🔍 New Opponent":"🔍 Neuer Gegner","🔄 Accept Rematch":"🔄 Revanche annehmen","Opponent left":"Gegner hat verlassen","Practice match":"Übungsmatch", |
| "GAME OVER":"SPIEL VORBEI","Play Again":"Nochmal spielen","Me":"Ich","Opp":"Gegner","👁 Spectator Mode":"👁 Zuschauermodus","Opponent:":"Gegner:","Skill:":"Skill:","Rank:":"Rang:", |
| "Waiting for an active match to start...":"Warte auf den Start eines Matches…","No online games are currently running.":"Zurzeit laufen keine Online-Spiele.", |
| "No live match is currently running. Waiting for a game...":"Zurzeit läuft kein Live-Match. Warte auf ein Spiel…","Match finished. Searching for next game...":"Match beendet. Suche nächstes Spiel…", |
| "You are watching – choose a TV mode":"Du schaust zu – wähle einen TV-Modus","Waiting for next match…":"Warte auf das nächste Match…","💀 You died! Waiting for result…":"💀 Du bist gestorben! Warte auf das Ergebnis…", |
| "Message…":"Nachricht…","Account required to write…":"Account zum Schreiben nötig…","View profile":"Profil ansehen", |
| "Total Games":"Spiele gesamt","Wins":"Siege","Losses":"Niederlagen","Winrate":"Siegquote","Highest Elo":"Höchste Elo","Lowest Elo":"Niedrigste Elo", |
| "Longest Winstreaks":"Längste Siegesserien","Current Winstreaks":"Aktuelle Siegesserien","Best Win":"Bester Sieg","Worst Loss":"Schlimmste Niederlage", |
| "Total Elo Won":"Elo gewonnen","Total Elo Lost":"Elo verloren","Matches":"Matches","Best Streaks":"Beste Serien","ELO Gained":"ELO gewonnen","ELO Lost":"ELO verloren", |
| "Status":"Status","Speed":"Tempo","Awareness":"Wahrnehmung","Skill":"Skill", |
| "MATCH FINISHED":"MATCH BEENDET","MATCH ENDED":"MATCH ABGEBROCHEN","No winner (opponent left)":"Kein Gewinner (Gegner hat verlassen)","Idle in Menu":"Im Menü","No Streaks records yet":"Noch keine Streaks-Rekorde","Streaks":"Streaks","Best":"Bestwert","Round":"Runde","Bot level":"Bot-Level","ROUND":"RUNDE","ROUND WON":"RUNDE GEWONNEN","STREAKS ENDED":"STREAKS BEENDET","wins in a row":"Siege in Folge","NEW BEST":"NEUER BESTWERT","Try again":"Nochmal","Home":"Menü","chasing balls":"verfolgende Bälle","🔥 Streaks":"🔥 Streaks","You already have an open challenge":"Du hast schon eine offene Herausforderung","Cancel challenge":"Herausforderung abbrechen","Challenge cancelled":"Herausforderung abgebrochen","1v1 CHALLENGE":"1v1-HERAUSFORDERUNG","Accept":"Annehmen","Decline":"Ablehnen","Challenge to a 1v1":"Zu einem 1v1 herausfordern","Currently in a match":"Ist gerade in einem Match","Log in to challenge players":"Melde dich an, um Spieler herauszufordern","You are in a match right now":"Du bist gerade in einem Match","Challenge already sent – wait a moment":"Herausforderung schon gesendet – kurz warten","Player is in a match right now":"Spieler ist gerade in einem Match","Could not send challenge":"Herausforderung konnte nicht gesendet werden","A match between you two is already running":"Zwischen euch läuft schon ein Match","Could not start the match":"Match konnte nicht gestartet werden","Searching for match":"Sucht ein Match","1v1 vs Bot":"1v1 gegen Bot","In 1v1 Match":"Im 1v1-Match","Watching Online TV":"Schaut Online-TV", |
| "Local Multiplayer":"Lokaler Mehrspieler","Tournament":"Turnier","Coin Hunt":"Münzjagd","AI Spectate ":"KI zuschauen", |
| "League":"Liga","Highest League":"Höchste Liga","🏅 Weekly League":"🏅 Wochenliga","Week ends in":"Woche endet in","Player":"Spieler","Last week":"Letzte Woche","Stayed in":"Geblieben in","Promotion zone":"Aufstiegszone","Relegation zone":"Abstiegszone","Top":"Top","Bottom":"Letzte","min.":"mind.","Loading league…":"Lade Liga…","+1 league win":"+1 Ligasieg","Win a match to enter this week's league group.":"Gewinne ein Match, um in die Liga-Gruppe dieser Woche zu kommen.","Every win counts, including wins against the Online Bot.":"Jeder Sieg zählt, auch gegen den Online-Bot.","Promoted to":"Aufgestiegen in","Relegated to":"Abgestiegen in","Silver":"Silber","Platinum":"Platin" |
| }}; |
| const RX = { de: [ |
| [/^⚔ Challenge sent to (.+)$/, "⚔ Herausforderung an $1 gesendet"], [/^(.+) declined the challenge$/, "$1 hat die Herausforderung abgelehnt"], [/^(.+) did not respond$/, "$1 hat nicht geantwortet"], |
| [/^Starting in (\d+)…$/, "Start in $1…"], |
| [/^Rank: (.+)$/, "Rang: $1"], [/^Rank: #(.+)$/, "Rang: #$1"], |
| [/^of (\d+)$/, "von $1"], |
| [/^Winner: (.+)$/, "Gewinner: $1"], [/^🏆 Winner: (.+)$/, "🏆 Gewinner: $1"], |
| [/^Your ELO: (.+)$/, "Dein ELO: $1"], |
| [/^Round (\d+)\/(\d+)$/, "Runde $1/$2"], |
| [/^1v1 vs (.+)$/, "1v1 gegen $1"], |
| [/^Playing (.+)$/, (m, g) => "Spielt " + (DICT.de[g] || g)], |
| [/^📺 LIVE GAME TV \(Average: (\d+) ELO\)$/, "📺 LIVE-SPIEL-TV (Durchschnitt: $1 ELO)"], |
| [/^Error: (.+)$/, "Fehler: $1"], |
| [/^(\d+) ELO vs (.+)$/, "$1 ELO gegen $2"] |
| ]}; |
| |
| Object.assign(DICT.de, {"Viewing only": "Nur ansehen", "Group": "Gruppe", "My league": "Meine Liga", "No groups in this league yet": "In dieser Liga gibt es diese Woche noch keine Gruppen"}); |
| Object.assign(DICT.de, {"You did not play last week": "Du hast letzte Woche nicht gespielt – spiel und gewinne, um wieder aufzusteigen!", "Group too small": "Gruppe zu klein", "Groups under": "Gruppen mit weniger als", "players can't promote anyone this week.": "Spielern lassen diese Woche niemanden aufsteigen.", "Promotion eligibility": "Aufstiegsberechtigung", "Promoted!": "Aufgestiegen!", "Relegated": "Abgestiegen", "Welcome to the next league – new week, new goals!": "Willkommen in der nächsten Liga – neue Woche, neue Ziele!", "New week, new chance – win your way back up!": "Neue Woche, neue Chance – kämpf dich wieder hoch!","Your league this week": "Deine Liga diese Woche", "players": "Spieler", "Mon": "Mo", "Sun": "So", "You're in the promotion zone!": "Du bist in der Aufstiegszone!", "Keep your spot until Sunday and you move up.": "Halte deinen Platz bis Sonntag, dann steigst du auf.", "Almost there!": "Fast geschafft!", "more wins needed – promotion needs at least": "Siege fehlen noch – für den Aufstieg brauchst du mindestens", "You're in the relegation zone!": "Du bist in der Abstiegszone!", "Win a few matches to climb out before the week ends.": "Gewinne ein paar Matches, um vor Wochenende rauszukommen.", "You're safe": "Du bist sicher", "more wins to reach the promotion zone": "Siege bis zur Aufstiegszone", "Top league": "Höchste Liga", "You can't be promoted further – defend your place!": "Höher geht's nicht – verteidige deinen Platz!", "Rank": "Rang", "Safe zone": "Sichere Zone", "You": "Du", "Not in a group yet": "Noch in keiner Gruppe", "How does the league work?": "Wie funktioniert die Liga?", "Win matches": "Matches gewinnen", "Every online win this week = 1 point. Wins against the Online Bot count too.": "Jeder Online-Sieg diese Woche = 1 Punkt. Siege gegen den Online-Bot zählen auch.", "Your group": "Deine Gruppe", "You compete against up to": "Du trittst gegen bis zu", "players of your league. Ties are won by whoever got there first.": "Spieler deiner Liga an. Bei Gleichstand gewinnt, wer zuerst dort war.", "Sunday = decision": "Sonntag = Entscheidung", "The week ends Sunday 24:00 (German time). A new week starts Monday with fresh groups and 0 wins.": "Die Woche endet Sonntag 24:00 Uhr (deutsche Zeit). Montag startet eine neue Woche mit neuen Gruppen und 0 Siegen.", "Up or down": "Auf oder ab", "Top players move up (at least": "Die Besten steigen auf (mindestens", "wins needed), the bottom ones drop a league, everyone else stays.": "Siege nötig), die Letzten steigen ab, alle anderen bleiben.", "Reach Platinum – your ELO is not affected by the league.": "Schaffe es bis Platin – dein ELO wird von der Liga nicht beeinflusst."}); |
| |
| Object.assign(DICT.de, {"Match abandoned – penalty applied": "Match verlassen – Strafe angewendet"}); |
| |
| Object.assign(DICT.de, {"One account per player. To use a different account you have to delete this one first.": "Ein Account pro Spieler. Für einen anderen Account musst du diesen zuerst löschen.", "Delete Account": "Account löschen", "Delete account permanently?": "Account endgültig löschen?", "Your name, ELO, stats and league progress will be deleted and cannot be restored. Afterwards you can create a new account.": "Dein Name, ELO, Statistiken und Ligafortschritt werden gelöscht und können nicht wiederhergestellt werden. Danach kannst du einen neuen Account erstellen.", "Delete permanently": "Endgültig löschen", "Cancel": "Abbrechen", "Deleting…": "Lösche…", "Incorrect password.": "Falsches Passwort.", "Too many attempts. Try again later.": "Zu viele Versuche. Versuche es später erneut.", "This device already has an account": "Dieses Gerät hat schon einen Account", "Only one account per player is allowed. Log in with your existing account, or delete it first to create a new one.": "Pro Spieler ist nur ein Account erlaubt. Melde dich mit deinem Account an oder lösche ihn zuerst, um einen neuen zu erstellen."}); |
| |
| Object.assign(DICT.de, {"Enter your password.":"Gib dein Passwort ein.","🔗 Invite friends":"🔗 Freunde einladen","Congratulations!":"Herzlichen Glückwunsch!","You were promoted to":"Du bist aufgestiegen in","You were relegated to":"Du bist abgestiegen in","You stay in":"Du bleibst in","Well played!":"Gut gespielt!","New week – push for the top!":"Neue Woche – greif nach der Spitze!","Last week's ranking":"Rangliste der letzten Woche","Week of":"Woche vom","No ranking available":"Keine Rangliste verfügbar","Invite link copied!":"Einladungslink kopiert!","Copy this link and send it to your friends:":"Kopiere diesen Link und schick ihn an deine Freunde:"}); |
| |
| Object.assign(DICT.de, {"No corner camping!": "Kein Ecken-Campen!", "Move out in": "Verlasse die Ecke in", "Corner camping is not allowed – you lost the match.": "Ecken-Campen ist nicht erlaubt – du hast das Match verloren.", "Opponent was disqualified for corner camping – you win!": "Gegner wurde wegen Ecken-Campens disqualifiziert – du gewinnst!"}); |
| |
| let LANG = (function () { |
| try { const s = localStorage.getItem("dobLang"); if (LANGS.includes(s)) return s; } catch (e) {} |
| return "en"; |
| })(); |
| |
| function trStr(s) { |
| if (LANG === "en" || typeof s !== "string") return s; |
| const core = s.trim(); |
| if (!core) return s; |
| const lead = s.slice(0, s.indexOf(core)), trail = s.slice(s.indexOf(core) + core.length); |
| const d = DICT[LANG] && DICT[LANG][core]; |
| if (d !== undefined) return lead + d + trail; |
| for (const [re, rep] of (RX[LANG] || [])) if (re.test(core)) return lead + core.replace(re, rep) + trail; |
| return s; |
| } |
| window.tr = trStr; |
| |
| |
| const _alert = window.alert.bind(window); |
| window.alert = m => _alert(trStr(String(m))); |
| |
| const SKIP = new Set(["SCRIPT", "STYLE", "TEXTAREA", "CANVAS", "OPTION_NOPE"]); |
| function skipNode(n) { |
| const el = n.nodeType === 1 ? n : n.parentElement; |
| return !el || SKIP.has(el.tagName) || !!el.closest('[translate="no"]'); |
| } |
| function doText(n) { |
| if (skipNode(n)) return; |
| const cur = n.nodeValue; |
| if (n.__de !== undefined && cur === n.__de) { if (LANG === "en") { n.nodeValue = n.__en; } return; } |
| |
| n.__en = cur; n.__de = undefined; |
| if (LANG === "en") return; |
| const out = trStr(cur); |
| if (out !== cur) { n.__de = out; n.nodeValue = out; } |
| } |
| function doAttr(el, attr) { |
| const key = "__en_" + attr, keyD = "__de_" + attr; |
| const cur = el.getAttribute(attr); |
| if (cur == null || el.closest('[translate="no"]')) return; |
| if (el[keyD] !== undefined && cur === el[keyD]) { if (LANG === "en") el.setAttribute(attr, el[key]); return; } |
| el[key] = cur; el[keyD] = undefined; |
| if (LANG === "en") return; |
| const out = trStr(cur); |
| if (out !== cur) { el[keyD] = out; el.setAttribute(attr, out); } |
| } |
| function walk(root) { |
| if (!root) return; |
| if (root.nodeType === 3) { doText(root); return; } |
| if (root.nodeType !== 1) return; |
| if (root.hasAttribute("placeholder")) doAttr(root, "placeholder"); |
| if (root.hasAttribute("title")) doAttr(root, "title"); |
| for (const c of root.childNodes) walk(c); |
| } |
| let _obs = null; |
| function startI18n() { |
| walk(document.body); |
| _obs = new MutationObserver(muts => { |
| _obs.disconnect(); |
| for (const m of muts) { |
| if (m.type === "characterData") doText(m.target); |
| else if (m.type === "attributes") doAttr(m.target, m.attributeName); |
| else m.addedNodes.forEach(walk); |
| } |
| _obs.observe(document.body, OBS_CFG); |
| }); |
| _obs.observe(document.body, OBS_CFG); |
| } |
| const OBS_CFG = { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["placeholder", "title"] }; |
| |
| function setLanguage(l) { |
| if (!LANGS.includes(l)) return; |
| LANG = l; |
| try { localStorage.setItem("dobLang", l); } catch (e) {} |
| document.documentElement.lang = l; |
| if (_obs) _obs.disconnect(); |
| walk(document.body); |
| if (_obs) _obs.observe(document.body, OBS_CFG); |
| const sel = document.getElementById("langSelect"); |
| if (sel) sel.value = l; |
| |
| try { if (typeof updateChatLock === "function") updateChatLock(); } catch (e) {} |
| try { if (window._lastPlayersData && typeof renderPresence === "function") renderPresence(window._lastPlayersData); } catch (e) {} |
| } |
| document.documentElement.lang = LANG; |
| (function () { const sel = document.getElementById("langSelect"); if (sel) sel.value = LANG; })(); |
| startI18n(); |
| </script> |
| <script> |
| |
| const DISCORD_URL = "https://discord.gg/w62CNMSbYd"; |
| (function () { |
| ["linkDiscord", "linkDiscordMain"].forEach(id => { |
| const a = document.getElementById(id); |
| if (a && DISCORD_URL) { a.href = DISCORD_URL; a.style.display = "flex"; } |
| }); |
| })(); |
| </script> |
|
|
| <script> |
| |
| |
| |
| |
| |
| const NEWS_ADMIN_UID = "6O1c9OKTRlbwzDlPuBG2ceMRdz63"; |
| let _newsData = null, _newsUnsub = null, _newsReactData = {}, _newsReactUnsub = null; |
| |
| function _newsMyUid() { try { return (window.dobMyUid && window.dobMyUid()) || null; } catch (e) { return null; } } |
| function _newsIsAdmin() { const u = _newsMyUid(); return !!(u && NEWS_ADMIN_UID && u === NEWS_ADMIN_UID); } |
| function _newsItems() { |
| return Object.entries(_newsData || {}).map(([id, n]) => ({ id, ...n })) |
| .filter(n => n && n.text).sort((a, b) => (b.ts || 0) - (a.ts || 0)); |
| } |
| function _newsUpdateDot() { |
| const dot = document.getElementById("newsDot"); if (!dot) return; |
| const items = _newsItems(); let seen = 0; |
| try { seen = Number(localStorage.getItem("dob_news_seen")) || 0; } catch (e) {} |
| dot.style.display = (items.length && (items[0].ts || 0) > seen) ? "inline-block" : "none"; |
| } |
| function _newsStart() { |
| if (_newsUnsub || !window._dobRPC) return; |
| _newsUnsub = window._dobRPC.on("news", d => { |
| _newsData = d; _newsUpdateDot(); |
| const ov = document.getElementById("newsOverlay"); |
| if (ov && ov.style.display === "flex") _newsRender(); |
| }); |
| } |
| function _newsReactStart() { |
| if (_newsReactUnsub || !window._dobRPC) return; |
| _newsReactUnsub = window._dobRPC.on("newsReactions", d => { |
| _newsReactData = d || {}; |
| const ov = document.getElementById("newsOverlay"); |
| if (ov && ov.style.display === "flex") _newsRender(); |
| }); |
| } |
| async function newsReact(newsId, value) { |
| const me = _newsMyUid(); |
| const cur = me && _newsReactData[newsId] ? _newsReactData[newsId][me] : null; |
| try { await window._dobRPC.newsReact(newsId, cur === value ? null : value); } |
| catch (e) { alert("Reaction failed: " + e.message); } |
| } |
| function _newsReactBar(n) { |
| const bar = document.createElement("div"); bar.className = "newsReact"; |
| const r = _newsReactData[n.id] || {}, me = _newsMyUid(), mine = me ? r[me] : null; |
| let up = 0, down = 0; |
| Object.values(r).forEach(v => { if (v === "up") up++; else if (v === "down") down++; }); |
| [["up", "✅", up], ["down", "❌", down]].forEach(([val, icon, cnt]) => { |
| const b = document.createElement("button"); |
| b.className = val + (mine === val ? " on" : ""); |
| b.textContent = icon + " " + cnt; |
| b.onclick = () => newsReact(n.id, val); |
| bar.appendChild(b); |
| }); |
| return bar; |
| } |
| if (window._dobRPC) { _newsStart(); _newsReactStart(); } else window.addEventListener("dobRPCReady", () => { _newsStart(); _newsReactStart(); }); |
| |
| function _newsRender() { |
| const list = document.getElementById("newsList"); if (!list) return; |
| const _st = list.scrollTop; |
| list.textContent = ""; |
| const items = _newsItems(), admin = _newsIsAdmin(); |
| if (!items.length) { |
| const e = document.createElement("div"); e.style.color = "#8b93a6"; e.textContent = "No news yet."; list.appendChild(e); |
| } |
| items.forEach(n => { |
| const d = document.createElement("div"); d.className = "newsItem"; |
| if (n.title) { const t = document.createElement("div"); t.className = "nTitle"; t.textContent = n.title; d.appendChild(t); } |
| const dt = document.createElement("div"); dt.className = "nDate"; dt.textContent = n.ts ? new Date(n.ts).toLocaleString() : ""; d.appendChild(dt); |
| const tx = document.createElement("div"); tx.className = "nText"; tx.textContent = n.text; d.appendChild(tx); |
| d.appendChild(_newsReactBar(n)); |
| if (admin) { |
| const del = document.createElement("button"); |
| del.textContent = "🗑 Delete"; del.style.cssText = "margin:6px 0 0;padding:4px 10px;font-size:12px;"; |
| del.onclick = async () => { if (!confirm("Delete this post?")) return; try { await window._dobRPC.newsRemove(n.id); } catch (e) { alert("Failed: " + e.message); } }; |
| d.appendChild(del); |
| } |
| list.appendChild(d); |
| }); |
| list.scrollTop = _st; |
| const ed = document.getElementById("newsEditor"), hint = document.getElementById("newsUidHint"); |
| ed.style.display = admin ? "block" : "none"; |
| const me = _newsMyUid(); |
| |
| const isDevName = typeof onlineAccount !== "undefined" && onlineAccount && onlineAccount.name === "DarkOnCrack"; |
| if (!NEWS_ADMIN_UID && me && isDevName) { hint.style.display = "block"; hint.textContent = "Setup: set NEWS_ADMIN_UID = \"" + me + "\" in the code."; } |
| else hint.style.display = "none"; |
| } |
| |
| function openNews() { |
| let ov = document.getElementById("newsOverlay"); |
| if (!ov) { |
| ov = document.createElement("div"); ov.id = "newsOverlay"; |
| ov.innerHTML = '<div id="newsBox"><div style="display:flex;justify-content:space-between;align-items:center;"><b style="font-size:18px;">📰 News</b><button onclick="closeNews()" style="margin:0;padding:4px 10px;font-size:14px;">✕</button></div>' |
| + '<div id="newsList"></div>' |
| + '<div id="newsUidHint" style="display:none;font-size:11px;color:#ffd700;word-break:break-all;margin-bottom:6px;"></div>' |
| + '<div id="newsEditor" style="display:none;border-top:1px solid rgba(255,255,255,0.1);padding-top:10px;">' |
| + '<input type="text" id="newsTitleIn" maxlength="80" placeholder="Title (optional)" style="width:100%;box-sizing:border-box;margin:0 0 6px;padding:8px;font-size:13px;">' |
| + '<textarea id="newsTextIn" maxlength="2000" rows="4" placeholder="Write an update…" style="width:100%;box-sizing:border-box;margin:0 0 6px;padding:8px;font-size:13px;resize:vertical;background:rgba(255,255,255,0.05);color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:8px;"></textarea>' |
| + '<button onclick="publishNews()" style="margin:0;padding:8px 16px;font-size:13px;">Publish</button></div></div>'; |
| ov.addEventListener("click", e => { if (e.target === ov) closeNews(); }); |
| document.body.appendChild(ov); |
| } |
| ov.style.display = "flex"; |
| _newsStart(); _newsReactStart(); _newsRender(); |
| const items = _newsItems(); |
| try { if (items.length) localStorage.setItem("dob_news_seen", String(items[0].ts || 0)); } catch (e) {} |
| _newsUpdateDot(); |
| } |
| function closeNews() { const ov = document.getElementById("newsOverlay"); if (ov) ov.style.display = "none"; } |
| async function publishNews() { |
| if (!_newsIsAdmin()) return; |
| const t = document.getElementById("newsTitleIn"), x = document.getElementById("newsTextIn"); |
| const text = x.value.trim(); if (!text) return; |
| try { |
| await window._dobRPC.newsPush({ title: t.value.trim(), text, ts: Date.now() }); |
| t.value = ""; x.value = ""; |
| } catch (e) { alert("Failed (Database Rules gesetzt?): " + e.message); } |
| } |
| </script> |
| <script> |
| |
| |
| |
| |
| |
| |
| const MOD_ADMIN_UID = "6O1c9OKTRlbwzDlPuBG2ceMRdz63"; |
| const MOD_DEFAULT_TEXT = "A Dev/Mod has checked your account. You have to change your username or delete that account. If you don't do so within the next 24 hours, your account will be banned permanently!"; |
| let _modState = null, _modUnsub = null, _modUid = null, _modSnoozeUntil = 0, _modMode = null; |
| |
| function _modMyUid() { try { return (window.dobMyUid && window.dobMyUid()) || null; } catch (e) { return null; } } |
| function _modIsAdmin() { return _modMyUid() === MOD_ADMIN_UID; } |
| function _modCurName() { return (typeof onlineAccount !== "undefined" && onlineAccount && onlineAccount.name) ? String(onlineAccount.name) : ""; } |
| function _modRPC() { return window._dobRPC; } |
| function _modFmtLeft(ms) { |
| if (ms < 0) ms = 0; |
| const s = Math.floor(ms / 1000), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; |
| return h + "h " + String(m).padStart(2, "0") + "m " + String(x).padStart(2, "0") + "s"; |
| } |
| |
| |
| function _modWatch() { |
| const uid = _modMyUid(); |
| if (uid === _modUid && (_modUnsub || !uid)) return; |
| if (_modUnsub) { try { _modUnsub(); } catch (e) {} _modUnsub = null; } |
| _modUid = uid; _modState = null; |
| if (uid && _modRPC()) _modUnsub = _modRPC().on("sanctions/" + uid, d => { _modState = d || null; _modRender(); }); |
| _modRender(); |
| } |
| window.addEventListener("dobAuthReady", _modWatch); |
| window.addEventListener("dobRPCReady", _modWatch); |
| |
| |
| function _modApplyRename() { |
| const r = _modState && _modState.rename; |
| if (!r || !r.ts || !r.name || typeof onlineAccount === "undefined" || !onlineAccount || !_modUid) return; |
| let last = 0; try { last = Number(localStorage.getItem("dob_mod_rn_" + _modUid)) || 0; } catch (e) {} |
| if (r.ts <= last) return; |
| try { localStorage.setItem("dob_mod_rn_" + _modUid, String(r.ts)); } catch (e) {} |
| onlineAccount.name = r.name; |
| try { saveAccountLocal(); } catch (e) {} |
| try { pushAccountToFirebase().catch(() => {}); } catch (e) {} |
| } |
| |
| |
| function _modWarnActive(w) { |
| const n = _modCurName().toLowerCase(); |
| return !!w && !!n && (!w.forName || n === String(w.forName).toLowerCase()); |
| } |
| function _modBanInfo() { |
| const s = _modState || {}; |
| if (s.banned) return { reason: s.banReason || "" }; |
| const w = s.warning; |
| if (w && w.deadline && Date.now() > w.deadline && _modWarnActive(w)) return { reason: "You did not change your username in time." }; |
| return null; |
| } |
| function _modWarnInfo() { |
| const s = _modState || {}, w = s.warning; |
| if (w && _modWarnActive(w)) return { text: w.text || MOD_DEFAULT_TEXT, deadline: w.deadline || 0 }; |
| const n = _modCurName(); |
| if (!_modIsAdmin() && n && typeof nameIsBlocked === "function" && nameIsBlocked(n)) |
| return { text: "Your username is not allowed. Please choose a different one or delete your account.", deadline: 0 }; |
| return null; |
| } |
| function _modChatBlock() { |
| const s = _modState || {}; |
| if (s.banned) return "You are banned."; |
| if (s.warning && _modWarnActive(s.warning)) return "Locked: moderator notice."; |
| if (s.chatBanned) return "You are banned from the chat."; |
| if (s.muteUntil && s.muteUntil > Date.now()) return "Muted until " + new Date(s.muteUntil).toLocaleString(); |
| return ""; |
| } |
| function _modChatLock() { |
| const inp = document.getElementById("chatInput"), btn = document.getElementById("chatSendBtn"); |
| if (!inp) return; |
| const b = _modChatBlock(); |
| if (b) { |
| if (inp._modPh === undefined) inp._modPh = inp.placeholder; |
| inp.disabled = true; if (btn) btn.disabled = true; inp.placeholder = b; inp._modLocked = true; |
| } else if (inp._modLocked) { |
| inp._modLocked = false; inp.placeholder = inp._modPh || ""; if (btn) btn.disabled = false; |
| if (typeof updateChatLock === "function") updateChatLock(); else inp.disabled = false; |
| } |
| } |
| |
| |
| ["keydown", "keyup"].forEach(t => window.addEventListener(t, e => { |
| if (_modMode && !(e.target && e.target.closest && e.target.closest("#modOverlay"))) { e.stopImmediatePropagation(); e.preventDefault(); } |
| }, true)); |
| |
| |
| function _modEnsureOv() { |
| let ov = document.getElementById("modOverlay"); |
| if (!ov) { |
| ov = document.createElement("div"); ov.id = "modOverlay"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:30000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.9);padding:16px;box-sizing:border-box;"; |
| document.body.appendChild(ov); |
| } |
| return ov; |
| } |
| function _modBuild(ov, mode) { |
| const box = 'width:min(560px,94vw);background:#0d0f16;border-radius:16px;padding:26px;text-align:center;color:#eaf2fb;box-shadow:0 0 60px rgba(255,68,68,0.35);'; |
| if (mode === "ban") { |
| ov.innerHTML = '<div style="' + box + 'border:2px solid #ff4444;">' |
| + '<div style="font-size:54px;">🔨</div>' |
| + '<div style="font-size:26px;font-weight:800;color:#ff4444;margin:6px 0 10px;">Your account has been banned</div>' |
| + '<div id="modBanReason" style="font-size:15px;margin-bottom:18px;word-break:break-word;"></div>' |
| + '<button id="modLogoutBtn" style="margin:0;padding:10px 22px;font-size:15px;">Log out</button></div>'; |
| ov.querySelector("#modLogoutBtn").onclick = async () => { |
| try { await window._dobAuth.logout(); } catch (e) {} |
| try { localStorage.removeItem("dobAccount"); } catch (e) {} |
| location.reload(); |
| }; |
| } else { |
| ov.innerHTML = '<div style="' + box + 'border:2px solid #ffd700;">' |
| + '<div style="font-size:54px;">⚠️</div>' |
| + '<div style="font-size:26px;font-weight:800;color:#ffd700;margin:6px 0 10px;">Moderator notice</div>' |
| + '<div id="modWarnText" style="font-size:16px;line-height:1.5;margin-bottom:12px;word-break:break-word;"></div>' |
| + '<div id="modCount" style="font-size:20px;font-weight:700;color:#ff6b6b;margin-bottom:14px;"></div>' |
| + '<input id="modNewName" type="text" maxlength="20" placeholder="New username" style="width:100%;box-sizing:border-box;margin:0 0 8px;padding:10px;font-size:15px;">' |
| + '<div id="modMsg" style="min-height:18px;font-size:13px;color:#ff8a8a;margin-bottom:8px;"></div>' |
| + '<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">' |
| + '<button id="modRenameBtn" style="margin:0;padding:10px 20px;font-size:15px;">Change username</button>' |
| + '<button id="modDeleteBtn" style="margin:0;padding:10px 20px;font-size:15px;">Delete my account</button></div>' |
| + '<div id="modDelRow" style="display:none;margin-top:14px;"><input id="modDelPw" type="password" placeholder="Password" style="width:100%;box-sizing:border-box;margin:0 0 8px;padding:10px;font-size:15px;">' |
| + '<button id="modDelGo" style="margin:0;padding:10px 20px;font-size:15px;background:#a22;border-color:#f44;color:#fff;">Delete permanently</button></div></div>'; |
| ov.querySelector("#modRenameBtn").onclick = _modDoRename; |
| ov.querySelector("#modNewName").addEventListener("keydown", e => { if (e.key === "Enter") _modDoRename(); }); |
| ov.querySelector("#modDeleteBtn").onclick = () => { |
| ov.querySelector("#modDelRow").style.display = "block"; |
| ov.querySelector("#modDelPw").focus(); |
| }; |
| ov.querySelector("#modDelGo").onclick = _modDoDelete; |
| } |
| } |
| async function _modDoDelete() { |
| const pw = (document.getElementById("modDelPw") || {}).value, msg = document.getElementById("modMsg"); |
| if (!msg) return; |
| if (!pw) { msg.textContent = "Enter your password."; return; } |
| msg.textContent = "Deleting…"; |
| try { |
| showDeleteAccountScreen(); |
| document.getElementById("deletePassword").value = pw; |
| await deleteAccount(); |
| const i = document.getElementById("accountInfo"); |
| if (i && i.textContent) msg.textContent = i.textContent; |
| } catch (e) { msg.textContent = "Error: " + (e && e.message); } |
| } |
| async function _modDoRename() { |
| const inp = document.getElementById("modNewName"), msg = document.getElementById("modMsg"); |
| if (!inp || !msg) return; |
| const v = inp.value.trim(); |
| if (v.length < 2 || !/^[a-zA-Z0-9_-]+$/.test(v)) { msg.textContent = "At least 2 characters; only letters, numbers, _ and -."; return; } |
| if (typeof nameIsBlocked === "function" && nameIsBlocked(v)) { msg.textContent = "This name is not allowed."; return; } |
| if (v.toLowerCase() === _modCurName().toLowerCase()) { msg.textContent = "Please choose a different name."; return; } |
| msg.textContent = "Saving…"; |
| try { await changeUsername(v); } catch (e) { msg.textContent = "Error: " + (e && e.message); return; } |
| if (_modCurName() === v) { msg.textContent = ""; inp.value = ""; _modRender(); } |
| else msg.textContent = "Name could not be changed (already taken?)."; |
| } |
| function _modRender() { |
| _modApplyRename(); |
| _modChatLock(); |
| const ov = _modEnsureOv(); |
| const ban = _modBanInfo(), warn = ban ? null : _modWarnInfo(); |
| window.__dobBanned = !!ban; |
| const mode = ban ? "ban" : (warn && Date.now() > _modSnoozeUntil ? "warn" : null); |
| if (!mode) { ov.style.display = "none"; _modMode = null; return; } |
| if (mode !== _modMode) { |
| _modBuild(ov, mode); _modMode = mode; |
| try { cleanupOnline(); } catch (e) {} |
| try { showScreen("menu"); } catch (e) {} |
| } |
| ov.style.display = "flex"; |
| if (mode === "ban") ov.querySelector("#modBanReason").textContent = ban.reason ? "Reason: " + ban.reason : ""; |
| else { |
| ov.querySelector("#modWarnText").textContent = warn.text; |
| ov.querySelector("#modCount").textContent = warn.deadline ? "Time left: " + _modFmtLeft(warn.deadline - Date.now()) : ""; |
| } |
| } |
| setInterval(_modRender, 1000); |
| setInterval(() => { _modWatch(); _modAdminBtn(); _dailyAdminBtn(); }, 3000); |
| if (_modRPC()) _modWatch(); |
| |
| |
| function _modAdminBtn() { |
| const ex = document.getElementById("modBtn"); |
| if (!_modIsAdmin()) { if (ex) ex.remove(); return; } |
| if (ex) return; |
| const nb = document.getElementById("newsBtn"); if (!nb) return; |
| const b = document.createElement("button"); b.id = "modBtn"; b.textContent = "🛡 Mod"; |
| b.style.cssText = "position:absolute;top:104px;right:16px;margin:0;padding:8px 16px;font-size:14px;z-index:5;"; |
| b.onclick = () => openModPanel(); |
| nb.insertAdjacentElement("afterend", b); |
| } |
| function _dailyAdminBtn() { |
| const ex = document.getElementById("dailyAdminBtn"); |
| if (!_modIsAdmin()) { if (ex) ex.remove(); return; } |
| if (ex) return; |
| const mb = document.getElementById("modBtn"); if (!mb) return; |
| const b = document.createElement("button"); b.id = "dailyAdminBtn"; b.textContent = "📅 Daily Admin"; |
| b.style.cssText = "position:absolute;top:146px;right:16px;margin:0;padding:8px 16px;font-size:14px;z-index:5;"; |
| b.onclick = () => openDailyAdmin(); |
| mb.insertAdjacentElement("afterend", b); |
| } |
| |
| let _modPlayers = {}, _modSanc = {}; |
| |
| function closeDailyAdmin() { const ov = document.getElementById("dailyAdminOv"); if (ov) ov.style.display = "none"; } |
| async function openDailyAdmin() { |
| if (!_modIsAdmin()) return; |
| await dailyPicksRefresh(); |
| let ov = document.getElementById("dailyAdminOv"); |
| if (!ov) { |
| ov = document.createElement("div"); ov.id = "dailyAdminOv"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:15000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.75);"; |
| ov.addEventListener("click", e => { if (e.target === ov) closeDailyAdmin(); }); |
| document.body.appendChild(ov); |
| } |
| ov.style.display = "flex"; |
| const today = dailyKeyNow(), days = []; |
| for (let i = 0; i < 8; i++) days.push(_dayAdd(today, i)); |
| const optHtml = '<option value="">🎲 Random (not in last 14 days)</option>' + DAILY_CHALLENGES.map((c, i) => '<option value="' + i + '">' + c.icon + ' ' + escapeHtml(c.name) + '</option>').join(""); |
| let h = '<div style="width:min(620px,95vw);max-height:88vh;overflow-y:auto;background:#0d0f16;border:1px solid rgba(255,255,255,0.12);border-top:3px solid #ffd700;border-radius:14px;padding:16px;color:#e6e9f2;text-align:left;">' |
| + '<div style="display:flex;justify-content:space-between;align-items:center;"><b style="font-size:18px;">📅 Daily Modes (admin)</b><button onclick="closeDailyAdmin()" style="margin:0;padding:4px 10px;font-size:14px;">✕</button></div>' |
| + '<div style="font-size:12px;color:#8b93a6;margin:8px 0;">Pick a mode per day. "Random" = automatic. Choose a day in the past 14 days only if you want it to count as played.</div>'; |
| days.forEach(d => { |
| const set_ = Number.isInteger(_dailyPicks[d]), cur = dailyChallengeFor(d); |
| h += '<div style="display:flex;gap:8px;align-items:center;margin:6px 0;"><span style="width:90px;font-size:13px;">' + d + (d === today ? ' ⭐' : '') + '</span>' |
| + '<select data-day="' + d + '" onchange="dailyAdminSet(this)" style="flex:1;margin:0;padding:6px;font-size:13px;">' + optHtml.replace('value="' + (set_ ? _dailyPicks[d] : '') + '"', 'value="' + (set_ ? _dailyPicks[d] : '') + '" selected') + '</select>' |
| + '<span style="font-size:12px;color:#ffd700;min-width:150px;">→ ' + cur.icon + ' ' + escapeHtml(cur.name) + '</span></div>'; |
| }); |
| h += '<hr style="border:0;border-top:1px solid rgba(255,255,255,0.1);margin:14px 0;"><b>🧪 Test any mode (only you, never saved)</b><div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;">' |
| + DAILY_CHALLENGES.map((c, i) => '<button onclick="dailyAdminTest(' + i + ')" style="margin:0;padding:6px 10px;font-size:13px;">' + c.icon + ' ' + escapeHtml(c.name) + '</button>').join("") |
| + '</div></div>'; |
| ov.innerHTML = h; |
| } |
| async function dailyAdminSet(sel) { |
| if (!_modIsAdmin()) return; |
| try { await _modRPC().modSetDailyPick(sel.dataset.day, sel.value === "" ? null : Number(sel.value)); await openDailyAdmin(); } |
| catch (e) { alert("Failed: " + (e && e.message) + " (Database Rules for dailyPick set?)"); } |
| } |
| function dailyAdminTest(i) { if (!_modIsAdmin()) return; closeDailyAdmin(); startSurvival(true, i); } |
| function closeModPanel() { const ov = document.getElementById("modPanelOv"); if (ov) ov.style.display = "none"; } |
| async function openModPanel(prefill) { |
| if (!_modIsAdmin()) return; |
| let ov = document.getElementById("modPanelOv"); |
| if (!ov) { |
| ov = document.createElement("div"); ov.id = "modPanelOv"; |
| ov.style.cssText = "position:fixed;inset:0;z-index:15000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.75);"; |
| ov.innerHTML = '<div style="width:min(760px,95vw);max-height:88vh;display:flex;flex-direction:column;background:#0d0f16;border:1px solid rgba(255,255,255,0.12);border-top:3px solid #ff4d6d;border-radius:14px;padding:16px;color:#e6e9f2;text-align:left;">' |
| + '<div style="display:flex;justify-content:space-between;align-items:center;"><b style="font-size:18px;">🛡 Mod Panel</b><button id="modPanelX" style="margin:0;padding:4px 10px;font-size:14px;">✕</button></div>' |
| + '<div style="display:flex;gap:8px;margin:10px 0;"><input id="modSearch" type="text" placeholder="Search name or UID…" style="flex:1;margin:0;padding:8px;font-size:13px;"><button id="modScanBtn" style="margin:0;padding:8px 12px;font-size:13px;">🔍 Scan for cheaters</button><button id="modReload" style="margin:0;padding:6px 12px;font-size:13px;">↻</button></div>' |
| + '<div id="modList" style="overflow-y:auto;display:flex;flex-direction:column;gap:6px;"></div></div>'; |
| ov.addEventListener("click", e => { if (e.target === ov) closeModPanel(); }); |
| document.body.appendChild(ov); |
| ov.querySelector("#modPanelX").onclick = closeModPanel; |
| ov.querySelector("#modReload").onclick = _modPanelLoad; |
| ov.querySelector("#modScanBtn").onclick = _modScanBtn; |
| ov.querySelector("#modSearch").addEventListener("input", _modPanelRender); |
| } |
| ov.style.display = "flex"; |
| if (typeof prefill === "string") ov.querySelector("#modSearch").value = prefill; |
| await _modPanelLoad(); |
| } |
| async function _modPanelLoad() { |
| const list = document.getElementById("modList"); if (!list) return; |
| try { |
| const [p, s] = await Promise.all([_modRPC().get("players"), _modRPC().get("sanctions")]); |
| _modPlayers = p || {}; _modSanc = s || {}; |
| _modPanelRender(); |
| } catch (e) { list.textContent = "Failed to load: " + (e && e.message) + " (Database Rules updated?)"; } |
| } |
| function _modBadges(uid, p) { |
| const s = _modSanc[uid] || {}, out = [], now = Date.now(); |
| if (s.banned) out.push("🔨 BANNED"); |
| if (s.chatBanned) out.push("🚫 chat ban"); |
| if (s.muteUntil && s.muteUntil > now) out.push("🔇 muted until " + new Date(s.muteUntil).toLocaleString()); |
| if (s.warning) { |
| const same = p && s.warning.forName && String(p.name).toLowerCase() === String(s.warning.forName).toLowerCase(); |
| if (same && s.warning.deadline && s.warning.deadline < now) out.push("⏰ WARNING OVERDUE"); |
| else if (same) out.push("⚠ warned, " + _modFmtLeft(s.warning.deadline - now) + " left"); |
| else out.push("✔ warning resolved"); |
| } |
| return out; |
| } |
| function _modPanelRender() { |
| const list = document.getElementById("modList"); if (!list) return; |
| const q = (document.getElementById("modSearch").value || "").trim().toLowerCase(); |
| const rows = Object.entries(_modPlayers).filter(([uid, p]) => p && p.name && uid !== "bot__online_bot" |
| && (!q || String(p.name).toLowerCase().includes(q) || uid.toLowerCase().includes(q))) |
| .sort((a, b) => (_modBadges(b[0], b[1]).length - _modBadges(a[0], a[1]).length) || String(a[1].name).localeCompare(String(b[1].name))); |
| list.textContent = ""; |
| if (!rows.length) { list.textContent = "No players found."; return; } |
| rows.slice(0, 80).forEach(([uid, p]) => { |
| const s = _modSanc[uid] || {}; |
| const row = document.createElement("div"); |
| row.style.cssText = "border:1px solid rgba(255,255,255,0.1);border-radius:10px;padding:8px 10px;"; |
| const top = document.createElement("div"); |
| const nm = document.createElement("b"); nm.textContent = p.name; nm.setAttribute("translate", "no"); |
| const meta = document.createElement("span"); meta.style.cssText = "font-size:11px;color:#8b93a6;margin-left:8px;"; meta.textContent = "Elo " + (p.elo ?? "?") + " · " + uid; |
| top.append(nm, meta); row.appendChild(top); |
| const bd = _modBadges(uid, p); |
| if (bd.length) { const b = document.createElement("div"); b.style.cssText = "font-size:12px;color:#ffd700;margin-top:3px;"; b.textContent = bd.join(" · "); row.appendChild(b); } |
| const bar = document.createElement("div"); bar.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;"; |
| const mk = (label, fn) => { const x = document.createElement("button"); x.textContent = label; x.style.cssText = "margin:0;padding:4px 9px;font-size:12px;"; x.onclick = () => _modAct(fn); bar.appendChild(x); }; |
| mk("⚠ Warn", () => _modWarn(uid, p)); |
| mk("✏ Rename", () => _modRenameAct(uid, p)); |
| mk("🔇 Mute", () => _modMute(uid)); |
| mk(s.chatBanned ? "💬 Allow chat" : "🚫 Chat ban", () => _modRPC().modSetSanction(uid, { chatBanned: s.chatBanned ? null : true })); |
| mk(s.banned ? "♻ Unban" : "🔨 Ban + wipe data", () => _modBan(uid, s, p)); |
| if (s.banned) mk("🧹 Wipe data again", () => _modPurge(uid, p.name)); |
| mk("🧹 Clear all", () => _modRPC().modClearSanction(uid)); |
| row.appendChild(bar); list.appendChild(row); |
| }); |
| } |
| async function _modAct(fn) { |
| try { await fn(); await _modPanelLoad(); } |
| catch (e) { if (e && e.message === "__cancel") return; alert("Failed: " + (e && e.message) + "\n(Database Rules updated?)"); } |
| } |
| const _modCancel = () => { throw new Error("__cancel"); }; |
| async function _modWarn(uid, p) { |
| const text = prompt("Message to show to " + p.name + " (empty = default text):", ""); if (text === null) _modCancel(); |
| const hrs = Number(prompt("Deadline in hours:", "24")); if (!(hrs > 0)) _modCancel(); |
| await _modRPC().modSetSanction(uid, { warning: { text: text.trim(), deadline: Date.now() + hrs * 3600000, forName: p.name, ts: Date.now() } }); |
| } |
| async function _modRenameAct(uid, p) { |
| const nn = (prompt("New name for " + p.name + ":", "") || "").trim(); if (!nn) _modCancel(); |
| if (!/^[a-zA-Z0-9_ -]{2,20}$/.test(nn)) { alert("2–20 characters: letters, numbers, space, _ and -"); _modCancel(); } |
| if (typeof nameIsBlocked === "function" && nameIsBlocked(nn)) { alert("That name is on the block list."); _modCancel(); } |
| const taken = await _modRPC().get("usernames/" + nn.toLowerCase()); |
| if (taken && taken !== uid) { alert("Name already taken."); _modCancel(); } |
| await _modRPC().modRename(uid, p.name, nn); |
| await _modRPC().modSetSanction(uid, { rename: { name: nn, ts: Date.now() } }); |
| try { if (p.leagueWeek && p.leagueGroup) await _modRPC().renameLeagueEntry(p.leagueWeek, p.leagueTier || 0, p.leagueGroup, uid, nn); } catch (e) { console.warn("league rename failed", e); } |
| try { await _modRPC().renameChatMessages(uid, nn); } catch (e) { console.warn("chat rename failed", e); } |
| } |
| async function _modMute(uid) { |
| const m = Number(prompt("Mute for how many minutes? (0 = unmute)", "60")); if (isNaN(m) || m < 0) _modCancel(); |
| await _modRPC().modSetSanction(uid, { muteUntil: m > 0 ? Date.now() + m * 60000 : null }); |
| } |
| async function _modPurge(uid, name) { |
| const r = await _modRPC().modPurgeUser(uid, name); |
| if (r && r.failed && r.failed.length) alert("Could not delete " + r.failed.length + " of " + r.total + " entries (Database Rules must allow the admin to delete them):\n" + r.failed.slice(0, 12).join("\n")); |
| return r; |
| } |
| async function _modBan(uid, s, p) { |
| if (s.banned) { |
| await _modRPC().modSetSanction(uid, { banned: null, banReason: null, autoBan: null }); |
| if (s.backup && confirm("Restore this player's backed-up data (profile, survival record, Streaks)?")) { await _modRPC().modRestoreUser(uid, s.backup); await _modRPC().modSetSanction(uid, { backup: null }); } |
| return; |
| } |
| const nm = (p && p.name) || uid; |
| const reason = prompt("Ban reason (shown to the player):", "Inappropriate username"); if (reason === null) _modCancel(); |
| if (!confirm("Ban " + nm + " and DELETE all their data (leaderboards, league, survival, Streaks, chat)?")) _modCancel(); |
| try { |
| const [pl, sv, sk] = await Promise.all([_modRPC().get("players/" + uid), _modRPC().get("survival/" + uid), _modRPC().get("streakBoard/" + uid)]); |
| await _modRPC().modSetSanction(uid, { backup: { player: pl || null, survival: sv || null, streak: sk || null } }); |
| } catch (e) { console.warn("backup failed", e); } |
| await _modRPC().modSetSanction(uid, { banned: true, banReason: reason.trim(), bannedAt: Date.now() }); |
| await _modPurge(uid, nm); |
| await new Promise(r => setTimeout(r, 4000)); |
| await _modPurge(uid, nm); |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| const CHEAT_MAX_ELO_PER_WIN = 50, CHEAT_START_ELO = 1500, CHEAT_MAX_STREAK_LEVEL = 1000, CHEAT_REPLAY_TOL_S = 2, CHEAT_CONFIRM_MS = 30000; |
| let _cheatPending = {}, _cheatScanning = false, _cheatIv = null; |
| function _cheatPlayerEvidence(p) { |
| const out = [], num = v => typeof v === "number" && isFinite(v); |
| if (!num(p.elo)) out.push("ELO is not a valid number"); |
| else { |
| if (p.elo > 5000) out.push("ELO " + p.elo + " > 5000"); |
| if (p.elo < 100) out.push("ELO " + p.elo + " < 100"); |
| } |
| const wOk = num(p.wins) && p.wins >= 0 && Number.isInteger(p.wins), lOk = num(p.losses) && p.losses >= 0 && Number.isInteger(p.losses); |
| if (p.wins !== undefined && !wOk) out.push("invalid wins: " + p.wins); |
| if (p.losses !== undefined && !lOk) out.push("invalid losses: " + p.losses); |
| if (num(p.elo) && wOk && p.elo > CHEAT_START_ELO + CHEAT_MAX_ELO_PER_WIN * p.wins) out.push("ELO " + Math.round(p.elo) + " impossible with " + p.wins + " wins (max " + (CHEAT_START_ELO + CHEAT_MAX_ELO_PER_WIN * p.wins) + ")"); |
| return out; |
| } |
| async function _cheatCollect() { |
| const R = _modRPC(), found = {}; |
| const add = (uid, name, why) => { (found[uid] = found[uid] || { name: name || uid, why: [] }).why.push(why); }; |
| const [players, survival, streakB, sanc] = await Promise.all([R.get("players"), R.get("survival"), R.get("streakBoard"), R.get("sanctions")]); |
| const skip = uid => uid === MOD_ADMIN_UID || uid === "bot__online_bot" || String(uid).indexOf("bot__") === 0 || (sanc && sanc[uid] && sanc[uid].banned); |
| Object.entries(players || {}).forEach(([uid, p]) => { if (!p || skip(uid)) return; _cheatPlayerEvidence(p).forEach(w => add(uid, p.name, w)); }); |
| Object.entries(streakB || {}).forEach(([uid, r]) => { |
| if (!r || skip(uid)) return; |
| const L = r.level; |
| if (!(typeof L === "number" && isFinite(L) && Number.isInteger(L) && L >= 1 && L <= CHEAT_MAX_STREAK_LEVEL)) add(uid, r.name || (players && players[uid] && players[uid].name), "impossible Streaks level: " + L); |
| }); |
| |
| const sv = Object.entries(survival || {}).filter(([uid, r]) => r && !skip(uid)); |
| for (let i = 0; i < sv.length; i += 5) { |
| await Promise.all(sv.slice(i, i + 5).map(async ([uid, r]) => { |
| const t = r.time; |
| if (!(typeof t === "number" && isFinite(t) && t > 0)) { add(uid, r.name, "invalid survival time: " + t); return; } |
| let rec = null; |
| try { const raw = await R.get("survivalReplays/" + uid); rec = raw && JSON.parse(raw); } catch (e) { return; } |
| if (!rec || typeof rec.n !== "number" || !isFinite(rec.n)) return; |
| const rt = rec.n / 60; |
| if (Math.abs(rt - t) > CHEAT_REPLAY_TOL_S) add(uid, r.name, "survival time " + t.toFixed(1) + "s does not match replay (" + rt.toFixed(1) + "s)"); |
| })); |
| } |
| return found; |
| } |
| async function _cheatBanOne(uid, name, why) { |
| const R = _modRPC(); |
| const [pl, sv, sk] = await Promise.all([R.get("players/" + uid), R.get("survival/" + uid), R.get("streakBoard/" + uid)]); |
| await R.modSetSanction(uid, { backup: { player: pl || null, survival: sv || null, streak: sk || null }, autoBan: { why: why, ts: Date.now() } }); |
| await R.modSetSanction(uid, { banned: true, banReason: "Cheating detected (automatic).", bannedAt: Date.now() }); |
| await _modPurge(uid, name); |
| await new Promise(r => setTimeout(r, 4000)); |
| await _modPurge(uid, name); |
| } |
| async function _cheatScan(manual) { |
| if (!_modIsAdmin() || !_modRPC() || _cheatScanning) return null; |
| _cheatScanning = true; |
| const report = { banned: [], pending: [], clean: true }; |
| try { |
| const found = await _cheatCollect(), now = Date.now(); |
| for (const uid of Object.keys(_cheatPending)) if (!found[uid]) delete _cheatPending[uid]; |
| for (const [uid, f] of Object.entries(found)) { |
| report.clean = false; |
| const sig = f.why.slice().sort().join(" | "), pend = _cheatPending[uid]; |
| if (pend && pend.sig === sig && now - pend.t >= CHEAT_CONFIRM_MS) { |
| try { await _cheatBanOne(uid, f.name, f.why); report.banned.push({ uid, name: f.name, why: f.why }); delete _cheatPending[uid]; } |
| catch (e) { console.warn("auto-ban failed", uid, e); } |
| } else { |
| if (!pend || pend.sig !== sig) _cheatPending[uid] = { sig, t: now }; |
| report.pending.push({ uid, name: f.name, why: f.why }); |
| } |
| } |
| } catch (e) { console.warn("cheat scan failed", e); report.error = e && e.message; } |
| _cheatScanning = false; |
| return report; |
| } |
| async function _cheatScanTwice() { |
| let r = await _cheatScan(true); |
| if (r && r.pending.length) { await new Promise(x => setTimeout(x, CHEAT_CONFIRM_MS + 1000)); r = await _cheatScan(true); } |
| return r; |
| } |
| function _cheatStartAuto() { |
| if (_cheatIv || !_modIsAdmin()) return; |
| setTimeout(() => _cheatScan(false), 20000); |
| _cheatIv = setInterval(() => _cheatScan(false), 5 * 60 * 1000); |
| } |
| window.addEventListener("dobAuthReady", () => setTimeout(_cheatStartAuto, 1500)); |
| window.addEventListener("dobRPCReady", () => setTimeout(_cheatStartAuto, 1500)); |
| async function _modScanBtn() { |
| const btn = document.getElementById("modScanBtn"); if (btn) { btn.disabled = true; btn.textContent = "Scanning… (~30 s)"; } |
| const r = await _cheatScanTwice(); |
| if (btn) { btn.disabled = false; btn.textContent = "🔍 Scan for cheaters"; } |
| if (!r) { alert("Scan not possible (admin only / already running)."); return; } |
| if (r.error) { alert("Scan failed: " + r.error); return; } |
| let msg = r.clean ? "No cheaters found." : ""; |
| if (r.banned.length) msg += "Banned + wiped:\n" + r.banned.map(b => "• " + b.name + ": " + b.why.join("; ")).join("\n") + "\n\n"; |
| if (r.pending.length) msg += "Suspicious but not confirmed (not banned):\n" + r.pending.map(b => "• " + b.name + ": " + b.why.join("; ")).join("\n"); |
| alert(msg.trim()); await _modPanelLoad(); |
| } |
| |
| |
| |
| let _lgAdminBusy = false; |
| async function leagueAdminResolveAll() { |
| if (!_modIsAdmin() || !_modRPC() || _lgAdminBusy) return 0; |
| if (Date.now() < leagueWeekStart() + LEAGUE_GRACE_MS + 30000) return 0; |
| _lgAdminBusy = true; |
| let applied = 0; |
| try { |
| const R = _modRPC(), cur = leagueWeekId(), me = _modMyUid(); |
| const [wl, players] = await Promise.all([R.get("weeklyLeague"), R.get("players")]); |
| const up = {}; |
| for (const [week, tiers] of Object.entries(wl || {})) { |
| if (!(week < cur)) continue; |
| for (const [t, groups] of Object.entries(tiers || {})) { |
| const tier = Number(t); |
| for (const [gid, members] of Object.entries(groups || {})) { |
| const list = leagueSortMembers(Object.values(members || {})), size = list.length; |
| list.forEach((m, idx) => { |
| const p = players && players[m.uid]; |
| if (!p || m.uid === me) return; |
| if (p.leagueWeek !== week || p.leagueGroup !== gid || (p.leagueTier || 0) !== tier) return; |
| const wins = m.wins || 0, outcome = leagueOutcome(idx, size, wins, tier); |
| let nt = tier; |
| if (outcome === "up") nt = Math.min(LEAGUES.length - 1, tier + 1); |
| if (outcome === "down") nt = Math.max(0, tier - 1); |
| const b = "players/" + m.uid + "/"; |
| up[b + "leagueTier"] = nt; |
| up[b + "leagueBest"] = Math.max(p.leagueBest || 0, nt); |
| up[b + "leagueWeek"] = null; |
| up[b + "leagueGroup"] = null; |
| up[b + "leagueLastResult"] = { week, rank: idx + 1, size, wins, outcome, from: tier, to: nt, group: gid }; |
| applied++; |
| }); |
| } |
| } |
| } |
| |
| const prevId = leagueWeekId(leagueWeekStart() - 1); |
| const inGroup = new Set(); |
| for (const tiers of Object.values((wl && wl[prevId]) || {})) |
| for (const members of Object.values(tiers || {})) |
| for (const m of Object.values(members || {})) if (m && m.uid) inGroup.add(m.uid); |
| for (const [uid, p] of Object.entries(players || {})) { |
| if (!p || uid === me || inGroup.has(uid) || up["players/" + uid + "/leagueTier"] !== undefined) continue; |
| const tier = Number(p.leagueTier) || 0; |
| if (tier <= 0 || p.leagueWeek) continue; |
| if (p.leagueLastResult && String(p.leagueLastResult.week) >= prevId) continue; |
| const nt = tier - 1, b = "players/" + uid + "/"; |
| up[b + "leagueTier"] = nt; |
| up[b + "leagueLastResult"] = { week: prevId, rank: 0, size: 0, wins: 0, outcome: "idle", from: tier, to: nt, group: null }; |
| applied++; |
| } |
| |
| const keys = Object.keys(up); |
| for (let i = 0; i < keys.length; i += 2500) { |
| const part = {}; keys.slice(i, i + 2500).forEach(k => part[k] = up[k]); |
| await R.modApplyLeague(part); |
| } |
| if (applied) { try { leagueToast("🏅 " + applied + " league results applied", "#00ff88"); } catch (e) {} } |
| } catch (e) { console.warn("league admin resolve failed", e); applied = 0; } |
| _lgAdminBusy = false; |
| return applied; |
| } |
| setTimeout(() => { leagueAdminResolveAll(); setInterval(leagueAdminResolveAll, 60000); }, 25000); |
| |
| const _liveSeen = {}; |
| async function liveAdminCleanup() { |
| if (!_modIsAdmin() || !_modRPC()) return; |
| try { |
| const R = _modRPC(), d = (await R.get("survivalLive")) || {}, now = Date.now(), me = _modMyUid(); |
| for (const [uid, v] of Object.entries(d)) { |
| const sig = String(v && v.x) + ":" + String(v && v.y) + ":" + String(v && v.bx) + ":" + String(v && v.by) + ":" + String(v && v.b); |
| const o = _liveSeen[uid]; |
| if (!o || o.sig !== sig) { _liveSeen[uid] = { sig, at: now }; continue; } |
| if (now - o.at > 30000) { |
| await R.modRemoveLive(uid); |
| try { const st = await R.get("players/" + uid + "/status"); if (st === "Survival" || st === "Daily" || st === "Playing Streaks") await R.modSetIdle(uid); } catch (e) {} |
| delete _liveSeen[uid]; |
| } |
| } |
| for (const uid of Object.keys(_liveSeen)) if (!d[uid]) delete _liveSeen[uid]; |
| } catch (e) { console.warn("live cleanup failed", e); } |
| } |
| setInterval(liveAdminCleanup, 10000); |
| async function modDeleteChatMsg(key) { |
| if (!confirm("Delete this chat message?")) return; |
| try { await _modRPC().modDeleteChat(key); delete chatAll[key]; renderChat(true); } |
| catch (e) { alert("Failed: " + (e && e.message)); } |
| } |
| </script> |
| </body> |
| </html> |
|
|