// ===================== PlayBull Frontend ===================== import { renderAvatar, renderUploadItem, hydrateFramedImages, isImageAvatar, avatarEmoji } from './framed-image.js'; import { uploadImageWithEditor, repositionImageWithEditor, appendPositionFields } from './upload-helper.js'; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const state = { user: null, assets: new Map(), // symbol -> asset obj liveHistory: new Map(), // symbol -> [{t,p}] filter: 'all', selected: null, socket: null, }; // ---------- API ---------- async function api(path, body, method = 'POST') { const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' }; if (body && method !== 'GET') opts.body = JSON.stringify(body); const res = await fetch(path, method === 'GET' ? { credentials: 'same-origin' } : opts); const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' })); if (!res.ok && !data.error) data.error = 'Fehler ' + res.status; return data; } const get = (p) => api(p, null, 'GET'); async function apiForm(path, formData, method = 'POST') { const res = await fetch(path, { method, body: formData, credentials: 'same-origin' }); const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' })); if (!res.ok && !data.error) data.error = 'Fehler ' + res.status; return data; } async function apiDelete(path) { const res = await fetch(path, { method: 'DELETE', credentials: 'same-origin' }); const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' })); if (!res.ok && !data.error) data.error = 'Fehler ' + res.status; return data; } // ---------- Formatters ---------- const nf = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const nf4 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 4 }); function fmt(n) { return nf.format(Number(n) || 0); } function coins(n) { return `πŸͺ™ ${fmt(n)}`; } function px(n) { const v = Number(n); return v < 1 ? nf4.format(v) : nf.format(v); } function timeAgo(ts) { const s = Math.floor((Date.now() - ts) / 1000); if (s < 60) return s + 's'; if (s < 3600) return Math.floor(s / 60) + 'm'; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; } // ---------- Toast ---------- function toast(msg, type = '') { const el = document.createElement('div'); el.className = 'toast ' + type; el.innerHTML = msg; $('#toastWrap').appendChild(el); setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 2600); setTimeout(() => el.remove(), 3000); } // ---------- Celebration FX ---------- const GOLD_CONFETTI = ['#c9a84c', '#d8bd6e', '#f4f1e9', '#46c98b', '#8a6e1f']; function burstConfetti(opts = {}) { if (typeof window.confetti !== 'function') return; window.confetti({ particleCount: 110, spread: 75, startVelocity: 42, ticks: 180, origin: { y: 0.55 }, colors: GOLD_CONFETTI, disableForReducedMotion: true, ...opts }); } function bigWinFX(mult = 1) { burstConfetti({ particleCount: Math.min(60 + mult * 18, 220), spread: 100 }); if (mult >= 3) setTimeout(() => burstConfetti({ angle: 60, origin: { x: 0, y: 0.6 } }), 120); if (mult >= 3) setTimeout(() => burstConfetti({ angle: 120, origin: { x: 1, y: 0.6 } }), 220); } function gsapOK() { return typeof window.gsap !== 'undefined'; } // ---------- Modal ---------- function openModal(html) { $('#modalBox').innerHTML = `
${html}`; $('#modal').hidden = false; document.body.style.overflow = 'hidden'; } function closeModal() { $('#modal').hidden = true; document.body.style.overflow = ''; state.selected = null; } $('#modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); }); // ---------- Balance ---------- function setBalance(b) { if (!state.user) return; state.user.balance = b; const bal = $('#balanceValue'); if (bal) bal.textContent = fmt(b); } function updateAuthUI() { const btn = $('#authBtn'); const chip = $('#balanceChip'); const bal = $('#balanceValue'); if (btn) { if (state.user) { if (chip) chip.hidden = false; if (bal) bal.textContent = fmt(state.user.balance); btn.className = 'btn-auth btn-auth--avatar'; btn.innerHTML = renderAvatar(state.user.avatar, { size: 'sm' }); hydrateFramedImages(btn); btn.onclick = openAccountMenu; } else { if (chip) chip.hidden = true; btn.className = 'btn-auth'; btn.textContent = 'Login'; btn.onclick = openAuthModal; } } const hint = $('#gamesLoginHint'); if (hint) hint.hidden = !!state.user; } // ===================== AUTH ===================== function openAuthModal(mode = 'login') { const isLogin = mode === 'login'; openModal(`

${isLogin ? 'Noch kein Account?' : 'Schon registriert?'}

