feat(ui): 3D scenes, mobile-first layout, portfolio charts
Three WebGL scenes on one renderer and one context, since a phone should not allocate a context per game: a rocket straining against gravity, a decaying orbit, and a tower that sways harder the higher it stacks. The loop stops when the tab is hidden. Navigation moves to the bottom, where a thumb already is. New Stats view with balance history, cash-out rate, and a distribution chart that plots observed crash points against what the published maths predicts — the honest version of a hot-numbers board. Charts are hand-built SVG, ~300 lines, rather than a library that would cost more to load than the 3D engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/* Quantum Arcade client.
|
||||
*
|
||||
* Identity is an ed25519 keypair generated in the browser and kept in
|
||||
* localStorage. There is no account to create and no password to lose.
|
||||
* Identity is a keypair generated in the browser and kept in localStorage.
|
||||
* There is no account to create and no password to lose.
|
||||
*
|
||||
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
||||
* the server whether a round was fair — it checks.
|
||||
@@ -10,17 +10,53 @@
|
||||
* Nothing that originates from another player (nicknames, keys) or from the
|
||||
* server ever reaches innerHTML. */
|
||||
|
||||
import { Arcade3D } from '/scene3d.js';
|
||||
import * as charts from '/charts.js';
|
||||
|
||||
const KEY_STORAGE = 'quantum-arcade-key';
|
||||
const NAME_STORAGE = 'quantum-arcade-name';
|
||||
const STATS_STORAGE = 'quantum-arcade-stats';
|
||||
|
||||
let keypair = null; // { publicKeyHex, privateKey (CryptoKey) }
|
||||
let keypair = null;
|
||||
let token = null;
|
||||
let nickname = '';
|
||||
let stake = 5000; // millisatoshis
|
||||
let stake = 5000; // millisatoshis
|
||||
let currentGame = 'rocket';
|
||||
let socket = null;
|
||||
let snapshot = null;
|
||||
let myBet = null; // 'in' | 'out' | null
|
||||
let myBet = null; // 'in' | 'out' | null
|
||||
let scene = null;
|
||||
let balanceMsat = 0;
|
||||
|
||||
/* Session statistics, kept client-side. The ledger remains the authority on
|
||||
* money; this is only for the charts. */
|
||||
const stats = loadStats();
|
||||
|
||||
function loadStats() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(STATS_STORAGE) || '{}');
|
||||
return {
|
||||
crashes: raw.crashes || [], // crash points seen, for distribution
|
||||
balances: raw.balances || [], // balance samples over time
|
||||
wagered: raw.wagered || 0,
|
||||
plays: raw.plays || 0,
|
||||
wins: raw.wins || 0,
|
||||
losses: raw.losses || 0,
|
||||
best: raw.best || 0,
|
||||
sessionStart: null, // set at sign-in, never persisted
|
||||
};
|
||||
} catch {
|
||||
return { crashes: [], balances: [], wagered: 0, plays: 0,
|
||||
wins: 0, losses: 0, best: 0, sessionStart: null };
|
||||
}
|
||||
}
|
||||
|
||||
function saveStats() {
|
||||
// Cap the arrays so localStorage cannot grow without bound over a long night.
|
||||
stats.crashes = stats.crashes.slice(-300);
|
||||
stats.balances = stats.balances.slice(-300);
|
||||
localStorage.setItem(STATS_STORAGE, JSON.stringify(stats));
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
||||
@@ -44,6 +80,11 @@ function clear(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
/* A short haptic tap. Phones only, and silently absent elsewhere. */
|
||||
function buzz(ms) {
|
||||
if (navigator.vibrate) navigator.vibrate(ms);
|
||||
}
|
||||
|
||||
/* ---------------- identity ---------------- */
|
||||
|
||||
async function loadOrCreateKey() {
|
||||
@@ -88,10 +129,16 @@ async function signIn() {
|
||||
|
||||
$('signin').hidden = true;
|
||||
$('app').hidden = false;
|
||||
$('tabs').hidden = false;
|
||||
$('balance-wrap').hidden = false;
|
||||
setBalance(res.balance_msat);
|
||||
stats.sessionStart = res.balance_msat;
|
||||
$('pubkey').textContent = keypair.publicKeyHex;
|
||||
|
||||
scene = new Arcade3D($('scene'));
|
||||
scene.setGame(currentGame);
|
||||
scene.start();
|
||||
|
||||
connect(currentGame);
|
||||
loadScratch();
|
||||
}
|
||||
@@ -102,8 +149,7 @@ async function api(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
method, headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -112,11 +158,17 @@ async function api(method, path, body) {
|
||||
}
|
||||
|
||||
function setBalance(msat) {
|
||||
balanceMsat = msat;
|
||||
$('balance').textContent = sats(msat);
|
||||
const last = stats.balances[stats.balances.length - 1];
|
||||
if (last !== msat) {
|
||||
stats.balances.push(msat);
|
||||
saveStats();
|
||||
}
|
||||
}
|
||||
|
||||
/* Reads the auto cash-out box. Returns 0 when empty or invalid, which the
|
||||
* server treats as "no target". */
|
||||
/* ---------------- auto cash-out ---------------- */
|
||||
|
||||
function autoTarget() {
|
||||
const v = parseFloat($('auto-target').value);
|
||||
return Number.isFinite(v) && v > 1 ? v : 0;
|
||||
@@ -132,6 +184,7 @@ function connect(game) {
|
||||
if (socket) socket.close();
|
||||
currentGame = game;
|
||||
myBet = null;
|
||||
if (scene) scene.setGame(game);
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
||||
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
||||
@@ -140,37 +193,54 @@ function connect(game) {
|
||||
|
||||
function onSnapshot(s) {
|
||||
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
||||
const wasSettled = snapshot && snapshot.state === 'settled';
|
||||
snapshot = s;
|
||||
if (roundChanged) myBet = null;
|
||||
|
||||
const mult = $('multiplier');
|
||||
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×';
|
||||
const current = parseFloat(s.multiplier);
|
||||
mult.textContent = current.toFixed(2) + '×';
|
||||
mult.className = 'multiplier';
|
||||
|
||||
$('commitment').textContent = s.commitment || '—';
|
||||
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
||||
|
||||
const potMsat = (s.players || []).reduce((n, p) => n + p.stake_msat, 0);
|
||||
$('potline').textContent = potMsat
|
||||
? `${(s.players || []).length} in · ${sats(potMsat)} sats at stake`
|
||||
: 'no bets yet';
|
||||
|
||||
// Drive the 3D scene. Progress is log-scaled so the early climb is visible
|
||||
// and the tail does not saturate instantly.
|
||||
if (scene) {
|
||||
scene.setState(Math.log(Math.max(1, current)) / Math.log(25),
|
||||
s.state === 'settled');
|
||||
}
|
||||
|
||||
const action = $('action');
|
||||
const hint = $('hint');
|
||||
|
||||
switch (s.state) {
|
||||
case 'betting_open':
|
||||
$('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||
$('state').textContent =
|
||||
`betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||
action.textContent = myBet
|
||||
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
|
||||
: 'Place bet';
|
||||
action.className = 'primary big';
|
||||
action.disabled = !!myBet;
|
||||
break;
|
||||
|
||||
case 'locked':
|
||||
$('state').textContent = 'launching';
|
||||
action.textContent = 'Launching…';
|
||||
action.disabled = true;
|
||||
break;
|
||||
case 'running':
|
||||
|
||||
case 'running': {
|
||||
$('state').textContent = 'in flight';
|
||||
if (myBet === 'in') {
|
||||
const payout = stake * parseFloat(s.multiplier);
|
||||
const payout = stake * current;
|
||||
action.textContent = `Cash out ${sats(payout)}`;
|
||||
action.className = 'primary big cashout';
|
||||
action.disabled = false;
|
||||
@@ -180,21 +250,49 @@ function onSnapshot(s) {
|
||||
action.disabled = true;
|
||||
}
|
||||
break;
|
||||
case 'settled':
|
||||
if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×';
|
||||
}
|
||||
|
||||
case 'settled': {
|
||||
const crash = s.crash_point ? parseFloat(s.crash_point) : current;
|
||||
mult.textContent = crash.toFixed(2) + '×';
|
||||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||||
$('state').textContent =
|
||||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||
action.textContent = 'Next round';
|
||||
action.className = 'primary big';
|
||||
action.disabled = true;
|
||||
if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; }
|
||||
|
||||
// Record the round once, on the transition into settled.
|
||||
if (!wasSettled && s.crash_point) {
|
||||
stats.crashes.push(crash);
|
||||
if (myBet === 'in') {
|
||||
stats.losses++;
|
||||
hint.textContent = 'Rode it too far.';
|
||||
hint.className = 'hint bad';
|
||||
buzz(120);
|
||||
} else if (myBet === 'out') {
|
||||
stats.wins++;
|
||||
buzz([30, 40, 30]);
|
||||
}
|
||||
saveStats();
|
||||
renderStrip();
|
||||
refreshBalance();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderPlayers(s.players);
|
||||
draw(s);
|
||||
if (s.state === 'running') tone(parseFloat(s.multiplier));
|
||||
}
|
||||
|
||||
/* The recent-rounds strip under the stage. */
|
||||
function renderStrip() {
|
||||
const wrap = $('strip');
|
||||
clear(wrap);
|
||||
for (const c of stats.crashes.slice(-24).reverse()) {
|
||||
const cls = c >= 10 ? 'pip high' : c >= 2 ? 'pip mid' : 'pip';
|
||||
wrap.appendChild(el('span', { class: cls, text: c.toFixed(2) + '×' }));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlayers(players) {
|
||||
@@ -210,6 +308,13 @@ function renderPlayers(players) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
const b = await api('GET', '/api/balance');
|
||||
setBalance(b.balance_msat);
|
||||
} catch { /* a failed refresh is cosmetic; the ledger is still correct */ }
|
||||
}
|
||||
|
||||
async function onAction() {
|
||||
const hint = $('hint');
|
||||
hint.textContent = '';
|
||||
@@ -221,14 +326,22 @@ async function onAction() {
|
||||
auto_cashout: autoTarget(),
|
||||
});
|
||||
setBalance(r.balance_msat);
|
||||
stats.wagered += stake;
|
||||
stats.plays++;
|
||||
saveStats();
|
||||
myBet = 'in';
|
||||
buzz(20);
|
||||
} else if (snapshot.state === 'running' && myBet === 'in') {
|
||||
const r = await api('POST', '/api/cashout', { game: currentGame });
|
||||
myBet = 'out';
|
||||
hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`;
|
||||
const at = parseFloat(r.cashed_out_at);
|
||||
const won = stake * at;
|
||||
if (won > stats.best) stats.best = won;
|
||||
saveStats();
|
||||
hint.textContent = `Out at ${at.toFixed(2)}× — paid at settlement.`;
|
||||
hint.className = 'hint good';
|
||||
const b = await api('GET', '/api/balance');
|
||||
setBalance(b.balance_msat);
|
||||
buzz([25, 30, 25]);
|
||||
refreshBalance();
|
||||
}
|
||||
} catch (e) {
|
||||
hint.textContent = e.message;
|
||||
@@ -236,131 +349,7 @@ async function onAction() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- rendering ----------------
|
||||
* Each game draws the same climb differently: a rocket fighting gravity, a
|
||||
* craft spiralling inward, or a tower stacking upward. */
|
||||
|
||||
const canvas = $('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let stars = [];
|
||||
|
||||
function sizeCanvas() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = canvas.clientHeight * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stars = Array.from({ length: 70 }, () => ({
|
||||
x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3,
|
||||
}));
|
||||
}
|
||||
window.addEventListener('resize', sizeCanvas);
|
||||
|
||||
function draw(s) {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (!w || !h) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const m = parseFloat(s.multiplier) || 1;
|
||||
const crashed = s.state === 'settled';
|
||||
const progress = Math.min(1, Math.log(m) / Math.log(12));
|
||||
|
||||
// Scrolling grid: a horizon that rushes past as the multiplier climbs.
|
||||
ctx.strokeStyle = '#0a7a5233';
|
||||
ctx.lineWidth = 1;
|
||||
const spacing = 34;
|
||||
const offset = (progress * 260) % spacing;
|
||||
for (let x = 0; x <= w; x += spacing) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||
}
|
||||
for (let y = -spacing + offset; y <= h; y += spacing) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Sparse drifting particles, phosphor green.
|
||||
ctx.fillStyle = '#00ff9c';
|
||||
for (const st of stars) {
|
||||
const y = (st.y + progress * 0.9) % 1;
|
||||
ctx.globalAlpha = 0.12 + st.r * 0.18;
|
||||
ctx.fillRect(st.x * w, y * h, st.r, st.r);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (currentGame === 'orbital') drawOrbital(w, h, progress, crashed);
|
||||
else if (currentGame === 'tower') drawTower(w, h, progress, crashed);
|
||||
else drawRocket(w, h, progress, crashed);
|
||||
}
|
||||
|
||||
function drawRocket(w, h, p, crashed) {
|
||||
const x = w * 0.5;
|
||||
const y = h * (0.88 - p * 0.66);
|
||||
const accent = crashed ? '#ff3355' : '#00ff9c';
|
||||
|
||||
// Exhaust plume: longer and more agitated as the climb steepens.
|
||||
const plume = 26 + p * 60;
|
||||
const g = ctx.createLinearGradient(x, y, x, y + plume);
|
||||
g.addColorStop(0, crashed ? '#ff3355aa' : '#00ff9ccc');
|
||||
g.addColorStop(1, '#00ff9c00');
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 7, y + 8);
|
||||
ctx.lineTo(x + 7, y + 8);
|
||||
ctx.lineTo(x + (Math.random() - 0.5) * 8, y + plume);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = accent;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - 18);
|
||||
ctx.lineTo(x + 9, y + 10);
|
||||
ctx.lineTo(x - 9, y + 10);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
if (crashed) {
|
||||
ctx.strokeStyle = '#ff335588';
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const a = (i / 9) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + Math.cos(a) * 34, y + Math.sin(a) * 34);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawOrbital(w, h, p, crashed) {
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const planet = Math.min(w, h) * 0.16;
|
||||
ctx.fillStyle = '#00291c';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, planet, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
const orbit = planet + 12 + (1 - p) * Math.min(w, h) * 0.26;
|
||||
ctx.strokeStyle = crashed ? '#ff335555' : '#00ff9c55';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
|
||||
|
||||
const a = p * Math.PI * 9;
|
||||
const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit;
|
||||
ctx.fillStyle = crashed ? '#ff3355' : '#00ff9c';
|
||||
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function drawTower(w, h, p, crashed) {
|
||||
const blocks = Math.floor(p * 16) + 1;
|
||||
const bw = w * 0.28, bh = h * 0.05;
|
||||
for (let i = 0; i < blocks; i++) {
|
||||
const sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1);
|
||||
const y = h * 0.9 - (i + 1) * bh;
|
||||
ctx.fillStyle = i === blocks - 1
|
||||
? (crashed ? '#ff3355' : '#00ff9c')
|
||||
: `hsl(${160 - i} 90% ${12 + i}%)`;
|
||||
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- ambient sound ----------------
|
||||
* Synthesised, so it never loops and ships no audio files. */
|
||||
/* ---------------- ambient sound ---------------- */
|
||||
|
||||
let audio = null;
|
||||
|
||||
@@ -391,23 +380,6 @@ function toggleSound() {
|
||||
$('sound-toggle').classList.add('on');
|
||||
}
|
||||
|
||||
let lastTone = 0;
|
||||
function tone(mult) {
|
||||
if (!audio) return;
|
||||
const now = audio.currentTime;
|
||||
if (now - lastTone < 0.28) return;
|
||||
lastTone = now;
|
||||
const o = audio.createOscillator();
|
||||
const g = audio.createGain();
|
||||
o.type = 'triangle';
|
||||
o.frequency.value = 220 * Math.min(4, mult);
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.03, now + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
|
||||
o.connect(g).connect(audio.destination);
|
||||
o.start(now); o.stop(now + 0.3);
|
||||
}
|
||||
|
||||
/* ---------------- scratch tickets ---------------- */
|
||||
|
||||
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
||||
@@ -426,7 +398,7 @@ async function loadScratch() {
|
||||
const result = el('div', { class: 'result' });
|
||||
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
||||
button.dataset.play = t.id;
|
||||
button.onclick = () => playScratch(t, grid, result);
|
||||
button.onclick = () => playScratch(t, grid, result, button);
|
||||
|
||||
const table = el('table', { class: 'odds' },
|
||||
el('tr', {},
|
||||
@@ -454,9 +426,10 @@ async function loadScratch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function playScratch(t, grid, result) {
|
||||
async function playScratch(t, grid, result, button) {
|
||||
result.textContent = '';
|
||||
result.className = 'result';
|
||||
button.disabled = true;
|
||||
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
||||
|
||||
let out;
|
||||
@@ -464,11 +437,16 @@ async function playScratch(t, grid, result) {
|
||||
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
||||
} catch (e) {
|
||||
result.textContent = e.message;
|
||||
button.disabled = false;
|
||||
return;
|
||||
}
|
||||
setBalance(out.balance_msat);
|
||||
stats.wagered += stake;
|
||||
stats.plays++;
|
||||
if (out.outcome.payout_msat > stats.best) stats.best = out.outcome.payout_msat;
|
||||
if (out.outcome.payout_msat > 0) stats.wins++; else stats.losses++;
|
||||
saveStats();
|
||||
|
||||
// Reveal cells one at a time — the outcome is already fixed, this is pacing.
|
||||
const cells = out.outcome.cells;
|
||||
const counts = {};
|
||||
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
||||
@@ -479,17 +457,47 @@ async function playScratch(t, grid, result) {
|
||||
const cell = grid.children[i];
|
||||
cell.textContent = SYMBOLS[sym];
|
||||
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
||||
buzz(8);
|
||||
if (i === cells.length - 1) {
|
||||
const won = out.outcome.payout_msat > 0;
|
||||
result.className = 'result' + (won ? ' win' : '');
|
||||
result.textContent = won
|
||||
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
||||
: 'No win this time';
|
||||
if (won) buzz([40, 50, 40]);
|
||||
button.disabled = false;
|
||||
}
|
||||
}, i * 130);
|
||||
}, i * 120);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- portfolio ---------------- */
|
||||
|
||||
function renderPortfolio() {
|
||||
$('t-balance').textContent = sats(balanceMsat);
|
||||
charts.sparkline($('spark-balance'), stats.balances.slice(-40));
|
||||
|
||||
const delta = stats.sessionStart == null ? 0 : balanceMsat - stats.sessionStart;
|
||||
const sess = $('t-session');
|
||||
sess.textContent = (delta >= 0 ? '+' : '') + sats(delta);
|
||||
sess.className = 'tile-value ' + (delta > 0 ? 'up' : delta < 0 ? 'down' : '');
|
||||
|
||||
$('t-wagered').textContent = sats(stats.wagered);
|
||||
$('t-plays').textContent = `${stats.plays} play${stats.plays === 1 ? '' : 's'}`;
|
||||
$('t-best').textContent = sats(stats.best);
|
||||
|
||||
charts.balanceChart($('chart-balance'),
|
||||
stats.balances.map((b) => ({ BalanceAfter: b })));
|
||||
charts.winLossDonut($('chart-donut'), stats.wins, stats.losses);
|
||||
charts.distributionChart($('chart-dist'), stats.crashes);
|
||||
charts.crashHistoryChart($('chart-history'), stats.crashes);
|
||||
|
||||
const total = stats.wins + stats.losses;
|
||||
$('donut-note').textContent = total
|
||||
? `${stats.wins} cashed out, ${stats.losses} rode into the crash, across ${total} rounds.`
|
||||
: '';
|
||||
}
|
||||
|
||||
/* ---------------- wallet ---------------- */
|
||||
|
||||
async function loadHistory() {
|
||||
@@ -524,8 +532,7 @@ async function sendSats() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- verifier ----------------
|
||||
* Recomputed here, in the browser, from published values only. */
|
||||
/* ---------------- verifier ---------------- */
|
||||
|
||||
async function sha256(bytes) {
|
||||
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||||
@@ -613,7 +620,11 @@ function selectTab(view) {
|
||||
t.classList.toggle('active', t.dataset.view === view));
|
||||
document.querySelectorAll('.view').forEach((v) =>
|
||||
(v.hidden = v.id !== 'view-' + view));
|
||||
|
||||
// The 3D loop only runs while its view is visible.
|
||||
if (scene) { if (view === 'crash') scene.start(); else scene.stop(); }
|
||||
if (view === 'wallet') loadHistory();
|
||||
if (view === 'portfolio') renderPortfolio();
|
||||
}
|
||||
|
||||
function setStake(v) {
|
||||
@@ -626,7 +637,6 @@ function setStake(v) {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
sizeCanvas();
|
||||
keypair = await loadOrCreateKey();
|
||||
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
||||
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
||||
@@ -644,7 +654,14 @@ async function init() {
|
||||
(t.onclick = () => selectTab(t.dataset.view)));
|
||||
document.querySelectorAll('.chip').forEach((c) =>
|
||||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||
|
||||
$('auto-target').oninput = refreshAutoRow;
|
||||
document.querySelectorAll('[data-auto]').forEach((b) => {
|
||||
b.onclick = () => {
|
||||
$('auto-target').value = b.dataset.auto;
|
||||
refreshAutoRow();
|
||||
};
|
||||
});
|
||||
|
||||
const pick = $('gamepick');
|
||||
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||||
@@ -658,6 +675,7 @@ async function init() {
|
||||
});
|
||||
|
||||
setStake(stake);
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user