Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor <cursoragent@cursor.com>
1226 lines
57 KiB
JavaScript
1226 lines
57 KiB
JavaScript
// ===================== 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 = `<div class="handle"></div>${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(`
|
||
<div class="modal-head"><h2>${isLogin ? 'Login' : 'Registrieren'} 🐂</h2><button class="modal-close" onclick="window._closeModal()">✕</button></div>
|
||
<div class="field"><label>Username</label><input id="auUser" autocomplete="username" placeholder="dein_name" /></div>
|
||
<div class="field"><label>Passwort</label><input id="auPass" type="password" autocomplete="current-password" placeholder="••••" /></div>
|
||
<div id="auErr" class="muted" style="color:var(--red);font-size:.85rem;min-height:18px"></div>
|
||
<button class="btn btn-accent" id="auSubmit">${isLogin ? 'Einloggen' : 'Account erstellen'}</button>
|
||
<p class="center muted" style="margin-top:14px;font-size:.85rem">
|
||
${isLogin ? 'Noch kein Account?' : 'Schon registriert?'}
|
||
<button class="link" id="auSwitch">${isLogin ? 'Registrieren' : 'Login'}</button>
|
||
</p>
|
||
`);
|
||
$('#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(`
|
||
<div class="modal-head account-head">
|
||
<h2>${renderAvatar(u.avatar, { size: 'md' })} ${u.username}</h2>
|
||
<button class="modal-close" onclick="window._closeModal()">✕</button>
|
||
</div>
|
||
<div class="card"><div class="stat-row"><span class="k">Guthaben</span><span class="v">${coins(u.balance)}</span></div></div>
|
||
<button class="btn btn-ghost" id="goSettings">⚙️ Einstellungen & Geld</button>
|
||
<div class="spacer"></div>
|
||
<button class="btn btn-red" id="logoutBtn">Logout</button>
|
||
`);
|
||
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 `
|
||
<div class="asset-row" data-sym="${a.symbol}" id="row-${a.symbol}">
|
||
<div class="asset-emoji">${a.emoji}</div>
|
||
<div class="asset-info">
|
||
<div class="sym">${a.symbol}${a.halted ? '<span class="badge-halt">HALT</span>' : ''}</div>
|
||
<div class="name">${a.name}</div>
|
||
</div>
|
||
<div class="asset-price">
|
||
<div class="px" data-px>${px(a.price)}</div>
|
||
<div class="chg ${cls}" data-chg>${sign}${fmt(a.changePct)}%</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
$$('#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(`
|
||
<div class="modal-head">
|
||
<h2>${a.emoji} ${a.symbol} <small class="muted">${a.name}</small></h2>
|
||
<button class="modal-close" onclick="window._closeModal()">✕</button>
|
||
</div>
|
||
<div class="big-num" id="dPrice">${px(a.price)}</div>
|
||
<div class="${a.change>=0?'up':'down'}" id="dChg" style="font-weight:700;margin-bottom:12px">${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)</div>
|
||
<div class="chart-wrap"><canvas id="chart" height="180"></canvas></div>
|
||
|
||
${state.user ? tradePanelHTML() : '<div class="login-hint">🔒 Zum Handeln <button class="link" onclick="window._closeModal();window._openAuth()">einloggen</button></div>'}
|
||
|
||
<h2>🎛️ Markt manipulieren <small>(wirkt für ALLE)</small></h2>
|
||
${state.user ? manipPanelHTML() : '<div class="muted center" style="padding:8px">Login nötig</div>'}
|
||
`);
|
||
drawChartFor(sym);
|
||
if (state.user) wireTradePanel(sym), wireManipPanel(sym);
|
||
}
|
||
window._openAuth = () => openAuthModal();
|
||
|
||
function tradePanelHTML() {
|
||
return `
|
||
<div class="field"><label>Menge</label><input id="tQty" type="number" inputmode="decimal" value="1" min="0" step="0.1" /></div>
|
||
<div class="chips" style="margin-bottom:12px">
|
||
<button class="chip" data-amt="0.1">0.1</button>
|
||
<button class="chip" data-amt="1">1</button>
|
||
<button class="chip" data-amt="5">5</button>
|
||
<button class="chip" data-amt="10">10</button>
|
||
<button class="chip" data-max>MAX</button>
|
||
</div>
|
||
<div id="tEst" class="muted center" style="margin-bottom:10px;font-size:.85rem"></div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-green" id="btnBuy">Kaufen (Long)</button>
|
||
<button class="btn btn-red" id="btnSell">Verkaufen / Short</button>
|
||
</div>`;
|
||
}
|
||
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 `
|
||
<div class="btn-row" style="margin-bottom:8px">
|
||
<button class="btn btn-green" data-mp="pump">🚀 Pump</button>
|
||
<button class="btn btn-red" data-mp="dump">📉 Dump</button>
|
||
</div>
|
||
<div class="field"><label>Kurs hart setzen</label>
|
||
<div style="display:flex;gap:8px">
|
||
<input id="mSet" type="number" inputmode="decimal" placeholder="z.B. 999" />
|
||
<button class="btn btn-gold" style="width:auto;padding:0 18px" data-mp="set">Set</button>
|
||
</div>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-ghost" data-mp="halt">⏸️ Halt</button>
|
||
<button class="btn btn-ghost" data-mp="resume">▶️ Resume</button>
|
||
<button class="btn btn-ghost" data-mp="reset">♻️ Reset</button>
|
||
</div>`;
|
||
}
|
||
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) => `
|
||
<div class="game-card" data-game="${g.id}" style="--glow:${g.glow}">
|
||
<span class="g-tag">${g.tag}</span>
|
||
<div class="g-emoji">${g.emoji}</div>
|
||
<div>
|
||
<div class="g-name">${g.name}</div>
|
||
<div class="g-desc">${g.desc}</div>
|
||
</div>
|
||
</div>`).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 `<div class="modal-head"><h2>${emoji} ${title}</h2><button class="modal-close" onclick="window._closeModal()">✕</button></div>
|
||
<div class="muted" id="gBal" style="margin-bottom:10px">Guthaben: ${coins(state.user.balance)}</div>`;
|
||
}
|
||
function refreshGBal() { const el = $('#gBal'); if (el) el.textContent = `Guthaben: ${coins(state.user.balance)}`; }
|
||
|
||
// ---------- Chicken Road ----------
|
||
function gameChicken() {
|
||
openModal(`${betHeader('Chicken Road', '🐔')}
|
||
<div id="chSetup">
|
||
<div class="field"><label>Einsatz</label><input id="chBet" type="number" value="10" min="1" /></div>
|
||
<div class="field"><label>Schwierigkeit</label>
|
||
<div class="chips" id="chDiff">
|
||
<button class="chip active" data-d="easy">Easy</button>
|
||
<button class="chip" data-d="medium">Medium</button>
|
||
<button class="chip" data-d="hard">Hard</button>
|
||
<button class="chip" data-d="insane">Insane 🔥</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-gold" id="chStart">Start 🐔</button>
|
||
</div>
|
||
<div id="chPlay" hidden>
|
||
<div class="chicken-stage" id="chStage">
|
||
<div class="chicken-track" id="chTrack"></div>
|
||
<div class="chicken-hero idle" id="chHero">🐔</div>
|
||
<div class="boom-flash" id="chBoom"></div>
|
||
</div>
|
||
<div class="chicken-hud"><span class="muted">Multiplikator</span> <span class="mult-big" id="chMult">1.00×</span></div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-accent" id="chWalk">Schritt 🐾</button>
|
||
<button class="btn btn-green" id="chCash">Cashout 💰</button>
|
||
</div>
|
||
</div>`);
|
||
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 = ['<div class="lane curb" id="cell-0"><div class="mult-flag">START</div></div>'];
|
||
rd.mults.forEach((m, i) => cells.push(
|
||
`<div class="lane" id="cell-${i + 1}"><div class="mult-flag">${m.toFixed(2)}×</div><div class="car">🚗</div></div>`));
|
||
$('#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', '🃏')}
|
||
<div id="mSetup">
|
||
<div class="field"><label>Einsatz</label><input id="mBet" type="number" value="10" min="1" /></div>
|
||
<div class="field"><label>Bomben 💣</label>
|
||
<div class="chips" id="mBombs">
|
||
<button class="chip" data-b="5">5</button>
|
||
<button class="chip active" data-b="10">10</button>
|
||
<button class="chip" data-b="20">20</button>
|
||
<button class="chip" data-b="35">35</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-accent" id="mStart">Deck mischen 🃏</button>
|
||
</div>
|
||
<div id="mPlay" hidden>
|
||
<div class="grid2" style="margin-bottom:10px">
|
||
<div class="stat-tile"><div class="lbl">Multiplikator</div><div class="val" id="mMult" style="color:var(--gold)">1.00×</div></div>
|
||
<div class="stat-tile"><div class="lbl">Auszahlung</div><div class="val" id="mPay" style="color:var(--green)">0</div></div>
|
||
</div>
|
||
<div class="mines-grid" id="mGrid"></div>
|
||
<button class="btn btn-green" id="mCash">Cashout 💰</button>
|
||
</div>`);
|
||
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) => `<div class="tile" data-i="${i}"><div class="face back"></div><div class="face front"></div></div>`;
|
||
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', '🚀')}
|
||
<div id="crSetup">
|
||
<div class="field"><label>Einsatz</label><input id="crBet" type="number" value="10" min="1" /></div>
|
||
<button class="btn btn-gold" id="crStart">Start 🚀</button>
|
||
</div>
|
||
<div id="crPlay" hidden>
|
||
<div class="crash-stage" id="crStage">
|
||
<canvas id="crCanvas"></canvas>
|
||
<div class="crash-overlay">
|
||
<div class="crash-mult" id="crMult">1.00×</div>
|
||
<div class="crash-sub" id="crSub">🚀 steigt…</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-green" id="crCash">Cashout 💰</button>
|
||
</div>`);
|
||
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', '🎲')}
|
||
<div class="dice-bar-wrap">
|
||
<div class="dice-bar"><div class="win-zone" id="dZone"></div><div class="dice-marker settle" id="dMark">50</div></div>
|
||
<div class="dice-scale"><span>0</span><span>25</span><span>50</span><span>75</span><span>100</span></div>
|
||
</div>
|
||
<div class="dice-result muted" id="dRes">Bereit?</div>
|
||
<div class="field"><label>Einsatz</label><input id="dBet" type="number" value="10" min="1" /></div>
|
||
<div class="field"><label>Richtung</label>
|
||
<div class="chips" id="dDir"><button class="chip active" data-d="under">Unter ⬇️</button><button class="chip" data-d="over">Über ⬆️</button></div>
|
||
</div>
|
||
<div class="field"><label>Ziel: <b id="dTval">50</b></label><input id="dTarget" type="range" min="2" max="98" value="50" /></div>
|
||
<div class="grid2" style="margin-bottom:14px">
|
||
<div class="stat-tile"><div class="lbl">Gewinnchance</div><div class="val" id="dChance">50%</div></div>
|
||
<div class="stat-tile"><div class="lbl">Multiplikator</div><div class="val" id="dMult" style="color:var(--gold)">1.98×</div></div>
|
||
</div>
|
||
<button class="btn btn-green" id="dRoll">Würfeln 🎲</button>`);
|
||
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', '🪙')}
|
||
<div class="coin-stage"><div class="coin" id="cCoin"><div class="side heads">👑</div><div class="side tails">🔢</div></div></div>
|
||
<div class="field"><label>Einsatz</label><input id="cBet" type="number" value="10" min="1" /></div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-gold" id="cHeads">Kopf 👑</button>
|
||
<button class="btn btn-ghost" id="cTails">Zahl 🔢</button>
|
||
</div>`);
|
||
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 = '<div class="login-hint">🔒 <button class="link" data-open-auth onclick="window._openAuth()">Einloggen</button> um dein Depot zu sehen</div>'; return; }
|
||
const r = await get('/api/portfolio');
|
||
if (!r.ok) { el.innerHTML = `<div class="muted">${r.error}</div>`; return; }
|
||
const p = r.portfolio;
|
||
const pnlTotal = p.holdings.reduce((s, h) => s + h.pnl, 0);
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<div class="muted" style="font-size:.8rem">Gesamtwert (Net Worth)</div>
|
||
<div class="big-num">${coins(p.netWorth)}</div>
|
||
<div class="stat-row"><span class="k">💵 Cash</span><span class="v">${coins(p.balance)}</span></div>
|
||
<div class="stat-row"><span class="k">📊 Positionen</span><span class="v">${coins(p.holdingsValue)}</span></div>
|
||
<div class="stat-row"><span class="k">P&L offen</span><span class="v ${pnlTotal>=0?'up':'down'}">${pnlTotal>=0?'+':''}${fmt(pnlTotal)}</span></div>
|
||
</div>
|
||
<h2>Positionen</h2>
|
||
${p.holdings.length ? p.holdings.map(h => `
|
||
<div class="asset-row" style="grid-template-columns:40px 1fr auto" data-sym="${h.symbol}">
|
||
<div class="asset-emoji">${h.emoji}</div>
|
||
<div class="asset-info">
|
||
<div class="sym">${h.symbol} <span class="pill ${h.side}">${h.side.toUpperCase()}</span></div>
|
||
<div class="name">${fmt(h.quantity)} @ ${px(h.avgPrice)}</div>
|
||
</div>
|
||
<div class="asset-price">
|
||
<div class="px">${coins(h.value)}</div>
|
||
<div class="chg ${h.pnl>=0?'up':'down'}">${h.pnl>=0?'+':''}${fmt(h.pnl)} (${fmt(h.pnlPct)}%)</div>
|
||
</div>
|
||
</div>`).join('') : '<div class="muted center" style="padding:20px">Noch keine Positionen. Geh ins Trading! 📈</div>'}
|
||
<h2>Letzte Trades</h2>
|
||
<div id="tradeHist" class="feed-list"></div>`;
|
||
$$('#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 => `
|
||
<div class="feed-item"><span>${t.side === 'buy' ? '🟢' : '🔴'}</span><span>${t.side === 'buy' ? 'Kauf' : 'Verkauf'} ${fmt(t.qty)} ${t.symbol} @ ${px(t.price)}</span><span class="ago">${timeAgo(t.ts)}</span></div>`).join('') || '<div class="muted">—</div>';
|
||
}
|
||
|
||
// ===================== SOCIAL =====================
|
||
async function renderSocial() {
|
||
const r = await get('/api/leaderboard');
|
||
const lb = $('#leaderboardContent');
|
||
if (r.ok) {
|
||
lb.innerHTML = r.leaderboard.map((u, i) => `
|
||
<div class="lb-row ${state.user && u.username === state.user.username ? 'me' : ''}">
|
||
<div class="lb-rank">${['🥇','🥈','🥉'][i] || '#' + (i + 1)}</div>
|
||
<div class="lb-user">${renderAvatar(u.avatar, { size: 'sm' })}<b>${u.username}</b></div>
|
||
<div class="v">${coins(u.netWorth)}</div>
|
||
</div>`).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 => `<div class="feed-item"><span>${icon[f.kind] || '•'}</span><span>${f.text}</span><span class="ago">${timeAgo(f.ts)}</span></div>`).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 = '<div class="login-hint">🔒 <button class="link" onclick="window._openAuth()">Einloggen</button></div>'; 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 = `
|
||
<div class="card">
|
||
<h2 style="margin-top:0">👤 Profil</h2>
|
||
<div class="profile-avatar-row">
|
||
${renderAvatar(av, { size: 'lg' })}
|
||
<div class="profile-avatar-actions">
|
||
<button type="button" class="btn btn-accent" id="avUploadBtn">📷 Profilbild hochladen</button>
|
||
<input type="file" id="avFile" accept="image/*" hidden />
|
||
${hasImage ? '<button type="button" class="btn btn-ghost" id="avReposition">↕️ Position anpassen</button>' : ''}
|
||
${hasImage ? '<button type="button" class="btn btn-ghost" id="avRemoveImg">🗑️ Bild entfernen</button>' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="field"><label>Avatar (Emoji)</label>
|
||
<div class="chips" id="avChips">${['🐂','🚀','💎','🔥','🤑','👑','🦍','🐳','🃏','🎰'].map(e => `<button class="chip ${!hasImage && currentEmoji === e ? 'active' : ''}" data-av="${e}">${e}</button>`).join('')}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2 style="margin-top:0">🖼️ Uploads</h2>
|
||
<p class="muted" style="font-size:.82rem;margin-top:0">Bilder hochladen — Editor zeigt den Rahmen wie auf der Seite.</p>
|
||
<div class="uploads-toolbar">
|
||
<div class="field" style="margin-bottom:0">
|
||
<label>Rahmen-Typ</label>
|
||
<div class="chips" id="uploadFrameChips">
|
||
<button type="button" class="chip ${selectedUploadFrame === 'gallery' ? 'active' : ''}" data-uframe="gallery">⬜ Quadrat</button>
|
||
<button type="button" class="chip ${selectedUploadFrame === 'banner' ? 'active' : ''}" data-uframe="banner">▬ Banner</button>
|
||
<button type="button" class="chip ${selectedUploadFrame === 'card' ? 'active' : ''}" data-uframe="card">🃏 Karte</button>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn btn-accent" id="galleryUploadBtn">📷 Bild hochladen</button>
|
||
<input type="file" id="galleryFile" accept="image/*" hidden />
|
||
</div>
|
||
<div class="uploads-grid" id="uploadsGrid">
|
||
${uploads.length ? uploads.map(renderUploadItem).join('') : '<p class="muted center" style="grid-column:1/-1;padding:16px">Noch keine Uploads</p>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2 style="margin-top:0">💰 Geld bearbeiten</h2>
|
||
<p class="muted" style="font-size:.82rem;margin-top:0">Spaßmodus: jeder darf bei sich & anderen Geld ändern.</p>
|
||
<div class="field"><label>User</label><select id="adUser"></select></div>
|
||
<div class="field"><label>Betrag</label><input id="adAmount" type="number" value="1000" /></div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-green" id="adAdd">➕ Hinzufügen</button>
|
||
<button class="btn btn-accent" id="adSet">✏️ Setzen</button>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<div class="chips">
|
||
<button class="chip" data-quick="1000">+1k</button>
|
||
<button class="chip" data-quick="10000">+10k</button>
|
||
<button class="chip" data-quick="100000">+100k</button>
|
||
<button class="chip" data-quick="1000000">+1M</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2 style="margin-top:0">🎛️ Markt-Kontrolle</h2>
|
||
<p class="muted" style="font-size:.82rem;margin-top:0">Pumpe/dumpe Kurse für ALLE Spieler gleichzeitig.</p>
|
||
<div class="field"><label>Asset</label><select id="mkSym">${[...state.assets.values()].map(a => `<option value="${a.symbol}">${a.emoji} ${a.symbol}</option>`).join('')}</select></div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-green" data-mc="pump">🚀 Pump</button>
|
||
<button class="btn btn-red" data-mc="dump">📉 Dump</button>
|
||
<button class="btn btn-ghost" data-mc="reset">♻️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2 style="margin-top:0">👥 Alle Spieler</h2>
|
||
<div id="userList"></div>
|
||
</div>`;
|
||
|
||
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 => `<option value="${u.id}" ${u.id === state.user.id ? 'selected' : ''}>${avatarEmoji(u.avatar)} ${u.username} (${fmt(u.balance)})</option>`).join('');
|
||
$('#userList').innerHTML = users.map(u => `<div class="stat-row"><span class="k" style="display:flex;align-items:center;gap:8px">${renderAvatar(u.avatar, { size: 'sm' })} ${u.username}</span><span class="v">${coins(u.balance)}</span></div>`).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 => `<span class="ticker-item">${x}</span>`).join('') + items.map(x => `<span class="ticker-item">${x}</span>`).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();
|