`); $('#auSwitch').onclick = () => openAuthModal(isLogin ? 'register' : 'login'); $('#auSubmit').onclick = async () => { const username = $('#auUser').value.trim(); const password = $('#auPass').value; const r = await api(isLogin ? '/api/login' : '/api/register', { username, password }); if (!r.ok) { $('#auErr').textContent = r.error; return; } state.user = r.user; updateAuthUI(); closeModal(); toast(`Willkommen, ${r.user.username}! πŸŽ‰`, 'win'); renderActiveView(); }; $('#auPass').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#auSubmit').click(); }); } window._closeModal = closeModal; function openAccountMenu() { const u = state.user; openModal(`
Guthaben${coins(u.balance)}
`); hydrateFramedImages($('#modalBox')); $('#goSettings').onclick = () => { closeModal(); switchView('settings'); }; $('#logoutBtn').onclick = async () => { await api('/api/logout', {}); state.user = null; updateAuthUI(); closeModal(); renderActiveView(); toast('Ausgeloggt'); }; } // ===================== NAVIGATION ===================== function switchView(name) { $$('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + name)); $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === name)); window.scrollTo(0, 0); renderActiveView(); } $$('.nav-btn').forEach(b => b.onclick = () => switchView(b.dataset.view)); $('#brandHome')?.addEventListener('click', () => switchView('trading')); $$('[data-open-auth]').forEach(b => b.onclick = () => openAuthModal()); function activeView() { const v = $$('.view').find(v => v.classList.contains('active')); return v ? v.id.replace('view-', '') : 'trading'; } function renderActiveView() { const v = activeView(); if (v === 'trading') renderAssets(); else if (v === 'games') renderGames(); else if (v === 'portfolio') renderPortfolio(); else if (v === 'social') renderSocial(); else if (v === 'settings') renderSettings(); } // ===================== TRADING ===================== function renderAssets() { const list = $('#assetList'); const arr = [...state.assets.values()].filter(a => state.filter === 'all' || a.type === state.filter); list.innerHTML = arr.map(a => assetRowHTML(a)).join(''); $$('.asset-row', list).forEach(row => row.onclick = () => openAssetDetail(row.dataset.sym)); } function assetRowHTML(a) { const cls = a.change >= 0 ? 'up' : 'down'; const sign = a.change >= 0 ? '+' : ''; return `
${a.emoji}
${a.symbol}${a.halted ? 'HALT' : ''}
${a.name}
${px(a.price)}
${sign}${fmt(a.changePct)}%
`; } $$('#marketFilter .seg-btn').forEach(b => b.onclick = () => { $$('#marketFilter .seg-btn').forEach(x => x.classList.remove('active')); b.classList.add('active'); state.filter = b.dataset.f; renderAssets(); }); async function openAssetDetail(sym) { state.selected = sym; const a = state.assets.get(sym); openModal(`
${px(a.price)}
${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)
${state.user ? tradePanelHTML() : '
πŸ”’ Zum Handeln
'}

πŸŽ›οΈ Markt manipulieren (wirkt fΓΌr ALLE)

${state.user ? manipPanelHTML() : '
Login nΓΆtig
'} `); drawChartFor(sym); if (state.user) wireTradePanel(sym), wireManipPanel(sym); } window._openAuth = () => openAuthModal(); function tradePanelHTML() { return `
`; } function wireTradePanel(sym) { const qty = $('#tQty'); const updEst = () => { const a = state.assets.get(sym); const cost = (parseFloat(qty.value) || 0) * a.price; $('#tEst').textContent = `β‰ˆ ${coins(cost)}`; }; qty.addEventListener('input', updEst); updEst(); $$('.chip[data-amt]').forEach(c => c.onclick = () => { qty.value = c.dataset.amt; updEst(); }); $('.chip[data-max]')?.addEventListener('click', () => { const a = state.assets.get(sym); qty.value = (state.user.balance / a.price).toFixed(4); updEst(); }); $('#btnBuy').onclick = () => doTrade(sym, 'buy', parseFloat(qty.value)); $('#btnSell').onclick = () => doTrade(sym, 'sell', parseFloat(qty.value)); } async function doTrade(sym, side, qty) { if (!(qty > 0)) return toast('Menge ungΓΌltig', 'lose'); const r = await api('/api/trade', { symbol: sym, side, qty }); if (!r.ok) return toast(r.error, 'lose'); setBalance(r.balance); toast(`${side === 'buy' ? 'Gekauft' : 'Verkauft'}: ${fmt(qty)} ${sym} @ ${px(r.price)}`, 'win'); } function manipPanelHTML() { return `
`; } function wireManipPanel(sym) { $$('[data-mp]').forEach(b => b.onclick = async () => { const action = b.dataset.mp; const value = action === 'set' ? parseFloat($('#mSet').value) : (action === 'pump' || action === 'dump' ? 3 : 0); const r = await api('/api/manipulate', { symbol: sym, action, value }); if (!r.ok) return toast(r.error, 'lose'); toast(`${sym}: ${action} βœ…`); }); } // ---------- Chart ---------- async function drawChartFor(sym) { let hist = state.liveHistory.get(sym); if (!hist || hist.length < 5) { const r = await get(`/api/market/${sym}/history`); if (r.ok) { hist = r.history; state.liveHistory.set(sym, hist.slice()); } } drawChart(); } function drawChart() { const c = $('#chart'); if (!c) return; const sym = state.selected; if (!sym) return; const hist = state.liveHistory.get(sym) || []; const dpr = window.devicePixelRatio || 1; const w = c.clientWidth, h = 180; c.width = w * dpr; c.height = h * dpr; const ctx = c.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); if (hist.length < 2) return; const ys = hist.map(p => p.p); let min = Math.min(...ys), max = Math.max(...ys); if (min === max) { min *= 0.999; max *= 1.001; } const pad = 10; const X = i => pad + (i / (hist.length - 1)) * (w - 2 * pad); const Y = v => h - pad - ((v - min) / (max - min)) * (h - 2 * pad); const up = ys[ys.length - 1] >= ys[0]; const col = up ? '#1fd17a' : '#ff4d61'; // Fill const grad = ctx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, up ? 'rgba(31,209,122,.25)' : 'rgba(255,77,97,.25)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.beginPath(); ctx.moveTo(X(0), Y(ys[0])); hist.forEach((p, i) => ctx.lineTo(X(i), Y(p.p))); ctx.lineTo(X(hist.length - 1), h - pad); ctx.lineTo(X(0), h - pad); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // Line ctx.beginPath(); ctx.moveTo(X(0), Y(ys[0])); hist.forEach((p, i) => ctx.lineTo(X(i), Y(p.p))); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); // Last dot const lx = X(hist.length - 1), ly = Y(ys[ys.length - 1]); ctx.beginPath(); ctx.arc(lx, ly, 3.5, 0, 7); ctx.fillStyle = col; ctx.fill(); } // ===================== GAMES ===================== const GAMES = [ { id: 'chicken', emoji: 'πŸ”', name: 'Chicken Road', desc: 'Überquere die Spuren, cashe vor dem Crash', tag: 'Skill', glow: 'rgba(201,168,76,.28)' }, { id: 'mines', emoji: 'πŸƒ', name: '10Γ—10 Deck', desc: '100 Karten, finde die Diamanten', tag: 'Cards', glow: 'rgba(70,201,139,.24)' }, { id: 'crash', emoji: 'πŸš€', name: 'Crash', desc: 'Raus bevor die Rakete explodiert', tag: 'Live', glow: 'rgba(224,98,94,.26)' }, { id: 'mine_dice', emoji: '🎲', name: 'Dice', desc: 'Über/Unter β€” wΓ€hle dein Risiko', tag: 'Classic', glow: 'rgba(201,168,76,.24)' }, { id: 'coin', emoji: 'πŸͺ™', name: 'Coinflip', desc: 'Kopf oder Zahl, 2Γ—', tag: '50/50', glow: 'rgba(216,189,110,.26)' }, ]; function renderGames() { $('#gamesGrid').innerHTML = GAMES.map((g, i) => `
${g.tag}
${g.emoji}
${g.name}
${g.desc}
`).join(''); const cards = $$('.game-card'); cards.forEach(c => c.onclick = () => { if (!state.user) return openAuthModal(); openGame(c.dataset.game); }); if (gsapOK()) gsap.from(cards, { opacity: 0, y: 18, scale: .96, duration: .5, ease: 'power3.out', stagger: .07 }); $('#gamesLoginHint').hidden = !!state.user; } function openGame(id) { if (id === 'chicken') gameChicken(); else if (id === 'mines') gameMines(); else if (id === 'crash') gameCrash(); else if (id === 'mine_dice') gameDice(); else if (id === 'coin') gameCoin(); } function betHeader(title, emoji) { return `
Guthaben: ${coins(state.user.balance)}
`; } function refreshGBal() { const el = $('#gBal'); if (el) el.textContent = `Guthaben: ${coins(state.user.balance)}`; } // ---------- Chicken Road ---------- function gameChicken() { openModal(`${betHeader('Chicken Road', 'πŸ”')}
`); let diff = 'easy'; $$('#chDiff .chip').forEach(c => c.onclick = () => { $$('#chDiff .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); diff = c.dataset.d; }); let round = null, busy = false; function buildStage(rd) { const cells = ['
START
']; rd.mults.forEach((m, i) => cells.push( `
${m.toFixed(2)}Γ—
πŸš—
`)); $('#chTrack').innerHTML = cells.join(''); } function markLanes(rd) { for (let i = 0; i <= rd.lanes; i++) { const c = $(`#cell-${i}`); if (!c) continue; c.classList.toggle('passed', i > 0 && i <= rd.lane); c.classList.toggle('current', i === rd.lane + 1); } } function scrollTo(lane) { const cell = $(`#cell-${lane}`); if (!cell) return; $('#chTrack').scrollTo({ left: Math.max(0, cell.offsetLeft - 24), behavior: 'smooth' }); } function hop() { const hero = $('#chHero'); if (!hero) return; hero.classList.remove('idle', 'hop'); void hero.offsetWidth; hero.classList.add('hop'); setTimeout(() => hero && hero.classList.add('idle'), 360); } function setMult(v) { const el = $('#chMult'); if (!el) return; el.textContent = v.toFixed(2) + 'Γ—'; if (gsapOK()) gsap.fromTo(el, { scale: 1.4 }, { scale: 1, duration: .4, ease: 'back.out(3)' }); } $('#chStart').onclick = async () => { if (busy) return; busy = true; const bet = parseFloat($('#chBet').value); const r = await api('/api/games/chicken/start', { bet, difficulty: diff }); busy = false; if (!r.ok) return toast(r.error, 'lose'); setBalance(r.balance); refreshGBal(); round = { id: r.roundId, mults: r.multipliers, lane: 0, lanes: r.lanes, bet }; $('#chSetup').hidden = true; $('#chPlay').hidden = false; buildStage(round); markLanes(round); scrollTo(0); $('#chHero').textContent = 'πŸ”'; $('#chHero').className = 'chicken-hero idle'; $('#chMult').textContent = '1.00Γ—'; }; $('#chWalk').onclick = async () => { if (!round || busy) return; busy = true; const r = await api('/api/games/chicken/step', { roundId: round.id }); if (!r.ok) { busy = false; return toast(r.error, 'lose'); } setBalance(r.balance ?? state.user.balance); refreshGBal(); if (r.dead) { hop(); const cell = $(`#cell-${r.lane}`); if (cell) cell.classList.add('dead'); scrollTo(r.lane); setTimeout(() => { const stage = $('#chStage'), hero = $('#chHero'), boom = $('#chBoom'); if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); } if (boom) { boom.classList.remove('go'); void boom.offsetWidth; boom.classList.add('go'); } if (hero) { hero.classList.remove('idle'); hero.textContent = 'πŸ’₯'; } toast('πŸ’₯ Erwischt! Verloren.', 'lose'); }, 280); endChicken(); } else { round.lane = r.lane; hop(); markLanes(round); scrollTo(round.lane); setMult(r.multiplier); if (r.maxed) { bigWinFX(r.multiplier); toast(`🏁 Ende erreicht! +${coins(r.payout)}`, 'win'); endChicken(); } else busy = false; } }; $('#chCash').onclick = async () => { if (!round || busy) return; busy = true; const r = await api('/api/games/chicken/cashout', { roundId: round.id }); if (!r.ok) { busy = false; return toast(r.error, 'lose'); } setBalance(r.balance); refreshGBal(); bigWinFX(r.multiplier); toast(`πŸ’° Cashout ${r.multiplier.toFixed(2)}Γ— β†’ +${coins(r.payout)}`, 'win'); endChicken(); }; function endChicken() { round = null; setTimeout(() => { busy = false; if ($('#chPlay')) { $('#chSetup').hidden = false; $('#chPlay').hidden = true; } }, 1600); } } // ---------- Mines 10x10 ---------- function gameMines() { openModal(`${betHeader('10Γ—10 Karten-Deck', 'πŸƒ')}
`); let bombs = 10; $$('#mBombs .chip').forEach(c => c.onclick = () => { $$('#mBombs .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); bombs = +c.dataset.b; }); let round = null, busy = false; const tileHTML = (i) => `
`; function flip(t, kind, sym) { t.classList.add('flipped', kind); const f = t.querySelector('.front'); if (f) f.textContent = sym; } function setStats(mult) { const mEl = $('#mMult'), pEl = $('#mPay'); if (mEl) mEl.textContent = mult.toFixed(2) + 'Γ—'; if (pEl) pEl.textContent = fmt(round.bet * mult); if (gsapOK() && mEl) gsap.fromTo([mEl, pEl], { scale: 1.25 }, { scale: 1, duration: .35, ease: 'back.out(2.5)' }); } $('#mStart').onclick = async () => { if (busy) return; busy = true; const bet = parseFloat($('#mBet').value); const r = await api('/api/games/mines/start', { bet, bombs }); busy = false; if (!r.ok) return toast(r.error, 'lose'); setBalance(r.balance); refreshGBal(); round = { id: r.roundId, bet, picks: 0, mult: 1, over: false }; $('#mSetup').hidden = true; $('#mPlay').hidden = false; $('#mMult').textContent = '1.00Γ—'; $('#mPay').textContent = '0'; $('#mGrid').innerHTML = Array.from({ length: 100 }, (_, i) => tileHTML(i)).join(''); $$('#mGrid .tile').forEach(t => t.onclick = () => revealTile(+t.dataset.i, t)); if (gsapOK()) gsap.from('#mGrid .tile', { opacity: 0, scale: .5, duration: .35, ease: 'power2.out', stagger: { each: .004, from: 'center', grid: [10, 10] } }); }; async function revealTile(i, el) { if (!round || round.over || el.classList.contains('flipped') || busy) return; busy = true; const r = await api('/api/games/mines/reveal', { roundId: round.id, index: i }); if (!r.ok) { busy = false; return toast(r.error, 'lose'); } if (r.bomb) { round.over = true; flip(el, 'bomb', 'πŸ’£'); el.classList.add('hit'); const stage = $('#mGrid'); if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); } r.mines.forEach((m, idx) => { if (m === i) return; const t = $(`#mGrid .tile[data-i="${m}"]`); if (t) setTimeout(() => flip(t, 'bomb', 'πŸ’£'), 60 + idx * 18); }); $$('#mGrid .tile').forEach(t => { if (!t.classList.contains('flipped')) t.classList.add('dim'); }); setBalance(r.balance); refreshGBal(); toast('πŸ’₯ Bombe! Verloren.', 'lose'); endMines(); } else { flip(el, 'safe', 'πŸ’Ž'); round.picks = r.picks; round.mult = r.multiplier; setStats(r.multiplier); if (r.cleared) { setBalance(r.balance); refreshGBal(); bigWinFX(r.multiplier); toast(`πŸ† Deck geknackt! +${coins(r.payout)}`, 'win'); endMines(); } else busy = false; } } $('#mCash').onclick = async () => { if (!round || round.over || busy) return; busy = true; const r = await api('/api/games/mines/cashout', { roundId: round.id }); if (!r.ok) { busy = false; return toast(r.error, 'lose'); } round.over = true; setBalance(r.balance); refreshGBal(); r.mines.forEach((m, idx) => { const t = $(`#mGrid .tile[data-i="${m}"]`); if (t && !t.classList.contains('flipped')) setTimeout(() => flip(t, 'bomb', 'πŸ’£'), idx * 14); }); bigWinFX(r.multiplier); toast(`πŸ’° +${coins(r.payout)} (${r.multiplier.toFixed(2)}Γ—)`, 'win'); endMines(); }; function endMines() { if (round) round.over = true; setTimeout(() => { busy = false; if ($('#mPlay')) { $('#mSetup').hidden = false; $('#mPlay').hidden = true; } }, 1800); } } // ---------- Crash ---------- function gameCrash() { openModal(`${betHeader('Crash', 'πŸš€')}
`); let round = null, raf = null, poll = null, particles = []; const stars = Array.from({ length: 46 }, () => ({ x: Math.random(), y: Math.random(), r: Math.random() * 1.3 + 0.3, tw: Math.random() * 6 })); function cv() { const c = $('#crCanvas'); if (!c) return null; const dpr = window.devicePixelRatio || 1; const w = c.clientWidth || 320, h = 240; if (c.width !== Math.round(w * dpr)) { c.width = w * dpr; c.height = h * dpr; } const ctx = c.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { ctx, w, h }; } function rocketPos(elapsed, m, w, h) { const curMax = Math.max(1.4, m * 1.12), pad = 14; const x = pad + Math.min(1, elapsed / Math.max(elapsed, 1)) * (w - 2 * pad); const y = h - pad - ((m - 1) / (curMax - 1)) * (h - 2 * pad); return { x, y, curMax, pad }; } function draw(elapsed, m, color) { const ctxv = cv(); if (!ctxv) return; const { ctx, w, h } = ctxv; ctx.clearRect(0, 0, w, h); // stars stars.forEach(s => { ctx.globalAlpha = .35 + .45 * Math.abs(Math.sin(elapsed / 700 + s.tw)); ctx.fillStyle = '#d8bd6e'; ctx.beginPath(); ctx.arc(s.x * w, s.y * h, s.r, 0, 7); ctx.fill(); }); ctx.globalAlpha = 1; const { x: rx, y: ry, curMax, pad } = rocketPos(elapsed, m, w, h); const N = 64; // fill under curve const grad = ctx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, color === 'red' ? 'rgba(224,98,94,.30)' : 'rgba(201,168,76,.30)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.beginPath(); ctx.moveTo(pad, h - pad); for (let i = 0; i <= N; i++) { const t = elapsed * i / N; const mm = Math.exp(round.k * t); const x = pad + (i / N) * (rx - pad); const y = h - pad - ((mm - 1) / (curMax - 1)) * (h - 2 * pad); ctx.lineTo(x, y); } ctx.lineTo(rx, h - pad); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // curve line ctx.beginPath(); for (let i = 0; i <= N; i++) { const t = elapsed * i / N; const mm = Math.exp(round.k * t); const x = pad + (i / N) * (rx - pad); const y = h - pad - ((mm - 1) / (curMax - 1)) * (h - 2 * pad); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.strokeStyle = color === 'red' ? '#e0625e' : '#d8bd6e'; ctx.lineWidth = 3; ctx.lineJoin = 'round'; ctx.shadowColor = color === 'red' ? 'rgba(224,98,94,.7)' : 'rgba(201,168,76,.6)'; ctx.shadowBlur = 12; ctx.stroke(); ctx.shadowBlur = 0; if (color !== 'red') { // flame const fl = 8 + Math.random() * 8; ctx.fillStyle = 'rgba(255,180,60,.8)'; ctx.beginPath(); ctx.moveTo(rx - 6, ry + 6); ctx.lineTo(rx - 16 - fl, ry + 12); ctx.lineTo(rx - 6, ry + 14); ctx.closePath(); ctx.fill(); // rocket ctx.font = '22px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.save(); ctx.translate(rx, ry); ctx.rotate(-Math.PI / 5); ctx.fillText('πŸš€', 0, 0); ctx.restore(); } // particles (explosion) particles.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.life -= 0.022; p.vx *= 0.99; ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.c; ctx.beginPath(); ctx.arc(p.x, p.y, p.s, 0, 7); ctx.fill(); }); particles = particles.filter(p => p.life > 0); ctx.globalAlpha = 1; } function explode(x, y) { const cols = ['#e0625e', '#ffb43c', '#d8bd6e', '#f4f1e9']; for (let i = 0; i < 46; i++) { const a = Math.random() * Math.PI * 2, sp = 1 + Math.random() * 5; particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1, s: 1.5 + Math.random() * 3, life: 1, c: cols[i % cols.length] }); } } $('#crStart').onclick = async () => { const bet = parseFloat($('#crBet').value); const r = await api('/api/games/crash/start', { bet }); if (!r.ok) return toast(r.error, 'lose'); setBalance(r.balance); refreshGBal(); round = { id: r.roundId, k: r.k, start: Date.now(), over: false, bet }; particles = []; $('#crSetup').hidden = true; $('#crPlay').hidden = false; $('#crMult').classList.remove('boom'); $('#crMult').style.color = ''; $('#crSub').textContent = 'πŸš€ steigt…'; animate(); poll = setInterval(checkStatus, 280); }; function animate() { if (!round || round.over) return; const elapsed = Date.now() - round.start; const m = Math.exp(round.k * elapsed); $('#crMult').textContent = m.toFixed(2) + 'Γ—'; $('#crMult').style.color = m >= 2 ? 'var(--gold-soft)' : ''; draw(elapsed, m, 'gold'); raf = requestAnimationFrame(animate); } async function checkStatus() { if (!round || round.over) return; const r = await api('/api/games/crash/status', { roundId: round.id }); if (r.ok && r.crashed) boom(r.multiplier); } function boom(at) { if (!round) return; round.over = true; cancelAnimationFrame(raf); clearInterval(poll); const elapsed = Date.now() - round.start; const ctxv = cv(); if (ctxv) { const p = rocketPos(elapsed, at, ctxv.w, ctxv.h); explode(p.x, p.y); } $('#crMult').textContent = at.toFixed(2) + 'Γ—'; $('#crMult').classList.add('boom'); $('#crSub').textContent = 'πŸ’₯ CRASHED!'; const stage = $('#crStage'); if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); } boomLoop(elapsed, at); toast('πŸ’₯ Crashed! Verloren.', 'lose'); endCrash(); } function boomLoop(elapsed, at) { if (!particles.length) return; draw(elapsed, at, 'red'); requestAnimationFrame(() => boomLoop(elapsed, at)); } $('#crCash').onclick = async () => { if (!round || round.over) return; const r = await api('/api/games/crash/cashout', { roundId: round.id }); if (!r.ok) return toast(r.error, 'lose'); round.over = true; cancelAnimationFrame(raf); clearInterval(poll); setBalance(r.balance); refreshGBal(); if (r.crashed) { const elapsed = Date.now() - round.start; const ctxv = cv(); if (ctxv) { const p = rocketPos(elapsed, r.crashPoint || 1, ctxv.w, ctxv.h); explode(p.x, p.y); boomLoop(elapsed, r.crashPoint || 1); } $('#crMult').classList.add('boom'); $('#crSub').textContent = 'πŸ’₯ zu spΓ€t!'; toast('πŸ’₯ Zu spΓ€t!', 'lose'); } else { $('#crMult').textContent = r.multiplier.toFixed(2) + 'Γ—'; $('#crMult').style.color = 'var(--green)'; $('#crSub').textContent = 'πŸ’° Cashout!'; bigWinFX(r.multiplier); toast(`πŸ’° ${r.multiplier.toFixed(2)}Γ— β†’ +${coins(r.payout)}`, 'win'); } endCrash(); }; function endCrash() { setTimeout(() => { if ($('#crPlay')) { $('#crSetup').hidden = false; $('#crPlay').hidden = true; } round = null; }, 2200); } } // ---------- Dice ---------- function gameDice() { openModal(`${betHeader('Dice', '🎲')}
50
0255075100
Bereit?
Gewinnchance
50%
Multiplikator
1.98Γ—
`); let dir = 'under', rolling = false; const upd = () => { const t = +$('#dTarget').value; $('#dTval').textContent = t; const chance = dir === 'under' ? t : 100 - t; $('#dChance').textContent = chance + '%'; $('#dMult').textContent = ((100 / chance) * 0.99).toFixed(2) + 'Γ—'; const zone = $('#dZone'); if (dir === 'under') { zone.style.left = '0%'; zone.style.right = 'auto'; zone.style.width = t + '%'; } else { zone.style.left = t + '%'; zone.style.right = '0'; zone.style.width = (100 - t) + '%'; } }; function moveMarker(v, settle) { const m = $('#dMark'); if (!m) return; m.classList.toggle('settle', !!settle); m.style.left = v + '%'; m.textContent = (Math.round(v * 10) / 10).toFixed(1); } $('#dTarget').addEventListener('input', upd); $$('#dDir .chip').forEach(c => c.onclick = () => { $$('#dDir .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); dir = c.dataset.d; upd(); }); upd(); $('#dRoll').onclick = async () => { if (rolling) return; rolling = true; const btn = $('#dRoll'); btn.disabled = true; $('#dRes').textContent = '🎲'; $('#dRes').className = 'dice-result muted'; const r = await api('/api/games/dice', { bet: parseFloat($('#dBet').value), target: +$('#dTarget').value, direction: dir }); if (!r.ok) { rolling = false; btn.disabled = false; return toast(r.error, 'lose'); } setBalance(r.balance); refreshGBal(); // spin the marker, then settle on the real roll let ticks = 0; const spin = setInterval(() => { moveMarker(2 + Math.random() * 96, false); ticks++; }, 55); setTimeout(() => { clearInterval(spin); moveMarker(r.roll, true); const res = $('#dRes'); res.textContent = r.roll.toFixed(2); res.className = 'dice-result ' + (r.win ? 'up' : 'down'); if (r.win) { bigWinFX(r.multiplier); toast(`πŸŽ‰ Gewonnen! +${coins(r.payout)}`, 'win'); } else toast('😒 Verloren', 'lose'); rolling = false; btn.disabled = false; }, 760); }; } // ---------- Coinflip ---------- function gameCoin() { openModal(`${betHeader('Coinflip', 'πŸͺ™')}
πŸ‘‘
πŸ”’
`); let busy = false; async function flip(choice) { if (busy) return; busy = true; $('#cHeads').disabled = $('#cTails').disabled = true; const coin = $('#cCoin'); coin.classList.remove('spin-heads', 'spin-tails'); coin.style.transform = 'none'; void coin.offsetWidth; const r = await api('/api/games/coinflip', { bet: parseFloat($('#cBet').value), choice }); if (!r.ok) { busy = false; $('#cHeads').disabled = $('#cTails').disabled = false; return toast(r.error, 'lose'); } setBalance(r.balance); refreshGBal(); coin.style.transform = ''; coin.classList.add(r.flip === 'heads' ? 'spin-heads' : 'spin-tails'); setTimeout(() => { if (r.win) { bigWinFX(2); toast(`πŸŽ‰ ${r.flip === 'heads' ? 'Kopf' : 'Zahl'}! +${coins(r.payout)}`, 'win'); } else toast(`😒 ${r.flip === 'heads' ? 'Kopf' : 'Zahl'} β€” verloren`, 'lose'); busy = false; $('#cHeads').disabled = $('#cTails').disabled = false; }, 2400); } $('#cHeads').onclick = () => flip('heads'); $('#cTails').onclick = () => flip('tails'); } // ===================== PORTFOLIO ===================== async function renderPortfolio() { const el = $('#portfolioContent'); if (!state.user) { el.innerHTML = '
πŸ”’ um dein Depot zu sehen
'; return; } const r = await get('/api/portfolio'); if (!r.ok) { el.innerHTML = `
${r.error}
`; return; } const p = r.portfolio; const pnlTotal = p.holdings.reduce((s, h) => s + h.pnl, 0); el.innerHTML = `
Gesamtwert (Net Worth)
${coins(p.netWorth)}
πŸ’΅ Cash${coins(p.balance)}
πŸ“Š Positionen${coins(p.holdingsValue)}
P&L offen${pnlTotal>=0?'+':''}${fmt(pnlTotal)}

Positionen

${p.holdings.length ? p.holdings.map(h => `
${h.emoji}
${h.symbol} ${h.side.toUpperCase()}
${fmt(h.quantity)} @ ${px(h.avgPrice)}
${coins(h.value)}
${h.pnl>=0?'+':''}${fmt(h.pnl)} (${fmt(h.pnlPct)}%)
`).join('') : '
Noch keine Positionen. Geh ins Trading! πŸ“ˆ
'}

Letzte Trades

`; $$('#portfolioContent .asset-row').forEach(row => row.onclick = () => { switchView('trading'); openAssetDetail(row.dataset.sym); }); const tr = await get('/api/trades'); if (tr.ok) $('#tradeHist').innerHTML = tr.trades.slice(0, 15).map(t => `
${t.side === 'buy' ? '🟒' : 'πŸ”΄'}${t.side === 'buy' ? 'Kauf' : 'Verkauf'} ${fmt(t.qty)} ${t.symbol} @ ${px(t.price)}${timeAgo(t.ts)}
`).join('') || '
β€”
'; } // ===================== SOCIAL ===================== async function renderSocial() { const r = await get('/api/leaderboard'); const lb = $('#leaderboardContent'); if (r.ok) { lb.innerHTML = r.leaderboard.map((u, i) => `
${['πŸ₯‡','πŸ₯ˆ','πŸ₯‰'][i] || '#' + (i + 1)}
${renderAvatar(u.avatar, { size: 'sm' })}${u.username}
${coins(u.netWorth)}
`).join(''); hydrateFramedImages(lb); } const f = await get('/api/feed'); if (f.ok) renderFeedList(f.feed); } function renderFeedList(feed) { const el = $('#feedList'); if (!el) return; const icon = { win: 'πŸ†', trade: 'πŸ’±', manip: 'πŸŽ›οΈ', admin: 'πŸ’°', join: 'πŸŽ‰' }; el.innerHTML = feed.map(f => `
${icon[f.kind] || 'β€’'}${f.text}${timeAgo(f.ts)}
`).join(''); } // ===================== SETTINGS / ADMIN ===================== async function handleAvatarFile(file) { if (!file) return; try { await uploadImageWithEditor(file, { frame: 'avatar', uploadFn: async (f, result) => { const fd = new FormData(); fd.append('file', f); appendPositionFields(fd, result); const r = await apiForm('/api/settings/avatar/image', fd); if (!r.ok) { toast(r.error, 'lose'); return null; } state.user.avatar = r.avatar; updateAuthUI(); renderSettings(); toast('Profilbild gespeichert βœ…', 'win'); return r; }, }); } catch (e) { toast(e.message || 'Upload fehlgeschlagen', 'lose'); } } async function adjustAvatarPosition() { const av = state.user?.avatar; if (!isImageAvatar(av)) return; try { await repositionImageWithEditor(av, { frame: 'avatar', saveFn: async (result) => { const r = await api('/api/settings/avatar/image/position', { focusX: result.focus.x, focusY: result.focus.y, zoom: result.zoom, }, 'PATCH'); if (!r.ok) { toast(r.error, 'lose'); return null; } state.user.avatar = r.avatar; updateAuthUI(); renderSettings(); toast('Position gespeichert βœ…', 'win'); return r; }, }); } catch (e) { toast(e.message || 'Fehler', 'lose'); } } async function removeAvatarImage() { const r = await apiDelete('/api/settings/avatar/image'); if (!r.ok) return toast(r.error, 'lose'); state.user.avatar = r.avatar; updateAuthUI(); renderSettings(); toast('Profilbild entfernt'); } let selectedUploadFrame = 'gallery'; async function handleGalleryUpload(file, frame) { if (!file) return; try { await uploadImageWithEditor(file, { frame, uploadFn: async (f, result) => { const fd = new FormData(); fd.append('file', f); appendPositionFields(fd, result, frame); const r = await apiForm('/api/uploads', fd); if (!r.ok) { toast(r.error, 'lose'); return null; } toast('Bild hochgeladen βœ…', 'win'); renderSettings(); return r; }, }); } catch (e) { toast(e.message || 'Upload fehlgeschlagen', 'lose'); } } async function repositionGalleryItem(item) { try { await repositionImageWithEditor(item, { frame: item.frame, saveFn: async (result) => { const r = await api(`/api/uploads/${item.id}/position`, { focusX: result.focus.x, focusY: result.focus.y, zoom: result.zoom, }, 'PATCH'); if (!r.ok) { toast(r.error, 'lose'); return null; } toast('Position gespeichert βœ…', 'win'); renderSettings(); return r; }, }); } catch (e) { toast(e.message || 'Fehler', 'lose'); } } async function deleteGalleryItem(id) { const r = await apiDelete(`/api/uploads/${id}`); if (!r.ok) return toast(r.error, 'lose'); toast('Bild gelΓΆscht'); renderSettings(); } async function renderSettings() { const el = $('#settingsContent'); if (!state.user) { el.innerHTML = '
πŸ”’
'; return; } const av = state.user.avatar; const hasImage = isImageAvatar(av); const currentEmoji = avatarEmoji(av); const uploadsRes = await get('/api/uploads'); const uploads = uploadsRes.ok ? uploadsRes.uploads : []; el.innerHTML = `

πŸ‘€ Profil

${renderAvatar(av, { size: 'lg' })}
${hasImage ? '' : ''} ${hasImage ? '' : ''}
${['πŸ‚','πŸš€','πŸ’Ž','πŸ”₯','πŸ€‘','πŸ‘‘','🦍','🐳','πŸƒ','🎰'].map(e => ``).join('')}

πŸ–ΌοΈ Uploads

Bilder hochladen β€” Editor zeigt den Rahmen wie auf der Seite.

${uploads.length ? uploads.map(renderUploadItem).join('') : '

Noch keine Uploads

'}

πŸ’° Geld bearbeiten

Spaßmodus: jeder darf bei sich & anderen Geld Àndern.

πŸŽ›οΈ Markt-Kontrolle

Pumpe/dumpe Kurse fΓΌr ALLE Spieler gleichzeitig.

πŸ‘₯ Alle Spieler

`; hydrateFramedImages(el); const fileInput = $('#avFile'); $('#avUploadBtn').onclick = () => fileInput.click(); fileInput.onchange = () => { const f = fileInput.files?.[0]; fileInput.value = ''; handleAvatarFile(f); }; $('#avReposition')?.addEventListener('click', adjustAvatarPosition); $('#avRemoveImg')?.addEventListener('click', removeAvatarImage); $$('#uploadFrameChips .chip').forEach(c => c.onclick = () => { selectedUploadFrame = c.dataset.uframe; $$('#uploadFrameChips .chip').forEach(x => x.classList.toggle('active', x.dataset.uframe === selectedUploadFrame)); }); const galleryInput = $('#galleryFile'); $('#galleryUploadBtn').onclick = () => galleryInput.click(); galleryInput.onchange = () => { const f = galleryInput.files?.[0]; galleryInput.value = ''; handleGalleryUpload(f, selectedUploadFrame); }; const uploadMap = new Map(uploads.map(u => [u.id, u])); $$('[data-upload-repos]').forEach(btn => btn.onclick = () => { const item = uploadMap.get(+btn.dataset.uploadRepos); if (item) repositionGalleryItem(item); }); $$('[data-upload-del]').forEach(btn => btn.onclick = () => deleteGalleryItem(+btn.dataset.uploadDel)); $$('#avChips .chip').forEach(c => c.onclick = async () => { const r = await api('/api/settings/avatar', { avatar: c.dataset.av }); if (r.ok) { state.user.avatar = r.avatar; updateAuthUI(); renderSettings(); } }); const users = (await get('/api/users')).users || []; const sel = $('#adUser'); sel.innerHTML = users.map(u => ``).join(''); $('#userList').innerHTML = users.map(u => `
${renderAvatar(u.avatar, { size: 'sm' })} ${u.username}${coins(u.balance)}
`).join(''); hydrateFramedImages($('#userList')); const doBalance = async (mode, amount) => { const r = await api('/api/admin/balance', { userId: +sel.value, mode, amount }); if (!r.ok) return toast(r.error, 'lose'); toast('Guthaben aktualisiert βœ…', 'win'); if (+sel.value === state.user.id) setBalance(r.balance); renderSettings(); }; $('#adAdd').onclick = () => doBalance('add', parseFloat($('#adAmount').value)); $('#adSet').onclick = () => doBalance('set', parseFloat($('#adAmount').value)); $$('[data-quick]').forEach(c => c.onclick = () => doBalance('add', +c.dataset.quick)); $$('[data-mc]').forEach(b => b.onclick = async () => { const action = b.dataset.mc; const r = await api('/api/manipulate', { symbol: $('#mkSym').value, action, value: action === 'reset' ? 0 : 4 }); if (!r.ok) return toast(r.error, 'lose'); toast(`${$('#mkSym').value}: ${action} βœ…`); }); } // ===================== LIVE (Socket.IO) ===================== function connectSocket() { const socket = io(); state.socket = socket; socket.on('hello', ({ assets, feed }) => { assets.forEach(a => state.assets.set(a.symbol, a)); if (activeView() === 'trading') renderAssets(); if (feed) buildTicker(feed); }); socket.on('prices', ({ data }) => { data.forEach(d => { const a = state.assets.get(d.s); if (!a) return; const prev = a.price; a.price = d.p; a.change = d.c; a.changePct = d.cp; a.halted = !!d.h; // live history let h = state.liveHistory.get(d.s); if (!h) { h = []; state.liveHistory.set(d.s, h); } h.push({ t: Date.now(), p: d.p }); if (h.length > 120) h.shift(); updateAssetRow(d.s, prev); if (state.selected === d.s) updateDetail(a); }); if (state.selected) drawChart(); }); socket.on('feed', (f) => { addTickerItem(f); if (activeView() === 'social') get('/api/feed').then(r => r.ok && renderFeedList(r.feed)); }); socket.on('balance_changed', ({ userId, balance }) => { if (state.user && userId === state.user.id) setBalance(balance); }); socket.on('manip', ({ symbol, action, by }) => { /* feed handles UI */ }); } function updateAssetRow(sym, prevPrice) { const row = $(`#row-${sym}`); if (!row) return; const a = state.assets.get(sym); const pxEl = row.querySelector('[data-px]'); const chgEl = row.querySelector('[data-chg]'); if (pxEl) pxEl.textContent = px(a.price); if (chgEl) { chgEl.textContent = (a.change >= 0 ? '+' : '') + fmt(a.changePct) + '%'; chgEl.className = 'chg ' + (a.change >= 0 ? 'up' : 'down'); } if (a.price !== prevPrice) { row.classList.remove('flash-up', 'flash-down'); void row.offsetWidth; row.classList.add(a.price > prevPrice ? 'flash-up' : 'flash-down'); } } function updateDetail(a) { const p = $('#dPrice'); if (p) p.textContent = px(a.price); const c = $('#dChg'); if (c) { c.textContent = `${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)`; c.className = a.change >= 0 ? 'up' : 'down'; } } // ---------- Ticker ---------- function buildTicker(feed) { const t = $('#tickerTrack'); if (!t) return; const items = (feed && feed.length) ? feed.map(f => f.text) : ['Willkommen bei PlayBull πŸ‚', 'Echte Kurse Β· Live Β· Spielgeld']; t.innerHTML = items.map(x => `${x}`).join('') + items.map(x => `${x}`).join(''); } function addTickerItem(f) { const t = $('#tickerTrack'); if (!t) return; const s = document.createElement('span'); s.className = 'ticker-item'; s.innerHTML = f.text; t.prepend(s); while (t.children.length > 60) t.lastChild.remove(); } // ===================== INIT ===================== async function init() { const embed = new URLSearchParams(location.search).has('embed'); if (embed) { document.body.classList.add('pb-embed'); $('#pbTopbar')?.remove(); $('#feedTicker')?.remove(); } const me = await get('/api/me'); if (me.ok && me.user) state.user = me.user; updateAuthUI(); const m = await get('/api/market'); if (m.ok) m.assets.forEach(a => state.assets.set(a.symbol, a)); connectSocket(); if (embed) switchView('games'); else renderActiveView(); setInterval(() => { // Ticker-Zeiten & Feed leichtgewichtig aktualisieren if (activeView() === 'social') { /* live via socket */ } }, 1000); } const authBtn = $('#authBtn'); if (authBtn) authBtn.onclick = openAuthModal; init();