// ===================== 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 ? 'Login' : 'Registrieren'} π
${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(`
${renderAvatar(u.avatar, { size: 'md' })} ${u.username}
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(`
${a.emoji} ${a.symbol} ${a.name}
${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) => `
`).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 `${emoji} ${title}
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', 'π')}
Multiplikator 1.00Γ
`);
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 = [''];
rd.mults.forEach((m, i) => cells.push(
``));
$('#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', 'π²')}
Bereit?
`);
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
${['π','π','π','π₯','π€','π','π¦','π³','π','π°'].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.
`;
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();