mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 08:08:32 +02:00
feat: productionize paid modes and PvP capture
This commit is contained in:
+216
-41
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Dashboard - Wowngeon</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
@@ -162,8 +161,13 @@
|
||||
|
||||
.status-badge { padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
|
||||
.status-pending { background: #f59e0b20; color: #f59e0b; }
|
||||
.status-requested { background: #f59e0b20; color: #f59e0b; }
|
||||
.status-processing { background: #3b82f620; color: #60a5fa; }
|
||||
.status-failed { background: #ef444420; color: #ef4444; }
|
||||
.status-needs_review { background: #ef444440; color: #fca5a5; border: 1px solid #ef4444; }
|
||||
.status-completed { background: #22c55e20; color: #22c55e; }
|
||||
.status-recorded { background: #6b728020; color: #9ca3af; }
|
||||
.status-batched { background: #8b5cf620; color: #a78bfa; }
|
||||
.status-permanently_failed { background: #7f1d1d; color: #fca5a5; }
|
||||
.status-won { background: #22c55e20; color: #22c55e; }
|
||||
.status-lost { background: #ef444420; color: #ef4444; }
|
||||
@@ -270,6 +274,16 @@
|
||||
<div id="statPending" class="value">-</div>
|
||||
<div id="statFailed" class="subtext">0 failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Payout Review</h3>
|
||||
<div id="statPayoutReview" class="value">-</div>
|
||||
<div id="statPayoutProcessing" class="subtext">0 processing</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Refund Attention</h3>
|
||||
<div id="statRefundAttention" class="value">-</div>
|
||||
<div id="statRefundBreakdown" class="subtext">0 requested · 0 processing</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Wallet Balance</h3>
|
||||
<div id="statBalance" class="value">-</div>
|
||||
@@ -282,6 +296,7 @@
|
||||
<button class="tab-btn active" data-tab="overview">Overview</button>
|
||||
<button class="tab-btn" data-tab="queue">Queue</button>
|
||||
<button class="tab-btn" data-tab="payouts">Payouts</button>
|
||||
<button class="tab-btn" data-tab="refunds">Refunds</button>
|
||||
<button class="tab-btn" data-tab="games">Games</button>
|
||||
<button class="tab-btn" data-tab="users">Users</button>
|
||||
<button class="tab-btn" data-tab="chat">Chat</button>
|
||||
@@ -330,12 +345,19 @@
|
||||
<h3>Payouts</h3>
|
||||
<select id="payoutStatusFilter">
|
||||
<option value="">All Status</option>
|
||||
<option value="attention">Needs Attention</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="needs_review">Needs Review</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="permanently_failed">Permanently Failed</option>
|
||||
<option value="batched">Batched</option>
|
||||
</select>
|
||||
</div>
|
||||
<p id="payoutTotals" style="color:#888; margin: 10px 15px; font-size:12px;">
|
||||
Loading payout totals...
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -344,11 +366,52 @@
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>TX Hash</th>
|
||||
<th>Last Error</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="payoutsTable">
|
||||
<tr><td colspan="8" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Refunds -->
|
||||
<div id="tab-refunds" class="tab-content">
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<h3>Payment Refund Outbox</h3>
|
||||
<select id="refundStatusFilter">
|
||||
<option value="attention">Needs Attention</option>
|
||||
<option value="">All Status</option>
|
||||
<option value="requested">Requested</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="needs_review">Needs Review</option>
|
||||
<option value="recorded">Recorded (no transfer)</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<p id="refundTotals" style="color:#888; margin: 10px 15px; font-size:12px;">
|
||||
Loading refund totals...
|
||||
</p>
|
||||
<p style="color:#f59e0b; margin: 10px 15px; font-size:12px;">
|
||||
Processing and needs-review transfers may already have broadcast. Never resend them from this dashboard.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Payment</th>
|
||||
<th>Amount</th>
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>TX / Error</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="refundsTable">
|
||||
<tr><td colspan="7" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -470,6 +533,7 @@
|
||||
|
||||
// Payout filter
|
||||
document.getElementById('payoutStatusFilter').addEventListener('change', loadPayouts);
|
||||
document.getElementById('refundStatusFilter').addEventListener('change', loadRefunds);
|
||||
|
||||
// User search on enter
|
||||
document.getElementById('userSearch').addEventListener('keypress', (e) => {
|
||||
@@ -535,6 +599,7 @@
|
||||
// Load data for tab
|
||||
if (tab === 'queue') loadQueue();
|
||||
else if (tab === 'payouts') loadPayouts();
|
||||
else if (tab === 'refunds') loadRefunds();
|
||||
else if (tab === 'games') loadGames();
|
||||
else if (tab === 'users') loadUsers();
|
||||
else if (tab === 'chat') loadChat();
|
||||
@@ -558,11 +623,27 @@
|
||||
document.getElementById('statWinRate').textContent = `${winRate}% win rate`;
|
||||
|
||||
const pending = overview.pendingPayouts || 0;
|
||||
const processing = overview.processingPayouts || 0;
|
||||
const failed = overview.failedPayouts || 0;
|
||||
const needsReview = overview.payoutsNeedsReview || 0;
|
||||
const permanentlyFailed = overview.permanentlyFailedPayouts || 0;
|
||||
document.getElementById('statPending').textContent = pending;
|
||||
document.getElementById('statPending').className = 'value' + (pending > 5 ? ' warning' : '');
|
||||
document.getElementById('statFailed').textContent = `${failed} failed`;
|
||||
document.getElementById('statFailed').className = 'subtext' + (failed > 0 ? ' danger' : '');
|
||||
document.getElementById('statFailed').textContent = `${failed} failed · ${permanentlyFailed} permanent`;
|
||||
document.getElementById('statFailed').className = 'subtext' + (failed + permanentlyFailed > 0 ? ' danger' : '');
|
||||
document.getElementById('statPayoutReview').textContent = needsReview;
|
||||
document.getElementById('statPayoutReview').className = 'value' + (needsReview > 0 ? ' danger' : '');
|
||||
document.getElementById('statPayoutProcessing').textContent = `${processing} processing`;
|
||||
document.getElementById('statPayoutProcessing').className = 'subtext' + (processing > 0 ? ' warning' : '');
|
||||
|
||||
const refundRequested = overview.refunds?.requested || 0;
|
||||
const refundProcessing = overview.refunds?.processing || 0;
|
||||
const refundReview = overview.refunds?.needsReview || 0;
|
||||
const refundAttention = refundRequested + refundProcessing + refundReview;
|
||||
document.getElementById('statRefundAttention').textContent = refundAttention;
|
||||
document.getElementById('statRefundAttention').className = 'value' + (refundAttention > 0 ? ' danger' : '');
|
||||
document.getElementById('statRefundBreakdown').textContent =
|
||||
`${refundRequested} requested · ${refundProcessing} processing · ${refundReview} review`;
|
||||
|
||||
// Update divisor and symbol from server
|
||||
if (overview.atomicDivisor) atomicDivisor = overview.atomicDivisor;
|
||||
@@ -615,31 +696,69 @@
|
||||
}
|
||||
|
||||
function renderOutcomesChart(summary) {
|
||||
const ctx = document.getElementById('outcomesChart').getContext('2d');
|
||||
const canvas = document.getElementById('outcomesChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (outcomesChart) outcomesChart.destroy();
|
||||
const labels = ['Won/Escaped', 'Caught', 'Expired', 'Other'];
|
||||
const values = [
|
||||
(summary.won || 0) + (summary.escaped || 0),
|
||||
summary.caught || 0,
|
||||
summary.expired || 0,
|
||||
Math.max(0, (summary.total || 0) - (summary.won || 0) - (summary.escaped || 0) - (summary.caught || 0) - (summary.expired || 0))
|
||||
];
|
||||
const colors = ['#22c55e', '#ef4444', '#f59e0b', '#6b7280'];
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const cssWidth = Math.max(320, canvas.clientWidth || 480);
|
||||
const cssHeight = Math.max(200, canvas.clientHeight || 240);
|
||||
canvas.width = Math.round(cssWidth * dpr);
|
||||
canvas.height = Math.round(cssHeight * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
outcomesChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Won/Escaped', 'Caught', 'Expired', 'Other'],
|
||||
datasets: [{
|
||||
data: [
|
||||
(summary.won || 0) + (summary.escaped || 0),
|
||||
summary.caught || 0,
|
||||
summary.expired || 0,
|
||||
Math.max(0, (summary.total || 0) - (summary.won || 0) - (summary.escaped || 0) - (summary.caught || 0) - (summary.expired || 0))
|
||||
],
|
||||
backgroundColor: ['#22c55e', '#ef4444', '#f59e0b', '#6b7280']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { color: '#888' } }
|
||||
}
|
||||
}
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
const centerX = Math.min(cssWidth * 0.34, cssHeight * 0.5);
|
||||
const centerY = cssHeight * 0.5;
|
||||
const radius = Math.max(45, Math.min(cssHeight * 0.36, centerX - 12));
|
||||
let angle = -Math.PI / 2;
|
||||
values.forEach((value, index) => {
|
||||
if (value <= 0 || total <= 0) return;
|
||||
const next = angle + (value / total) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, angle, next);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = colors[index];
|
||||
ctx.fill();
|
||||
angle = next;
|
||||
});
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius * 0.58, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.font = '600 18px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(total), centerX, centerY - 5);
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.font = '11px system-ui, sans-serif';
|
||||
ctx.fillText('games', centerX, centerY + 14);
|
||||
|
||||
const legendX = Math.max(centerX + radius + 28, cssWidth * 0.62);
|
||||
labels.forEach((label, index) => {
|
||||
const y = centerY - 54 + index * 34;
|
||||
ctx.fillStyle = colors[index];
|
||||
ctx.fillRect(legendX, y - 6, 12, 12);
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText(`${label}: ${values[index]}`, legendX + 20, y);
|
||||
});
|
||||
|
||||
outcomesChart = {
|
||||
destroy: () => ctx.clearRect(0, 0, cssWidth, cssHeight)
|
||||
};
|
||||
}
|
||||
|
||||
// Queue management functions
|
||||
@@ -729,37 +848,50 @@
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('payoutsTable');
|
||||
const totals = data.totals || {};
|
||||
document.getElementById('payoutTotals').textContent =
|
||||
`${totals.pending || 0} pending · ${totals.processing || 0} processing · ` +
|
||||
`${totals.failed || 0} failed · ${totals.needsReview || 0} needs review · ` +
|
||||
`${totals.permanentlyFailed || 0} permanently failed · ${totals.completed || 0} completed`;
|
||||
if (!data.payouts?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="loading">No payouts found</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="loading">No payouts found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.payouts.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${(p.amount / atomicDivisor).toFixed(6)}</td>
|
||||
<td title="${escapeHtml(p.address || '')}">${p.address ? escapeHtml(p.address.substring(0, 12)) + '...' : '-'}</td>
|
||||
<td><span class="status-badge status-${escapeHtml(p.status)}">${escapeHtml(p.status)}</span></td>
|
||||
<td class="tx-hash">${p.txHash ? escapeHtml(p.txHash.substring(0, 16)) + '...' : '-'}</td>
|
||||
<td>${new Date(p.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
${p.status === 'failed' || p.status === 'permanently_failed' ?
|
||||
`<button class="btn btn-primary" onclick="retryPayout(${p.id})">Retry</button>` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
tbody.innerHTML = data.payouts.map(p => {
|
||||
const retryable = (p.status === 'failed' || p.status === 'permanently_failed') && !p.txHash;
|
||||
const lastError = p.lastError || '';
|
||||
return `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td title="${escapeHtml(String(p.amount))} atomic">${(p.amount / atomicDivisor).toFixed(6)}</td>
|
||||
<td title="${escapeHtml(p.address || '')}">${p.address ? escapeHtml(p.address.substring(0, 12)) + '...' : '-'}</td>
|
||||
<td><span class="status-badge status-${escapeHtml(p.status)}">${escapeHtml(p.status)}</span></td>
|
||||
<td class="tx-hash">${p.txHash ? escapeHtml(p.txHash.substring(0, 16)) + '...' : '-'}</td>
|
||||
<td title="${escapeHtml(lastError)}">${lastError ? escapeHtml(lastError.substring(0, 48)) : '-'}</td>
|
||||
<td>${new Date(p.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
${retryable ? `<button class="btn btn-primary" onclick="retryPayout(${p.id})">Retry</button>` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load payouts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPayout(id) {
|
||||
if (!confirm('Queue this payout for retry?')) return;
|
||||
if (!confirm('Only retry after confirming the recorded failure was pre-broadcast and wallet history contains no matching transfer. Queue this payout?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/payouts/${id}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Admin-Key': apiKey }
|
||||
headers: {
|
||||
'X-Admin-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ confirm: true })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -774,6 +906,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRefunds() {
|
||||
const status = document.getElementById('refundStatusFilter').value;
|
||||
const url = `/api/admin/stats/refunds?limit=50${status ? `&status=${status}` : ''}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'X-Admin-Key': apiKey } });
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('refundsTable');
|
||||
const totals = data.totals || {};
|
||||
document.getElementById('refundTotals').textContent =
|
||||
`${totals.requested || 0} requested · ${totals.processing || 0} processing · ` +
|
||||
`${totals.needsReview || 0} needs review · ${totals.recorded || 0} recorded · ` +
|
||||
`${totals.completed || 0} completed`;
|
||||
|
||||
if (!data.refunds?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="loading">No refunds found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.refunds.map(refund => {
|
||||
const evidence = refund.txHash
|
||||
? `TX ${refund.txHash.substring(0, 16)}...`
|
||||
: (refund.error || refund.reason || '-');
|
||||
const updated = refund.updatedAt || refund.createdAt;
|
||||
return `
|
||||
<tr>
|
||||
<td>${refund.id}</td>
|
||||
<td>${refund.paymentId}</td>
|
||||
<td title="${escapeHtml(String(refund.amount))} atomic">${(refund.amount / atomicDivisor).toFixed(6)}</td>
|
||||
<td title="${escapeHtml(refund.address || '')}">${refund.address ? escapeHtml(refund.address.substring(0, 12)) + '...' : '-'}</td>
|
||||
<td><span class="status-badge status-${escapeHtml(refund.status)}">${escapeHtml(refund.status)}</span></td>
|
||||
<td title="${escapeHtml(evidence)}">${escapeHtml(evidence.substring(0, 56))}</td>
|
||||
<td>${new Date(updated).toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load refunds:', e);
|
||||
document.getElementById('refundsTable').innerHTML =
|
||||
'<tr><td colspan="7" class="loading">Failed to load refunds</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGames() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/stats/games?period=24h&limit=50', {
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Socket.io -->
|
||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
|
||||
<!-- Initialize socket -->
|
||||
<script>
|
||||
@@ -51,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<!-- Core libraries -->
|
||||
<script src="js/lib/jquery-3.4.1.min.js"></script>
|
||||
<script src="js/lib/jquery-3.7.1.min.js"></script>
|
||||
<script src="js/lib/rot.min.js"></script>
|
||||
<script src="js/core/options.js"></script>
|
||||
<script src="js/ui/teletype.js"></script>
|
||||
|
||||
+27
-13
@@ -2,13 +2,14 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wownerogue - A Wownero Roguelike</title>
|
||||
<link rel="stylesheet" type="text/css" href="styles/stylesheet.css">
|
||||
<link rel="stylesheet" type="text/css" href="styles/commerce-consent.css">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" id="favicon">
|
||||
|
||||
<!-- Socket.io -->
|
||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
|
||||
<!-- Initialize socket with resume token (for persistent anonymous session) -->
|
||||
<script>
|
||||
@@ -24,8 +25,7 @@
|
||||
if (existing) {
|
||||
// Send the session token in the handshake `auth` payload, NOT the URL
|
||||
// query string. Query strings leak into proxy/access/referer logs;
|
||||
// auth is carried in the connection body. (Server keeps a query-string
|
||||
// fallback for older clients during rollout.)
|
||||
// auth is carried in the connection body.
|
||||
opts.auth = { resumeToken: existing };
|
||||
}
|
||||
} catch(_) {
|
||||
@@ -46,9 +46,14 @@
|
||||
if (data.hostedBy && data.hostedBy.url) {
|
||||
var container = document.getElementById('hosted-by-container');
|
||||
if (container) {
|
||||
container.innerHTML = ' | Hosted by: <a href="' +
|
||||
data.hostedBy.url + '" target="_blank" rel="noopener" style="color:#888;">' +
|
||||
(data.hostedBy.name || data.hostedBy.url) + '</a>';
|
||||
container.textContent = ' | Hosted by: ';
|
||||
var hostedLink = document.createElement('a');
|
||||
hostedLink.href = data.hostedBy.url;
|
||||
hostedLink.target = '_blank';
|
||||
hostedLink.rel = 'noopener';
|
||||
hostedLink.style.color = '#888';
|
||||
hostedLink.textContent = data.hostedBy.name || data.hostedBy.url;
|
||||
container.appendChild(hostedLink);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -58,9 +63,10 @@
|
||||
</script>
|
||||
|
||||
<!-- Core libraries -->
|
||||
<script src="js/lib/jquery-3.4.1.min.js"></script>
|
||||
<script src="js/lib/jquery-3.7.1.min.js"></script>
|
||||
<script src="js/lib/rot.min.js"></script>
|
||||
<script src="js/core/escapeHtml.js"></script>
|
||||
<script src="js/legal/commerceConsent.js"></script>
|
||||
<script src="js/core/options.js"></script>
|
||||
<script src="js/ui/teletype.js"></script>
|
||||
|
||||
@@ -81,6 +87,7 @@
|
||||
<script src="js/render/charSprites.js"></script>
|
||||
<script src="js/render/packRegistry.js"></script>
|
||||
<script src="js/render/assetPacks.js"></script>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/render/charCustomize.js"></script>
|
||||
<script src="js/render/avatarVisuals.js"></script>
|
||||
@@ -165,7 +172,7 @@
|
||||
<button id="helpButton" class="hud-chip hud-chip--help hud-chip--push" title="How to play and payment information">❓ Help</button>
|
||||
<button id="historyButton" class="hud-chip hud-chip--history" title="View your payment and payout history">📜 History</button>
|
||||
<button id="leaderboardButton" class="hud-chip hud-chip--scores" title="View high scores">🏆 Scores</button>
|
||||
<button id="manageAddressButton" class="hud-chip hud-chip--address" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
|
||||
<button id="manageAddressButton" class="hud-chip hud-chip--address" style="display:none;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
|
||||
<span id="sessionWarning" style="display:none;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,6 +188,12 @@
|
||||
<div class="game-modes" style="margin: 15px 0;">
|
||||
<div style="color: #888; text-align: center; padding: 20px;">Loading payment options...</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:11px;color:#aaa;line-height:1.45;">
|
||||
Paid actions require a current acknowledgement. Review the <a href="/terms" target="_blank" rel="noopener">Terms</a>,
|
||||
<a href="/privacy" target="_blank" rel="noopener">Privacy Notice</a>, and
|
||||
<a href="/responsible-play" target="_blank" rel="noopener">Responsible Play</a> guidance.
|
||||
</p>
|
||||
|
||||
<div id="credits-display" style="margin: 10px 0; display: none;">
|
||||
<strong>Credits remaining: <span id="credits-count">0</span></strong>
|
||||
@@ -273,6 +286,7 @@
|
||||
<div class="footer">
|
||||
<span id="footer-title">Wownero Roguelike v0.1.0 | Use WASD or arrow keys to move</span>
|
||||
<span id="hosted-by-container"></span>
|
||||
<span> | <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/responsible-play">Responsible Play</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -341,9 +355,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px;">
|
||||
<h3 style="color:#4ade80; font-size:1em; margin-bottom:8px;">🎁 Payouts</h3>
|
||||
<p style="color:#ccc; font-size:0.9em; line-height:1.4;">Set your payout address using the "Manage Payout Address" button. When you escape the dungeon, your winnings will be sent automatically. Escaping with the treasure bag earns a higher multiplier!</p>
|
||||
<div id="helpPayoutSection" style="margin-bottom:20px;">
|
||||
<h3 id="helpPayoutTitle" style="color:#4ade80; font-size:1em; margin-bottom:8px;">🎁 Rewards</h3>
|
||||
<p id="helpPayoutCopy" style="color:#ccc; font-size:0.9em; line-height:1.4;">Reward policy is loading…</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:15px;">
|
||||
@@ -352,7 +366,7 @@
|
||||
<span style="color:#60a5fa;">@</span><span>You (the player)</span>
|
||||
<span style="color:#4ade80;">></span><span>Exit - escape here to win</span>
|
||||
<span style="color:#ef4444;">~</span><span>Monster - avoid at all costs</span>
|
||||
<span style="color:#fbbf24;">$</span><span>Treasure - collect for bonus payout</span>
|
||||
<span style="color:#fbbf24;">$</span><span id="helpTreasureLegend">Treasure - collect for a score bonus</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ const InputHandler = {
|
||||
// If the message is "enter", always send it to server
|
||||
// The server will handle queueing logic and timing
|
||||
if (msg.toLowerCase() === 'enter') {
|
||||
socket.emit('chat message', msg);
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
if (!SocketHandlers.emitFairGameStart('join_queue')) return false;
|
||||
} else {
|
||||
socket.emit('chat message', msg);
|
||||
}
|
||||
|
||||
// Don't automatically switch to waiting screen - let the server control screen state
|
||||
// The server will send appropriate events (payment_created, waiting_status, etc.)
|
||||
@@ -157,7 +161,11 @@ const InputHandler = {
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.protocol === 'file:';
|
||||
|
||||
socket.emit('chat message', 'enter');
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
if (!SocketHandlers.emitFairGameStart('join_queue')) return;
|
||||
} else {
|
||||
socket.emit('chat message', 'enter');
|
||||
}
|
||||
|
||||
if (isDebugMode || ScreenManager.canEnterGame()) {
|
||||
// Check if we should show waiting screen
|
||||
@@ -217,7 +225,11 @@ const InputHandler = {
|
||||
}
|
||||
|
||||
// Default: attempt immediate start (payment-required modes go through their own flow)
|
||||
socket.emit('auto_start');
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
if (!SocketHandlers.emitFairGameStart('auto_start')) return;
|
||||
} else {
|
||||
socket.emit('auto_start');
|
||||
}
|
||||
|
||||
// Check if we should show waiting screen
|
||||
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
var policy = null;
|
||||
var policyPromise = null;
|
||||
var memoryAcceptance = null;
|
||||
var STORAGE_KEY = 'wowngeon_paid_ack';
|
||||
var ACCEPTANCE_KEYS = [
|
||||
'policyVersion',
|
||||
'ageEligible',
|
||||
'termsRead',
|
||||
'riskAccepted',
|
||||
'testnetUnderstood'
|
||||
];
|
||||
|
||||
function clearAcceptance() {
|
||||
memoryAcceptance = null;
|
||||
try { sessionStorage.removeItem(STORAGE_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
function loadPolicy(options) {
|
||||
options = options || {};
|
||||
if (options.force === true) {
|
||||
policy = null;
|
||||
policyPromise = null;
|
||||
}
|
||||
if (policy) return Promise.resolve(policy);
|
||||
if (policyPromise) return policyPromise;
|
||||
var request = fetch('/api/disclosures', {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' }
|
||||
}).then(function (response) {
|
||||
if (!response.ok) throw new Error('Disclosure endpoint returned ' + response.status);
|
||||
return response.json();
|
||||
}).then(function (value) {
|
||||
if (!value
|
||||
|| typeof value.policyVersion !== 'string'
|
||||
|| value.policyVersion.length < 1
|
||||
|| value.policyVersion.length > 64
|
||||
|| !value.service) throw new Error('Disclosure response is incomplete');
|
||||
// A forced refresh can supersede an older request. Only the latest in-flight request
|
||||
// may publish its result into the cache.
|
||||
if (policyPromise === request) policy = value;
|
||||
return value;
|
||||
}).catch(function (error) {
|
||||
if (policyPromise === request) policyPromise = null;
|
||||
throw error;
|
||||
});
|
||||
policyPromise = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
function canonicalAcceptance(value) {
|
||||
if (!value || !policy || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
var keys = Object.keys(value);
|
||||
if (keys.length !== ACCEPTANCE_KEYS.length
|
||||
|| keys.some(function (key) { return ACCEPTANCE_KEYS.indexOf(key) === -1; })) return null;
|
||||
if (typeof value.policyVersion !== 'string'
|
||||
|| value.policyVersion.length < 1
|
||||
|| value.policyVersion.length > 64
|
||||
|| value.policyVersion !== policy.policyVersion) return null;
|
||||
if (value.ageEligible !== true || value.termsRead !== true || value.riskAccepted !== true) return null;
|
||||
if (typeof value.testnetUnderstood !== 'boolean'
|
||||
|| value.testnetUnderstood !== (policy.service.isTestNetwork === true)) return null;
|
||||
return {
|
||||
policyVersion: policy.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: policy.service.isTestNetwork === true
|
||||
};
|
||||
}
|
||||
|
||||
function readAcceptance() {
|
||||
var value = memoryAcceptance;
|
||||
try { value = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null') || value; } catch (_) {}
|
||||
var canonical = canonicalAcceptance(value);
|
||||
if (!canonical) {
|
||||
clearAcceptance();
|
||||
return null;
|
||||
}
|
||||
memoryAcceptance = canonical;
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function acknowledgement() {
|
||||
var accepted = readAcceptance();
|
||||
return accepted ? Object.assign({}, accepted) : null;
|
||||
}
|
||||
|
||||
function rememberAcceptance() {
|
||||
memoryAcceptance = {
|
||||
policyVersion: policy.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: policy.service.isTestNetwork === true
|
||||
};
|
||||
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(memoryAcceptance)); } catch (_) {}
|
||||
return acknowledgement();
|
||||
}
|
||||
|
||||
function reject(details) {
|
||||
details = details || {};
|
||||
var reportedVersion = typeof details.policyVersion === 'string'
|
||||
&& details.policyVersion.length > 0
|
||||
&& details.policyVersion.length <= 64
|
||||
? details.policyVersion
|
||||
: null;
|
||||
var changed = reportedVersion && (!policy || reportedVersion !== policy.policyVersion);
|
||||
clearAcceptance();
|
||||
if (changed || details.code === 'PAID_ACK_VERSION') {
|
||||
return loadPolicy({ force: true });
|
||||
}
|
||||
return policy ? Promise.resolve(policy) : loadPolicy();
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function checkbox(id, labelText) {
|
||||
var label = el('label', 'commerce-check');
|
||||
var input = document.createElement('input');
|
||||
input.type = 'checkbox'; input.id = id;
|
||||
label.appendChild(input);
|
||||
label.appendChild(el('span', '', labelText));
|
||||
return { label: label, input: input };
|
||||
}
|
||||
|
||||
function showModal(resolvedPolicy, onAccept) {
|
||||
var existing = document.getElementById('commerceConsentOverlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var overlay = el('div', 'commerce-consent-overlay');
|
||||
overlay.id = 'commerceConsentOverlay';
|
||||
overlay.setAttribute('role', 'presentation');
|
||||
var dialog = el('section', 'commerce-consent-dialog');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.setAttribute('aria-labelledby', 'commerceConsentTitle');
|
||||
|
||||
var title = el('h2', '', 'Review paid-play disclosures');
|
||||
title.id = 'commerceConsentTitle';
|
||||
dialog.appendChild(title);
|
||||
|
||||
var service = resolvedPolicy.service;
|
||||
var summary;
|
||||
if (service.isTestNetwork) {
|
||||
summary = service.currencyLabel + ' is valueless test-network currency. Never send mainnet ' + service.cryptoType + '.';
|
||||
} else if (service.paidPrestigeOnly) {
|
||||
summary = 'This paid mode purchases entry, credits, or products; this server currently offers no cryptocurrency prize.';
|
||||
} else if (service.anyPayoutsEnabled) {
|
||||
summary = 'This server has reward-enabled modes. A paid entry can lose its entire entry value; displayed rewards apply only to qualifying outcomes.';
|
||||
} else {
|
||||
summary = 'Blockchain payments may be irreversible. Verify the asset, network, amount, and address before sending.';
|
||||
}
|
||||
dialog.appendChild(el('p', 'commerce-summary', summary));
|
||||
|
||||
var age = checkbox('commerceAge', 'I confirm I am at least ' + resolvedPolicy.minimumAge + ' and may lawfully use this paid mode where I am located.');
|
||||
var terms = checkbox('commerceTerms', 'I have read the Terms of Use and Privacy Notice, including the anonymous-session and blockchain risks.');
|
||||
var risk = checkbox('commerceRisk', service.paidPrestigeOnly
|
||||
? 'I understand this purchase does not currently pay a cryptocurrency prize and a blockchain transfer may be irreversible.'
|
||||
: 'I understand paid play can lose the entire entry value, and previous outcomes do not predict future results.');
|
||||
var required = [age.input, terms.input, risk.input];
|
||||
dialog.appendChild(age.label);
|
||||
dialog.appendChild(terms.label);
|
||||
dialog.appendChild(risk.label);
|
||||
|
||||
if (service.isTestNetwork) {
|
||||
var testnet = checkbox('commerceTestnet', 'I understand test coins have no intended monetary value and I will not send mainnet funds.');
|
||||
required.push(testnet.input);
|
||||
dialog.appendChild(testnet.label);
|
||||
}
|
||||
|
||||
var links = el('p', 'commerce-links');
|
||||
[
|
||||
['Terms', resolvedPolicy.links.terms],
|
||||
['Privacy', resolvedPolicy.links.privacy],
|
||||
['Responsible play', resolvedPolicy.links.responsiblePlay]
|
||||
].forEach(function (entry, index) {
|
||||
if (index) links.appendChild(document.createTextNode(' · '));
|
||||
var link = el('a', '', entry[0]);
|
||||
link.href = entry[1]; link.target = '_blank'; link.rel = 'noopener';
|
||||
links.appendChild(link);
|
||||
});
|
||||
dialog.appendChild(links);
|
||||
|
||||
var status = el('p', 'commerce-status', 'All boxes are required. This statement does not verify identity, age, or location.');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
dialog.appendChild(status);
|
||||
var actions = el('div', 'commerce-actions');
|
||||
var cancel = el('button', 'commerce-cancel', 'Cancel'); cancel.type = 'button';
|
||||
var accept = el('button', 'commerce-accept', 'Acknowledge and continue'); accept.type = 'button'; accept.disabled = true;
|
||||
actions.appendChild(cancel); actions.appendChild(accept); dialog.appendChild(actions);
|
||||
overlay.appendChild(dialog); document.body.appendChild(overlay);
|
||||
|
||||
function update() { accept.disabled = !required.every(function (input) { return input.checked; }); }
|
||||
required.forEach(function (input) { input.addEventListener('change', update); });
|
||||
cancel.addEventListener('click', function () { clearAcceptance(); overlay.remove(); });
|
||||
accept.addEventListener('click', function () {
|
||||
if (accept.disabled) return;
|
||||
var ack = rememberAcceptance();
|
||||
overlay.remove();
|
||||
onAccept(ack);
|
||||
});
|
||||
age.input.focus();
|
||||
}
|
||||
|
||||
function requireAcknowledgement(onAccept) {
|
||||
loadPolicy().then(function (resolvedPolicy) {
|
||||
var current = acknowledgement();
|
||||
if (current) {
|
||||
onAccept(current);
|
||||
return;
|
||||
}
|
||||
showModal(resolvedPolicy, onAccept);
|
||||
}).catch(function () {
|
||||
clearAcceptance();
|
||||
var existing = document.getElementById('commerceConsentOverlay');
|
||||
if (existing) existing.remove();
|
||||
var overlay = el('div', 'commerce-consent-overlay');
|
||||
overlay.id = 'commerceConsentOverlay';
|
||||
var dialog = el('section', 'commerce-consent-dialog');
|
||||
dialog.setAttribute('role', 'alertdialog');
|
||||
dialog.appendChild(el('h2', '', 'Paid play is unavailable'));
|
||||
dialog.appendChild(el('p', 'commerce-summary', 'The current disclosures could not be loaded. No payment or paid entry was requested. Try again after the connection recovers.'));
|
||||
var close = el('button', 'commerce-cancel', 'Close'); close.type = 'button';
|
||||
close.addEventListener('click', function () { overlay.remove(); });
|
||||
dialog.appendChild(close); overlay.appendChild(dialog); document.body.appendChild(overlay);
|
||||
});
|
||||
}
|
||||
|
||||
function attach(payload) {
|
||||
var result = Object.assign({}, payload || {});
|
||||
var ack = acknowledgement();
|
||||
if (ack) result.legalAcknowledgement = ack;
|
||||
return result;
|
||||
}
|
||||
|
||||
var api = {
|
||||
acknowledgement: acknowledgement,
|
||||
attach: attach,
|
||||
clear: clearAcceptance,
|
||||
loadPolicy: loadPolicy,
|
||||
reject: reject,
|
||||
require: requireAcknowledgement
|
||||
};
|
||||
root.CommerceConsent = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Vendored
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
# jQuery 3.7.1 provenance
|
||||
|
||||
- Upstream: jQuery 3.7.1, OpenJS Foundation, MIT licence (`jquery.org/license`)
|
||||
- Local source: Ubuntu package `libjs-jquery` version `3.7.1+dfsg+~3.5.33-1build1`
|
||||
- Source file: `/usr/share/javascript/jquery/jquery.min.js`
|
||||
- Vendored file SHA-256: `3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260`
|
||||
- Vendored on: 2026-07-21
|
||||
|
||||
The application serves this same-origin copy. No runtime CDN or package-registry request is needed.
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
+333
-85
@@ -1,88 +1,285 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const socket = io({ transports: ['websocket', 'polling'] });
|
||||
var socketOpts = { transports: ['websocket', 'polling'] };
|
||||
try {
|
||||
var resumeToken = localStorage.getItem('wownerogue_token');
|
||||
if (resumeToken) socketOpts.auth = { resumeToken: resumeToken };
|
||||
} catch (_) {}
|
||||
var socket = io(socketOpts);
|
||||
var RK = window.RK || {};
|
||||
|
||||
const els = {
|
||||
var els = {
|
||||
economies: document.getElementById('economies'),
|
||||
status: document.getElementById('status'),
|
||||
queueBtn: document.getElementById('queueBtn'),
|
||||
leaveBtn: document.getElementById('leaveBtn'),
|
||||
queuePanel: document.getElementById('queuePanel'),
|
||||
racePanel: document.getElementById('racePanel'),
|
||||
raceMode: document.getElementById('raceMode'),
|
||||
raceEconomy: document.getElementById('raceEconomy'),
|
||||
raceTimer: document.getElementById('raceTimer'),
|
||||
racePlace: document.getElementById('racePlace'),
|
||||
stage: document.getElementById('stage')
|
||||
raceResult: document.getElementById('raceResult'),
|
||||
stage: document.getElementById('stage'),
|
||||
renderModes: document.getElementById('renderModes'),
|
||||
modeTitle: document.getElementById('modeTitle'),
|
||||
modeDescription: document.getElementById('modeDescription'),
|
||||
modeBadge: document.getElementById('modeBadge'),
|
||||
rulesetCatalog: document.getElementById('rulesetCatalog'),
|
||||
economyHelp: document.getElementById('economyHelp'),
|
||||
objectiveHint: document.getElementById('objectiveHint'),
|
||||
dpad: document.getElementById('matchDpad')
|
||||
};
|
||||
|
||||
let gameModeInfo = null;
|
||||
let selectedEconomy = 'free';
|
||||
let inQueue = false;
|
||||
let inRace = false;
|
||||
let mySocketId = null;
|
||||
let renderer = null;
|
||||
let mode = 'tiles';
|
||||
let keyState = {};
|
||||
var ECONOMIES = {
|
||||
free: { label: 'Free / Pleb', description: 'No entry cost · separate free board for competitive modes', available: 'FREE ENTRY' },
|
||||
credits_prestige: { label: 'Prestige', description: 'Spend credits · PvP Prestige board only', available: 'PAID CREDITS' },
|
||||
crypto_race: { label: 'Stagenet Stakes', description: 'Race-entry ticket · Champions board and the disclosed pot payout', available: 'STAGENET / CRYPTO' }
|
||||
};
|
||||
var MatchCopy = window.MatchUiCopy;
|
||||
|
||||
function setStatus(msg) {
|
||||
els.status.textContent = msg;
|
||||
var gameModeInfo = null;
|
||||
var activeRuleset = MatchCopy.normalize({ id: 'race' });
|
||||
var selectedEconomy = 'free';
|
||||
var inQueue = false;
|
||||
var inRace = false;
|
||||
var mySocketId = socket.id || null;
|
||||
var renderer = null;
|
||||
var rendererPending = false;
|
||||
var rendererToken = 0;
|
||||
var camera = null;
|
||||
var mode = (RK.loadMode && RK.loadMode('tiles')) || 'tiles';
|
||||
var keyState = {};
|
||||
var lastState = null;
|
||||
var tickMs = 250;
|
||||
var countdownTimer = null;
|
||||
var resultTimer = null;
|
||||
|
||||
function setStatus(msg) { els.status.textContent = msg; }
|
||||
function verifyMatchReveal(fairness) {
|
||||
if (!fairness || !fairness.seed || !/^[0-9a-f]{64}$/i.test(fairness.seedHash || '')) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fairness.seed))
|
||||
.then(function (digest) {
|
||||
var actual = Array.from(new Uint8Array(digest))
|
||||
.map(function (byte) { return byte.toString(16).padStart(2, '0'); }).join('');
|
||||
return actual === String(fairness.seedHash).toLowerCase();
|
||||
}).catch(function () { return null; });
|
||||
}
|
||||
function matchInfo() {
|
||||
return gameModeInfo && gameModeInfo.modes && gameModeInfo.modes.match;
|
||||
}
|
||||
function economyLabel(id) { return (ECONOMIES[id] && ECONOMIES[id].label) || id || 'Match'; }
|
||||
|
||||
function normalizedRuleset(raw) {
|
||||
return MatchCopy.normalize(raw);
|
||||
}
|
||||
|
||||
function applyRuleset(raw) {
|
||||
activeRuleset = normalizedRuleset(raw);
|
||||
els.modeTitle.textContent = activeRuleset.label;
|
||||
els.modeDescription.textContent = activeRuleset.description;
|
||||
els.modeBadge.textContent = activeRuleset.badge || 'LIVE MATCH';
|
||||
els.objectiveHint.textContent = 'Arrow keys / WASD · ' + activeRuleset.objective + ' · drag or pinch the camera';
|
||||
els.economyHelp.textContent = activeRuleset.cooperative
|
||||
? 'Co-op is a team result and does not create an individual leaderboard winner.'
|
||||
: 'Free, Prestige, and Champions results remain on separate boards.';
|
||||
}
|
||||
|
||||
function renderRulesets() {
|
||||
var info = matchInfo() || {};
|
||||
applyRuleset(info.activeRuleset || { id: info.rulesetId || 'race' });
|
||||
els.rulesetCatalog.innerHTML = '';
|
||||
var catalog = Array.isArray(info.rulesets) && info.rulesets.length ? info.rulesets : [activeRuleset];
|
||||
catalog.forEach(function (raw) {
|
||||
var r = normalizedRuleset(raw);
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'tag' + (r.id === activeRuleset.id ? ' active' : '');
|
||||
chip.textContent = r.label;
|
||||
chip.title = r.id === activeRuleset.id ? 'Active on this server' : 'Available to the operator';
|
||||
els.rulesetCatalog.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEconomies() {
|
||||
if (!gameModeInfo || !gameModeInfo.modes || !gameModeInfo.modes.match || !gameModeInfo.modes.match.enabled) {
|
||||
var info = matchInfo();
|
||||
if (!info || !info.enabled) {
|
||||
els.economies.innerHTML = '';
|
||||
setStatus('Match mode is not enabled on this server.');
|
||||
setStatus('Multiplayer mode is not enabled on this server.');
|
||||
els.queueBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const available = gameModeInfo.modes.match.economies || {};
|
||||
const labels = {
|
||||
free: 'Free',
|
||||
credits_prestige: 'Prestige Credits',
|
||||
crypto_race: 'Crypto Race'
|
||||
};
|
||||
|
||||
var available = info.economies || {};
|
||||
var availableKeys = Object.keys(ECONOMIES).filter(function (key) { return !!available[key]; });
|
||||
if (!available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0];
|
||||
els.economies.innerHTML = '';
|
||||
Object.keys(labels).forEach(function (key) {
|
||||
const locked = !available[key];
|
||||
const el = document.createElement('div');
|
||||
el.className = 'economy' + (key === selectedEconomy ? ' active' : '') + (locked ? ' locked' : '');
|
||||
el.textContent = labels[key];
|
||||
if (!locked) {
|
||||
el.addEventListener('click', function () {
|
||||
selectedEconomy = key;
|
||||
renderEconomies();
|
||||
});
|
||||
}
|
||||
els.economies.appendChild(el);
|
||||
Object.keys(ECONOMIES).forEach(function (key) {
|
||||
var def = ECONOMIES[key];
|
||||
var locked = !available[key];
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'economy' + (key === selectedEconomy && !locked ? ' active' : '') + (locked ? ' locked' : '');
|
||||
button.dataset.economy = key;
|
||||
button.disabled = locked;
|
||||
button.setAttribute('aria-pressed', key === selectedEconomy && !locked ? 'true' : 'false');
|
||||
var title = document.createElement('strong'); title.textContent = def.label;
|
||||
var desc = document.createElement('small'); desc.textContent = def.description;
|
||||
var avail = document.createElement('small'); avail.className = 'availability'; avail.textContent = locked ? 'NOT ENABLED' : def.available;
|
||||
button.appendChild(title); button.appendChild(desc); button.appendChild(avail);
|
||||
if (!locked) button.addEventListener('click', function () {
|
||||
selectedEconomy = key;
|
||||
renderEconomies();
|
||||
});
|
||||
els.economies.appendChild(button);
|
||||
});
|
||||
els.queueBtn.disabled = availableKeys.length === 0;
|
||||
}
|
||||
|
||||
els.queueBtn.disabled = false;
|
||||
function renderModeButtons() {
|
||||
if (!els.renderModes || !RK.RENDER_MODES) return;
|
||||
els.renderModes.innerHTML = '';
|
||||
RK.RENDER_MODES.forEach(function (meta) {
|
||||
var availability = RK.modeAvailability
|
||||
? RK.modeAvailability(meta.id)
|
||||
: { usable: !RK.canUseMode || RK.canUseMode(meta.id), runtimeAvailable: true };
|
||||
var usable = availability.usable;
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.textContent = meta.label
|
||||
+ (!availability.runtimeAvailable ? ' ⛔' : (meta.premium ? ' ★' : ''));
|
||||
b.className = (meta.id === mode ? 'active ' : '') + (usable ? '' : 'locked');
|
||||
b.disabled = !usable;
|
||||
if (!availability.runtimeAvailable) {
|
||||
b.title = 'Unavailable on this server: optional renderer dependency disabled.';
|
||||
} else if (!usable) b.title = 'Unlock a compatible render pack with credits.';
|
||||
b.addEventListener('click', function () { switchRenderMode(meta.id); });
|
||||
els.renderModes.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function defaultZoomForMode(name) { return name === 'iso' ? 0.82 : (name === '3d' ? 1.15 : 1.45); }
|
||||
function ensureCamera() {
|
||||
if (!camera && RK.attachCamera) camera = RK.attachCamera(els.stage, { zoom: defaultZoomForMode(mode), min: 0.5, max: 3.2 });
|
||||
return camera;
|
||||
}
|
||||
function destroyRenderer() {
|
||||
rendererToken++;
|
||||
rendererPending = false;
|
||||
if (renderer && renderer.destroy) { try { renderer.destroy(); } catch (_) {} }
|
||||
renderer = null;
|
||||
}
|
||||
function ensureRenderer() {
|
||||
if (renderer || rendererPending) return;
|
||||
rendererPending = true;
|
||||
var token = ++rendererToken;
|
||||
var done = function (next) {
|
||||
if (token !== rendererToken) { if (next && next.destroy) next.destroy(); return; }
|
||||
rendererPending = false;
|
||||
renderer = next;
|
||||
ensureCamera();
|
||||
if (camera) camera.setZoom(defaultZoomForMode(renderer.name || mode));
|
||||
renderState(lastState);
|
||||
};
|
||||
if (RK.createRendererAsync) RK.createRendererAsync(mode, els.stage, { cell: 26 }, done);
|
||||
else if (RK.createRenderer) done(RK.createRenderer(mode, els.stage, { cell: 26 }));
|
||||
}
|
||||
function switchRenderMode(next) {
|
||||
if (next === mode || (RK.canUseMode && !RK.canUseMode(next))) return;
|
||||
mode = next;
|
||||
if (RK.saveMode) RK.saveMode(mode);
|
||||
renderModeButtons();
|
||||
destroyRenderer();
|
||||
if (inRace && lastState) ensureRenderer();
|
||||
}
|
||||
function renderState(state) {
|
||||
if (!state || !renderer) return;
|
||||
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, {
|
||||
viewerId: mySocketId,
|
||||
cryptoType: gameModeInfo && gameModeInfo.cryptoType
|
||||
}) : null;
|
||||
if (!scene) return;
|
||||
renderer.render(scene);
|
||||
if (camera) camera.update(renderer);
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
function showCountdown(data) {
|
||||
clearCountdown();
|
||||
var ruleset = MatchCopy.fromPayload(data, activeRuleset);
|
||||
applyRuleset(ruleset);
|
||||
var ends = Date.now() + Math.max(0, Number(data.countdownMs) || 0);
|
||||
function update() {
|
||||
var left = Math.max(0, ends - Date.now());
|
||||
var count = data.players ? data.players.length : 0;
|
||||
setStatus('Match found · ' + count + ' ' + MatchCopy.plural(ruleset, count) + ' · starts in ' + (left / 1000).toFixed(1) + 's');
|
||||
if (left <= 0) clearCountdown();
|
||||
}
|
||||
update();
|
||||
countdownTimer = setInterval(update, 100);
|
||||
}
|
||||
function startRace(data, initialState) {
|
||||
clearCountdown();
|
||||
if (resultTimer) clearTimeout(resultTimer);
|
||||
inQueue = false;
|
||||
inRace = true;
|
||||
applyRuleset(MatchCopy.fromPayload({ ruleset: data && data.ruleset, state: initialState }, activeRuleset));
|
||||
tickMs = Math.max(50, Number(data && data.tickMs) || tickMs || 250);
|
||||
els.queuePanel.classList.add('hidden');
|
||||
els.racePanel.classList.remove('hidden');
|
||||
els.raceResult.classList.add('hidden');
|
||||
els.raceResult.textContent = '';
|
||||
els.raceResult.title = '';
|
||||
els.raceMode.textContent = activeRuleset.label;
|
||||
els.raceEconomy.textContent = economyLabel((initialState && initialState.economy) || selectedEconomy);
|
||||
els.raceTimer.textContent = '0.0s';
|
||||
els.racePlace.textContent = 'Waiting for first move';
|
||||
renderModeButtons();
|
||||
lastState = initialState || null;
|
||||
ensureRenderer();
|
||||
if (lastState) renderState(lastState);
|
||||
}
|
||||
|
||||
socket.on('connect', function () {
|
||||
mySocketId = socket.id;
|
||||
setStatus('Connected. Waiting for server info…');
|
||||
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
|
||||
socket.emit('match_reconnect', {});
|
||||
setStatus(inRace ? 'Reconnected to the arena.' : 'Connected. Loading server modes…');
|
||||
});
|
||||
socket.on('session_token', function (data) {
|
||||
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
|
||||
});
|
||||
socket.on('session_resumed', function (data) {
|
||||
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
|
||||
});
|
||||
|
||||
socket.on('game_mode_info', function (info) {
|
||||
gameModeInfo = info;
|
||||
gameModeInfo = info || {};
|
||||
if (RK.setEntitlementSnapshot && info && info.entitlements) RK.setEntitlementSnapshot(info.entitlements);
|
||||
renderRulesets();
|
||||
renderEconomies();
|
||||
if (gameModeInfo?.modes?.match?.enabled) {
|
||||
setStatus('Ready to queue. Choose an economy and click Join Queue.');
|
||||
renderModeButtons();
|
||||
if (matchInfo() && matchInfo().enabled && !inQueue && !inRace) {
|
||||
setStatus('Ready. Choose an entry mode and join the next ' + activeRuleset.label.toLowerCase() + '.');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('match_queue_joined', function (res) {
|
||||
if (res.success) {
|
||||
els.queueBtn.disabled = false;
|
||||
if (res && res.success) {
|
||||
inQueue = true;
|
||||
els.queueBtn.classList.add('hidden');
|
||||
els.leaveBtn.classList.remove('hidden');
|
||||
setStatus('Queued for ' + selectedEconomy + ' (position ' + (res.position || '?') + '). Waiting for next race…');
|
||||
setStatus('Queued for ' + economyLabel(selectedEconomy) + ' · position ' + (res.position || '?') + ' · waiting for the next block.');
|
||||
} else {
|
||||
setStatus('Queue failed: ' + (res.reason || 'unknown'));
|
||||
setStatus('Queue failed: ' + ((res && (res.reason || res.message)) || 'unknown error'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -90,86 +287,137 @@
|
||||
inQueue = false;
|
||||
els.queueBtn.classList.remove('hidden');
|
||||
els.leaveBtn.classList.add('hidden');
|
||||
setStatus(res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown'));
|
||||
setStatus(res && res.success ? 'Left the queue. Any held entry was returned.' : 'Could not leave: ' + ((res && res.reason) || 'unknown error'));
|
||||
});
|
||||
|
||||
socket.on('match_joined', function (data) {
|
||||
setStatus('Race starting! Seed: ' + data.seedHash.slice(0, 12) + '…');
|
||||
if (!data) return;
|
||||
selectedEconomy = data.economy || selectedEconomy;
|
||||
showCountdown(data);
|
||||
});
|
||||
|
||||
socket.on('match_start', function (data) {
|
||||
inQueue = false;
|
||||
inRace = true;
|
||||
els.queuePanel.classList.add('hidden');
|
||||
els.racePanel.classList.remove('hidden');
|
||||
els.raceEconomy.textContent = 'Economy: ' + selectedEconomy;
|
||||
els.raceTimer.textContent = 'Timer: 0s';
|
||||
els.racePlace.textContent = 'Place: —';
|
||||
|
||||
if (!renderer) {
|
||||
renderer = RK.createRenderer(mode, els.stage, {});
|
||||
}
|
||||
socket.on('match_start', function (data) { startRace(data || {}, null); });
|
||||
socket.on('match_rejoined', function (data) {
|
||||
if (!data || !data.state) return;
|
||||
selectedEconomy = data.state.economy || selectedEconomy;
|
||||
startRace({ tickMs: tickMs }, data.state);
|
||||
});
|
||||
|
||||
socket.on('match_tick', function (data) {
|
||||
if (!renderer || !data.state) return;
|
||||
const scene = RK.sceneFromGameState ? RK.sceneFromGameState(data.state, mySocketId) : null;
|
||||
if (scene) renderer.render(scene);
|
||||
|
||||
const me = data.state.players.find(function (p) { return p.you; });
|
||||
if (me) {
|
||||
els.racePlace.textContent = 'Place: ' + (me.placement || '—') + (me.alive ? '' : ' 💀');
|
||||
}
|
||||
if (!data || !data.state) return;
|
||||
if (!inRace) startRace({ tickMs: tickMs }, data.state);
|
||||
lastState = data.state;
|
||||
ensureRenderer();
|
||||
renderState(lastState);
|
||||
var tick = Number(data.tick != null ? data.tick : lastState.tick) || 0;
|
||||
els.raceTimer.textContent = (tick * tickMs / 1000).toFixed(1) + 's';
|
||||
els.racePlace.textContent = MatchCopy.liveSummary(lastState, mySocketId, activeRuleset);
|
||||
});
|
||||
|
||||
socket.on('match_end', function (data) {
|
||||
data = data || {};
|
||||
inRace = false;
|
||||
const me = data.players.find(function (p) { return p.id === mySocketId; });
|
||||
setStatus('Race over! ' + (data.reason || '') + ' — your placement: ' + (me ? me.placement : '—'));
|
||||
setTimeout(function () {
|
||||
if (!inRace) return;
|
||||
var result = MatchCopy.finalResult(data, mySocketId, activeRuleset);
|
||||
applyRuleset(result.mode);
|
||||
els.raceResult.textContent = result.text;
|
||||
if (data.fairness && data.fairness.seed) {
|
||||
els.raceResult.title = 'Seed: ' + data.fairness.seed
|
||||
+ '\nCommitment: ' + data.fairness.seedHash
|
||||
+ '\nSource: ' + (data.fairness.scope || data.fairness.version || 'commitment');
|
||||
verifyMatchReveal(data.fairness).then(function (valid) {
|
||||
if (valid === true) els.raceResult.textContent += ' · seed proof ✓';
|
||||
else if (valid === false) els.raceResult.textContent += ' · seed proof FAILED';
|
||||
else els.raceResult.textContent += ' · seed revealed';
|
||||
});
|
||||
}
|
||||
els.raceResult.classList.remove('hidden');
|
||||
resultTimer = setTimeout(function () {
|
||||
if (inRace) return;
|
||||
els.racePanel.classList.add('hidden');
|
||||
els.queuePanel.classList.remove('hidden');
|
||||
els.raceResult.classList.add('hidden');
|
||||
els.queueBtn.classList.remove('hidden');
|
||||
els.leaveBtn.classList.add('hidden');
|
||||
lastState = null;
|
||||
destroyRenderer();
|
||||
setStatus('Ready for another ' + activeRuleset.label.toLowerCase() + '.');
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
socket.on('match_error', function (err) {
|
||||
setStatus('Match error: ' + (err.reason || 'unknown'));
|
||||
socket.on('match_settlement_pending', function () {
|
||||
// The server deliberately withholds match_end until its authoritative finish transaction
|
||||
// commits. Keep this player in the arena mapping while the bounded retry runs.
|
||||
els.raceResult.textContent = 'Result pending durable settlement · retrying safely…';
|
||||
els.raceResult.classList.remove('hidden');
|
||||
setStatus('The arena result is not final yet. No final result has been published.');
|
||||
});
|
||||
|
||||
socket.on('match_error', function (err) {
|
||||
setStatus('Arena error: ' + ((err && (err.message || err.reason)) || 'unknown error'));
|
||||
});
|
||||
socket.on('commerce_ack_required', function (err) {
|
||||
setStatus((err && (err.message || err.error)) || 'Review the paid-play disclosures before joining.');
|
||||
if (window.CommerceConsent && window.CommerceConsent.require) {
|
||||
var refreshed = window.CommerceConsent.reject
|
||||
? window.CommerceConsent.reject(err)
|
||||
: Promise.resolve();
|
||||
refreshed.catch(function () { return null; }).then(function () {
|
||||
window.CommerceConsent.require(function () {
|
||||
setStatus('Disclosures acknowledged. Retry the paid queue when ready.');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.on('disconnect', function () {
|
||||
setStatus('Disconnected. Reconnecting…');
|
||||
inQueue = false;
|
||||
inRace = false;
|
||||
clearCountdown();
|
||||
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
|
||||
setStatus(inRace ? 'Connection lost · attempting to rejoin your match…' : 'Disconnected · reconnecting…');
|
||||
});
|
||||
|
||||
els.queueBtn.addEventListener('click', function () {
|
||||
if (inQueue || inRace) return;
|
||||
socket.emit('match_queue', { economy: selectedEconomy, action: 'join' });
|
||||
function join(acknowledgement) {
|
||||
els.queueBtn.disabled = true;
|
||||
setStatus('Joining ' + economyLabel(selectedEconomy) + '…');
|
||||
var payload = { economy: selectedEconomy, action: 'join' };
|
||||
if (acknowledgement) payload.legalAcknowledgement = acknowledgement;
|
||||
socket.emit('match_queue', payload);
|
||||
setTimeout(function () { if (!inQueue && !inRace) els.queueBtn.disabled = false; }, 1500);
|
||||
}
|
||||
if (selectedEconomy !== 'free' && window.CommerceConsent) {
|
||||
window.CommerceConsent.require(join);
|
||||
} else {
|
||||
join(null);
|
||||
}
|
||||
});
|
||||
|
||||
els.leaveBtn.addEventListener('click', function () {
|
||||
if (!inQueue) return;
|
||||
socket.emit('match_queue', { economy: selectedEconomy, action: 'leave' });
|
||||
});
|
||||
|
||||
function sendMove(dx, dy) {
|
||||
if (!inRace) return;
|
||||
if (!inRace || !socket.connected) return;
|
||||
socket.emit('match_move', { dx: dx, dy: dy });
|
||||
}
|
||||
|
||||
function directionMove(dir) {
|
||||
if (dir === 'up') sendMove(0, -1);
|
||||
else if (dir === 'down') sendMove(0, 1);
|
||||
else if (dir === 'left') sendMove(-1, 0);
|
||||
else if (dir === 'right') sendMove(1, 0);
|
||||
}
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (keyState[e.key]) return; // already pressed
|
||||
if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
|
||||
if (keyState[e.key]) return;
|
||||
keyState[e.key] = true;
|
||||
if (['ArrowUp', 'w', 'W'].includes(e.key)) { e.preventDefault(); sendMove(0, -1); }
|
||||
else if (['ArrowDown', 's', 'S'].includes(e.key)) { e.preventDefault(); sendMove(0, 1); }
|
||||
else if (['ArrowLeft', 'a', 'A'].includes(e.key)) { e.preventDefault(); sendMove(-1, 0); }
|
||||
else if (['ArrowRight', 'd', 'D'].includes(e.key)) { e.preventDefault(); sendMove(1, 0); }
|
||||
if (['ArrowUp', 'w', 'W'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('up'); }
|
||||
else if (['ArrowDown', 's', 'S'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('down'); }
|
||||
else if (['ArrowLeft', 'a', 'A'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('left'); }
|
||||
else if (['ArrowRight', 'd', 'D'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('right'); }
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', function (e) {
|
||||
keyState[e.key] = false;
|
||||
window.addEventListener('keyup', function (e) { keyState[e.key] = false; });
|
||||
window.addEventListener('blur', function () { keyState = {}; });
|
||||
if (els.dpad) Array.prototype.forEach.call(els.dpad.querySelectorAll('[data-dir]'), function (b) {
|
||||
// Native click covers mouse/touch plus Enter and Space activation for keyboard and AT users.
|
||||
b.addEventListener('click', function () { directionMove(b.dataset.dir); });
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -52,11 +52,19 @@ const SmirkAuth = {
|
||||
}
|
||||
|
||||
const socketId = window.socket.id;
|
||||
let sessionToken = '';
|
||||
try { sessionToken = localStorage.getItem('wownerogue_token') || ''; } catch (_) {}
|
||||
if (!sessionToken) {
|
||||
throw new Error('Your game session is still being established. Please retry in a moment.');
|
||||
}
|
||||
|
||||
// Step 1: Get challenge from server
|
||||
const challengeRes = await fetch('/api/auth/smirk/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-Token': sessionToken
|
||||
},
|
||||
body: JSON.stringify({ socketId })
|
||||
});
|
||||
|
||||
@@ -73,7 +81,7 @@ const SmirkAuth = {
|
||||
|
||||
if (this._supportsNip98()) {
|
||||
try {
|
||||
const result = await this._verifyNip98(socketId, challenge);
|
||||
const result = await this._verifyNip98(socketId, challenge, sessionToken);
|
||||
verifyData = result.verifyData;
|
||||
provenPublicKey = result.pubkey;
|
||||
} catch (nip98Err) {
|
||||
@@ -86,12 +94,12 @@ const SmirkAuth = {
|
||||
|| /reject|cancel|denied|declined/i.test((nip98Err && nip98Err.message) || '');
|
||||
if (userCancelled) throw nip98Err;
|
||||
console.warn('Smirk NIP-98 failed, trying legacy connect():', nip98Err && nip98Err.message);
|
||||
const result = await this._verifyLegacy(socketId, challenge);
|
||||
const result = await this._verifyLegacy(socketId, challenge, sessionToken);
|
||||
verifyData = result.verifyData;
|
||||
provenPublicKey = result.pubkey;
|
||||
}
|
||||
} else {
|
||||
const result = await this._verifyLegacy(socketId, challenge);
|
||||
const result = await this._verifyLegacy(socketId, challenge, sessionToken);
|
||||
verifyData = result.verifyData;
|
||||
provenPublicKey = result.pubkey;
|
||||
}
|
||||
@@ -140,7 +148,7 @@ const SmirkAuth = {
|
||||
*
|
||||
* @returns {Promise<{verifyData: object, pubkey: string|null}>}
|
||||
*/
|
||||
async _verifyNip98(socketId, challenge) {
|
||||
async _verifyNip98(socketId, challenge, sessionToken) {
|
||||
// Build the NIP-98 HTTP auth event. The wallet fills in pubkey/id/sig.
|
||||
const evt = {
|
||||
kind: 27235,
|
||||
@@ -173,7 +181,10 @@ const SmirkAuth = {
|
||||
|
||||
const verifyRes = await fetch('/api/auth/smirk/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-Token': sessionToken
|
||||
},
|
||||
body: JSON.stringify({ socketId, event: signed })
|
||||
});
|
||||
|
||||
@@ -192,7 +203,7 @@ const SmirkAuth = {
|
||||
*
|
||||
* @returns {Promise<{verifyData: object, pubkey: string|null}>}
|
||||
*/
|
||||
async _verifyLegacy(socketId, challenge) {
|
||||
async _verifyLegacy(socketId, challenge, sessionToken) {
|
||||
// Connect to Smirk extension
|
||||
let keys;
|
||||
try {
|
||||
@@ -226,7 +237,10 @@ const SmirkAuth = {
|
||||
// Verify signature with backend
|
||||
const verifyRes = await fetch('/api/auth/smirk/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-Token': sessionToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
socketId,
|
||||
challenge,
|
||||
@@ -374,7 +388,7 @@ const SmirkAuth = {
|
||||
*/
|
||||
init() {
|
||||
// Check if server has Smirk disabled (e.g., for Monero stagenet)
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === false) {
|
||||
if (typeof SocketHandlers === 'undefined' || SocketHandlers._smirkEnabled !== true) {
|
||||
console.log('Smirk integration disabled by server');
|
||||
return;
|
||||
}
|
||||
@@ -449,12 +463,12 @@ const SmirkAuth = {
|
||||
$(document).ready(function() {
|
||||
// Wait for game_mode_info to arrive first (typically within 2 seconds)
|
||||
// SocketHandlers will call SmirkAuth.init() when it receives game_mode_info
|
||||
// This fallback is for cases where game_mode_info doesn't include smirkEnabled
|
||||
// Fail closed: the server must explicitly advertise smirkEnabled=true.
|
||||
setTimeout(() => {
|
||||
// Only auto-init if not already initialized by SocketHandlers
|
||||
// and if SocketHandlers._smirkEnabled is not explicitly false
|
||||
// and only when the server explicitly enabled it
|
||||
if (!SmirkAuth._initialized &&
|
||||
(typeof SocketHandlers === 'undefined' || SocketHandlers._smirkEnabled !== false)) {
|
||||
typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === true) {
|
||||
SmirkAuth.init();
|
||||
}
|
||||
}, 3000);
|
||||
@@ -464,7 +478,8 @@ $(document).ready(function() {
|
||||
if (typeof window.socket !== 'undefined') {
|
||||
window.socket.on('connect', function() {
|
||||
setTimeout(() => {
|
||||
if (SmirkAuth.isAvailable() && !SmirkAuth._isLinked) {
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === true &&
|
||||
SmirkAuth.isAvailable() && !SmirkAuth._isLinked) {
|
||||
SmirkAuth.checkStatus().then(status => {
|
||||
const btn = $('#smirkLoginBtn');
|
||||
if (btn.length > 0 && status.linked) {
|
||||
|
||||
@@ -18,6 +18,74 @@ const SocketHandlers = {
|
||||
|
||||
_directPayoutsEnabled: false,
|
||||
_paymentsEnabled: false,
|
||||
_isSmirkExplicitlyEnabled: function(data) {
|
||||
return !!data && data.smirkEnabled === true;
|
||||
},
|
||||
_fairnessOffer: null,
|
||||
_pendingFairnessAttempt: null,
|
||||
|
||||
_randomClientSeed: function() {
|
||||
try {
|
||||
if (!window.crypto || typeof window.crypto.getRandomValues !== 'function') return null;
|
||||
var bytes = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return Array.prototype.map.call(bytes, function(b) { return b.toString(16).padStart(2, '0'); }).join('');
|
||||
} catch (e) { return null; }
|
||||
},
|
||||
|
||||
/** Build a start payload only after receiving the server's commitment. */
|
||||
fairnessAttempt: function(extra) {
|
||||
var offer = this._fairnessOffer;
|
||||
if (!offer || !offer.offerId || !/^[0-9a-f]{64}$/i.test(String(offer.commitment || ''))) {
|
||||
socket.emit('fairness_offer_request');
|
||||
$('#messages').append($('<li class="status" style="color:#ffcc00;">').text('Preparing a fairness commitment — try again in a moment.'));
|
||||
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
|
||||
return null;
|
||||
}
|
||||
// Generate the player's contribution only AFTER selecting the published commitment.
|
||||
var clientSeed = this._randomClientSeed();
|
||||
if (!clientSeed) {
|
||||
$('#messages').append($('<li class="error">').text('Secure randomness is unavailable in this browser; the game was not started.'));
|
||||
return null;
|
||||
}
|
||||
var payload = Object.assign({}, extra || {}, {
|
||||
fairnessOfferId: offer.offerId,
|
||||
clientSeed: clientSeed
|
||||
});
|
||||
this._pendingFairnessAttempt = {
|
||||
offerId: offer.offerId,
|
||||
commitment: offer.commitment,
|
||||
clientSeed: clientSeed
|
||||
};
|
||||
return payload;
|
||||
},
|
||||
|
||||
emitFairGameStart: function(eventName, extra, acknowledgementReady) {
|
||||
var paidIntent = !(extra && extra.free === true);
|
||||
var existingAcknowledgement = paidIntent
|
||||
&& typeof CommerceConsent !== 'undefined'
|
||||
&& CommerceConsent.acknowledgement
|
||||
? CommerceConsent.acknowledgement()
|
||||
: null;
|
||||
if (paidIntent && !acknowledgementReady && !existingAcknowledgement
|
||||
&& typeof CommerceConsent !== 'undefined' && CommerceConsent.require) {
|
||||
CommerceConsent.require(function(acknowledgement) {
|
||||
var acknowledgedExtra = Object.assign({}, extra || {});
|
||||
if (acknowledgement) acknowledgedExtra.legalAcknowledgement = acknowledgement;
|
||||
if (SocketHandlers.emitFairGameStart(eventName, acknowledgedExtra, true)) {
|
||||
$('#entryChoiceOverlay').remove();
|
||||
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) ScreenManager.drawWaitingScreen();
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
var acknowledgedPayload = Object.assign({}, extra || {});
|
||||
if (existingAcknowledgement) acknowledgedPayload.legalAcknowledgement = existingAcknowledgement;
|
||||
var payload = this.fairnessAttempt(acknowledgedPayload);
|
||||
if (!payload) return false;
|
||||
socket.emit(eventName, payload);
|
||||
return true;
|
||||
},
|
||||
|
||||
payoutAddressRequired: function() {
|
||||
if (this._gameMode === 'PAID_SINGLE' && this._directPayoutsEnabled) return true;
|
||||
@@ -114,6 +182,11 @@ const SocketHandlers = {
|
||||
registerEventHandlers: function() {
|
||||
// Connection handlers
|
||||
socket.on('connect', this.onConnect);
|
||||
socket.on('disconnect', function() {
|
||||
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.clear) {
|
||||
CommerceConsent.clear();
|
||||
}
|
||||
});
|
||||
socket.on('welcome', this.onWelcome);
|
||||
socket.on('session_token', this.onSessionToken);
|
||||
socket.on('session_resumed', this.onSessionResumed);
|
||||
@@ -130,6 +203,9 @@ const SocketHandlers = {
|
||||
socket.on('game_start', this.onGameStart);
|
||||
socket.on('game_update', this.onGameUpdate);
|
||||
socket.on('game_over', this.onGameOver);
|
||||
socket.on('game_settlement_pending', this.onGameSettlementPending);
|
||||
socket.on('fairness_offer', this.onFairnessOffer);
|
||||
socket.on('fairness_error', this.onFairnessError);
|
||||
// Discrete in-run events. Multi-level DESCEND is the big one — without this, taking the
|
||||
// stairs down silently dumped the player at a new entrance and read as a bug.
|
||||
socket.on('game_event', function (data) {
|
||||
@@ -156,6 +232,7 @@ const SocketHandlers = {
|
||||
socket.on('address_prompt', this.onAddressPrompt);
|
||||
socket.on('game_mode_info', this.onGameModeInfo);
|
||||
socket.on('payment_created', this.onPaymentCreated);
|
||||
socket.on('commerce_ack_required', this.onCommerceAcknowledgementRequired);
|
||||
socket.on('payment_confirmed', this.onPaymentConfirmed);
|
||||
socket.on('payment_detected', this.onPaymentDetected);
|
||||
socket.on('show_payment_options', this.onShowPaymentOptions);
|
||||
@@ -208,18 +285,24 @@ const SocketHandlers = {
|
||||
} catch (e) { /* non-critical */ }
|
||||
},
|
||||
|
||||
onFairnessOffer: function(data) {
|
||||
if (!data || !data.offerId || !/^[0-9a-f]{64}$/i.test(String(data.commitment || ''))) return;
|
||||
SocketHandlers._fairnessOffer = data;
|
||||
},
|
||||
|
||||
onFairnessError: function(data) {
|
||||
SocketHandlers._pendingFairnessAttempt = null;
|
||||
var message = (data && data.message) || 'Fairness offer could not be consumed. Please retry.';
|
||||
$('#messages').append($('<li class="error">').text(message));
|
||||
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
|
||||
socket.emit('fairness_offer_request');
|
||||
},
|
||||
|
||||
onConnect: function() {
|
||||
if (SocketHandlers._didConnect) return; // prevent duplicate registration emission
|
||||
SocketHandlers._didConnect = true;
|
||||
// Include stored session token in a lightweight resume emit (if server did not get it via handshake)
|
||||
try {
|
||||
const existing = localStorage.getItem('wownerogue_token');
|
||||
if (existing) {
|
||||
// If we later decide to rely solely on query param at io() creation, this is harmless redundancy.
|
||||
socket.io.opts.query = socket.io.opts.query || {};
|
||||
socket.io.opts.query.resumeToken = existing;
|
||||
}
|
||||
} catch (e) {}
|
||||
// The token is supplied in Socket.IO's handshake auth object by index.html. Do not copy
|
||||
// it into a query string: reverse proxies commonly log URLs, which would leak sessions.
|
||||
socket.emit('register_client', {
|
||||
clientId: socket.id,
|
||||
userAgent: navigator.userAgent
|
||||
@@ -545,14 +628,28 @@ const SocketHandlers = {
|
||||
|
||||
// Display provably fair commitment if present
|
||||
if (data && data.proof && data.proof.commitment) {
|
||||
var attempted = SocketHandlers._pendingFairnessAttempt;
|
||||
var proofMatchesAttempt = !attempted || (
|
||||
data.proof.offerId === attempted.offerId &&
|
||||
data.proof.commitment === attempted.commitment &&
|
||||
data.proof.clientSeed === attempted.clientSeed
|
||||
);
|
||||
const shortHash = data.proof.commitment.substring(0, 16) + '...';
|
||||
const shortClientSeed = data.proof.clientSeed
|
||||
? data.proof.clientSeed.substring(0, 16) + '...'
|
||||
: '(legacy empty seed)';
|
||||
const $proofMsg = $('<li class="proof-commitment" style="color:#0af; font-size:11px;">').html(
|
||||
'🔐 <strong>Provably Fair:</strong> Game hash commitment: <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
|
||||
data.proof.commitment + '">' + shortHash + '</code> ' +
|
||||
'<button class="copy-hash-btn" style="font-size:10px; padding:1px 4px; cursor:pointer;" data-hash="' +
|
||||
data.proof.commitment + '">Copy</button>'
|
||||
data.proof.commitment + '">Copy</button><br>' +
|
||||
'<span style="color:#888;">Accepted client seed:</span> <code>' + shortClientSeed + '</code>'
|
||||
);
|
||||
$('#messages').append($proofMsg);
|
||||
if (!proofMatchesAttempt) {
|
||||
$('#messages').append($('<li class="error">').text('⚠️ Fairness proof mismatch: the server did not echo the offer/client seed used for this attempt.'));
|
||||
}
|
||||
SocketHandlers._pendingFairnessAttempt = null;
|
||||
|
||||
// Store for later verification display
|
||||
SocketHandlers._currentGameProof = data.proof;
|
||||
@@ -609,21 +706,40 @@ const SocketHandlers = {
|
||||
UI.scrollChat();
|
||||
},
|
||||
|
||||
onGameSettlementPending: function() {
|
||||
// The server withholds game_over until the authoritative completion + any payout
|
||||
// obligation commit together. Keep the player informed without publishing a result that
|
||||
// PostgreSQL cannot yet prove or offering another entry while retry is in progress.
|
||||
$('#messages').find('.solo-settlement-pending').remove();
|
||||
$('#messages').append($('<li class="status solo-settlement-pending" style="color:#ffcc00;">')
|
||||
.text('Result pending durable settlement · retrying safely…'));
|
||||
SocketHandlers._setBannerStatus('Saving result…', '#ffcc00');
|
||||
UI.scrollChat();
|
||||
},
|
||||
|
||||
onGameOver: function(data) {
|
||||
$('#messages').find('.solo-settlement-pending').remove();
|
||||
$('#messages').append($('<li class="game-over">').text("Game Over: " + data.message));
|
||||
|
||||
// Display provably fair verification info if present
|
||||
if (data && data.proof && data.proof.seed) {
|
||||
const shortSeed = data.proof.seed.substring(0, 16) + '...';
|
||||
const serverSeed = data.proof.serverSeed || '';
|
||||
const effectiveSeed = data.proof.effectiveSeed || data.proof.seed;
|
||||
const clientSeed = data.proof.clientSeed || '';
|
||||
const shortServerSeed = serverSeed ? serverSeed.substring(0, 16) + '...' : '(unavailable)';
|
||||
const shortEffectiveSeed = effectiveSeed.substring(0, 16) + '...';
|
||||
const shortClientSeed = clientSeed ? clientSeed.substring(0, 16) + '...' : '(legacy empty seed)';
|
||||
const shortCommitment = data.proof.commitment.substring(0, 16) + '...';
|
||||
const verifyUrl = data.proof.verificationUrl || ('/verify/' + data.proof.gameId);
|
||||
|
||||
const $proofReveal = $('<li class="proof-reveal" style="color:#4ade80; font-size:11px; margin-top:5px; padding:8px; background:rgba(0,50,0,0.5); border-radius:4px;">').html(
|
||||
'🔓 <strong>Game Verified:</strong><br>' +
|
||||
'<span style="color:#888;">Seed:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
|
||||
data.proof.seed + '">' + shortSeed + '</code> ' +
|
||||
'🔓 <strong>Proof revealed:</strong><br>' +
|
||||
'<span style="color:#888;">Server seed:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
|
||||
serverSeed + '">' + shortServerSeed + '</code> ' +
|
||||
'<button class="copy-seed-btn" style="font-size:10px; padding:1px 4px; cursor:pointer;" data-seed="' +
|
||||
data.proof.seed + '">Copy</button><br>' +
|
||||
serverSeed + '">Copy</button><br>' +
|
||||
'<span style="color:#888;">Client seed:</span> <code>' + shortClientSeed + '</code><br>' +
|
||||
'<span style="color:#888;">Effective seed:</span> <code title="' + effectiveSeed + '">' + shortEffectiveSeed + '</code><br>' +
|
||||
'<span style="color:#888;">Hash:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;">' + shortCommitment + '</code><br>' +
|
||||
'<a href="' + verifyUrl + '" target="_blank" style="color:#0af;">🔗 Verify this game</a>'
|
||||
);
|
||||
@@ -783,10 +899,17 @@ const SocketHandlers = {
|
||||
SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled;
|
||||
SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled;
|
||||
SocketHandlers._paymentsEnabled = !!data.paymentsEnabled;
|
||||
SocketHandlers._smirkEnabled = data.smirkEnabled !== false; // Default to true if not specified
|
||||
SocketHandlers._smirkEnabled = SocketHandlers._isSmirkExplicitlyEnabled(data);
|
||||
SocketHandlers._cryptoType = data.cryptoType || 'WOW';
|
||||
SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet
|
||||
SocketHandlers._explorerTxUrl = data.explorerTxUrl || null;
|
||||
// Do not ask prestige-only users for a payout address or imply that mainnet paid
|
||||
// entries award crypto. Match crypto is exposed only when its economy is admitted.
|
||||
var matchCryptoPayouts = !!(data.modes && data.modes.match && data.modes.match.economies
|
||||
&& data.modes.match.economies.crypto_race);
|
||||
var anyPayouts = SocketHandlers._creditsPayoutsEnabled
|
||||
|| SocketHandlers._directPayoutsEnabled || matchCryptoPayouts;
|
||||
$('#manageAddressButton').toggle(anyPayouts);
|
||||
if (data.entitlements && typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.applyEntitlements(data.entitlements);
|
||||
}
|
||||
@@ -1057,6 +1180,24 @@ const SocketHandlers = {
|
||||
}
|
||||
},
|
||||
|
||||
onCommerceAcknowledgementRequired: function(data) {
|
||||
var message = data && (data.message || data.error)
|
||||
|| 'Review the paid-play disclosures before continuing.';
|
||||
$('#messages').append($('<li class="status" style="color:#fbbf24;">').text(message));
|
||||
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
|
||||
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.require) {
|
||||
var refreshed = CommerceConsent.reject
|
||||
? CommerceConsent.reject(data)
|
||||
: Promise.resolve();
|
||||
refreshed.catch(function() { return null; }).then(function() {
|
||||
CommerceConsent.require(function() {
|
||||
$('#messages').append($('<li class="status" style="color:#aaa;">').text('Disclosures acknowledged. Retry the paid action when ready.'));
|
||||
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onBalanceCritical: function(data) {
|
||||
console.warn('Balance critical - games halted:', data);
|
||||
|
||||
@@ -1729,7 +1870,9 @@ const SocketHandlers = {
|
||||
var $btn = $('#earlyEntryButton');
|
||||
$btn.prop('disabled', true).text('⏳ Entering...');
|
||||
|
||||
socket.emit('early_entry');
|
||||
if (!SocketHandlers.emitFairGameStart('early_entry')) {
|
||||
$btn.prop('disabled', false).text('⚡ ENTER NOW (RISKY!) ⚡');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1806,8 +1949,8 @@ const SocketHandlers = {
|
||||
'<span style="font-size:12px;letter-spacing:1px;color:' + accent + ';">ENTER ▸</span></div>';
|
||||
}
|
||||
function begin(msg, emit) {
|
||||
if (emit() === false) return;
|
||||
$('#entryChoiceOverlay').remove();
|
||||
emit();
|
||||
// Transient "dropping in…/queued…" line — cleared in onGameStart once the game is live so
|
||||
// it doesn't linger next to a running game reading as "still starting". The queued variant
|
||||
// stays visible during the (real) block wait; only a started game removes it.
|
||||
@@ -1842,13 +1985,13 @@ const SocketHandlers = {
|
||||
$('#ecNow').on('click', function () { timing = 'now'; render(); });
|
||||
$('#ecX').on('click', function () { $('#entryChoiceOverlay').remove(); });
|
||||
$('#ecFree').on('click', function () {
|
||||
if (timing === 'now') begin('⚡ Free game — dropping in...', function () { socket.emit('auto_start', { free: true }); });
|
||||
else begin('🛡️ Free game — queued for the next block...', function () { socket.emit('join_queue', { free: true }); });
|
||||
if (timing === 'now') begin('⚡ Free game — dropping in...', function () { return SocketHandlers.emitFairGameStart('auto_start', { free: true }); });
|
||||
else begin('🛡️ Free game — queued for the next block...', function () { return SocketHandlers.emitFairGameStart('join_queue', { free: true }); });
|
||||
});
|
||||
$('#ecRank').on('click', function () {
|
||||
if (!hasCredits) { pay(); return; }
|
||||
if (timing === 'now') begin('⚡ Ranked — dropping in now...', function () { socket.emit('auto_start'); });
|
||||
else begin('🛡️ Ranked — queued for the next block...', function () { socket.emit('join_queue'); });
|
||||
if (timing === 'now') begin('⚡ Ranked — dropping in now...', function () { return SocketHandlers.emitFairGameStart('auto_start'); });
|
||||
else begin('🛡️ Ranked — queued for the next block...', function () { return SocketHandlers.emitFairGameStart('join_queue'); });
|
||||
});
|
||||
$('#ecBuy').on('click', pay);
|
||||
}
|
||||
|
||||
@@ -114,13 +114,17 @@
|
||||
// Camera focus — centre the SP camera on the player cell (same convention as the tiled
|
||||
// renderer). Sticky: only update when the player is present, so switching TO ascii mid-game
|
||||
// doesn't leave focusPoint null (which pinned the whole grid to the corner → black screen).
|
||||
var focusFallback = null;
|
||||
for (var pj = 0; pj < scene.entities.length; pj++) {
|
||||
var pe = scene.entities[pj];
|
||||
if (pe.kind === 'player' || pe.you) {
|
||||
if (pe.kind === 'player' && !focusFallback) focusFallback = pe;
|
||||
if (pe.you || pe.cameraTarget) {
|
||||
this.focusPoint = { x: pe.x * cell + cell / 2, y: pe.y * cell + cell / 2 };
|
||||
focusFallback = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (focusFallback) this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
|
||||
|
||||
// Keep a light RAF alive so the torch glows flicker between game updates (~22fps).
|
||||
this._hasTorches = hasTorch;
|
||||
|
||||
@@ -624,12 +624,16 @@
|
||||
|
||||
function modeLockedTitle(mode) {
|
||||
var label = mode && mode.label ? mode.label : 'Mode';
|
||||
if (mode && RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode.id)) {
|
||||
return label + ' is unavailable on this server because its optional renderer dependency is disabled.';
|
||||
}
|
||||
if (mode && mode.pack) return label + ' is locked. Buy any credits or unlock ' + packLabel(mode.pack) + '.';
|
||||
return label + ' is locked. Buy any credits to unlock premium modes.';
|
||||
}
|
||||
|
||||
function modeStatus(mode, unlocked) {
|
||||
if (!mode || !mode.premium) return 'Included';
|
||||
if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode.id)) return 'Unavailable';
|
||||
if (unlocked && RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return 'Test';
|
||||
return unlocked ? 'Unlocked' : 'Locked';
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
this.cell = opts.cell || 26;
|
||||
this.app = new PIXI.Application({ backgroundColor: 0x0a0c0f, antialias: true });
|
||||
this.app.view.className = 'rk-canvas';
|
||||
this.canvas = this.app.view;
|
||||
host.appendChild(this.app.view);
|
||||
|
||||
this.tileLayer = new PIXI.Container();
|
||||
@@ -288,7 +289,7 @@
|
||||
};
|
||||
|
||||
FancyRenderer.prototype._syncEntities = function (scene) {
|
||||
var cell = this.cell, seen = {}, RKr = window.RK;
|
||||
var cell = this.cell, seen = {}, RKr = window.RK, focusFallback = null;
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
seen[e.id] = true;
|
||||
@@ -315,6 +316,14 @@
|
||||
s = this.sprites[e.id] = { c: c, kind: kind, renderKey: renderKey };
|
||||
}
|
||||
s.tx = e.x; s.ty = e.y; s.e = e;
|
||||
if (e.kind === 'player' && !focusFallback) focusFallback = e;
|
||||
if (e.you || e.cameraTarget) {
|
||||
this.focusPoint = { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
focusFallback = null;
|
||||
}
|
||||
}
|
||||
if (!this.focusPoint && focusFallback) {
|
||||
this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
|
||||
}
|
||||
for (var id in this.sprites) {
|
||||
if (!seen[id]) {
|
||||
|
||||
@@ -326,8 +326,9 @@
|
||||
var e = scene.entities[i];
|
||||
p = this._project(e.x, e.y, originX, originY);
|
||||
items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 });
|
||||
// Sticky camera target (keep last if the player is momentarily absent).
|
||||
if (e.you || e.kind === 'player') { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
|
||||
// Sticky camera target (keep last if the player is momentarily absent). Multiplayer
|
||||
// scenes explicitly mark one racer as cameraTarget; never let a later rival steal it.
|
||||
if (e.you || e.cameraTarget) { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
|
||||
}
|
||||
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
RK.externalRenderersEnabled = function () {
|
||||
return !!(root.WOWNGEON_RUNTIME && root.WOWNGEON_RUNTIME.rendererCdnEnabled === true);
|
||||
};
|
||||
RK.isModeRuntimeAvailable = function (id) {
|
||||
if (!RK.modeMeta || !RK.modeMeta(id)) return false;
|
||||
if (id === '3d') {
|
||||
return !!(RK.THREE && RK.THREE.THREE) || RK.externalRenderersEnabled();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic
|
||||
// entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering
|
||||
@@ -39,6 +49,7 @@
|
||||
RK.canUseMode = function (id) {
|
||||
var m = RK.modeMeta(id);
|
||||
if (!m) return false;
|
||||
if (!RK.isModeRuntimeAvailable(id)) return false;
|
||||
if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return true;
|
||||
if (!m.premium) return true;
|
||||
// A premium mode is available once the user has ANY unlocked pack for its projection.
|
||||
@@ -47,6 +58,16 @@
|
||||
return !!RK.entitlements.premium;
|
||||
};
|
||||
|
||||
RK.modeAvailability = function (id) {
|
||||
var runtimeAvailable = RK.isModeRuntimeAvailable(id);
|
||||
var usable = runtimeAvailable && RK.canUseMode(id);
|
||||
return {
|
||||
usable: usable,
|
||||
runtimeAvailable: runtimeAvailable,
|
||||
reason: !runtimeAvailable ? 'runtime_unavailable' : (usable ? null : 'entitlement_locked')
|
||||
};
|
||||
};
|
||||
|
||||
RK.createRenderer = function (mode, host, opts) {
|
||||
if (!RK.canUseMode(mode)) return new RK.TileRenderer(host, opts);
|
||||
if (mode === 'ascii' && RK.AsciiRenderer) return new RK.AsciiRenderer(host, opts);
|
||||
@@ -76,6 +97,7 @@
|
||||
RK.pixiReady = function () { return typeof PIXI !== 'undefined'; };
|
||||
RK.ensurePixi = function (cb) {
|
||||
if (RK.pixiReady()) { cb(true); return; }
|
||||
if (!RK.externalRenderersEnabled()) { cb(false); return; }
|
||||
pixiCbs.push(cb);
|
||||
if (pixiLoading) return;
|
||||
pixiLoading = true;
|
||||
@@ -96,6 +118,7 @@
|
||||
RK.threeReady = function () { return !!(RK.THREE && RK.THREE.THREE); };
|
||||
RK.ensureThree = function (cb) {
|
||||
if (RK.threeReady()) { cb(true); return; }
|
||||
if (!RK.externalRenderersEnabled()) { cb(false); return; }
|
||||
threeCbs.push(cb);
|
||||
if (threeLoading) return;
|
||||
threeLoading = true;
|
||||
|
||||
@@ -155,9 +155,67 @@ function maxDims(maps) {
|
||||
return { rows: rows, cols: cols };
|
||||
}
|
||||
|
||||
function normalizeGameStateOpts(opts) {
|
||||
// Older multiplayer callers passed a socket id directly. Keep that path working while the
|
||||
// public API moves to an options object (`{ viewerId, playerAppearance, cryptoType }`).
|
||||
if (typeof opts === 'string') return { viewerId: opts };
|
||||
return (opts && typeof opts === 'object') ? opts : {};
|
||||
}
|
||||
|
||||
function pickCameraPlayer(players, state, opts) {
|
||||
if (!players || !players.length) return null;
|
||||
var viewerId = opts.viewerId || opts.socketId || null;
|
||||
var focusId = opts.focusPlayerId || null;
|
||||
var i, p;
|
||||
|
||||
// Match ticks are broadcast once, so the client socket id is the authoritative local marker
|
||||
// when the server cannot include a recipient-specific `you` flag.
|
||||
if (viewerId) {
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.id === viewerId) return p;
|
||||
}
|
||||
}
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.you === true) return p;
|
||||
}
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && focusId && p.id === focusId) return p;
|
||||
}
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && state.winnerId && p.id === state.winnerId) return p;
|
||||
}
|
||||
// Spectators follow a live contender by default, then gracefully fall back to the first racer.
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.alive !== false && !p.finished) return p;
|
||||
}
|
||||
return players[0] || null;
|
||||
}
|
||||
|
||||
function pointOf(value) {
|
||||
if (!value) return null;
|
||||
if (Array.isArray(value)) return { x: value[0], y: value[1] };
|
||||
if (typeof value.x === 'number' && typeof value.y === 'number') return { x: value.x, y: value.y };
|
||||
return null;
|
||||
}
|
||||
|
||||
function racerColor(player, index, isYou) {
|
||||
if (isYou) return '#67e8f9';
|
||||
if (player && player.alive === false) return '#6b7280';
|
||||
if (player && (player.finished || player.escaped)) return '#fbbf24';
|
||||
var palette = ['#9aa4b2', '#a78bfa', '#4ade80', '#fb7185', '#60a5fa', '#f97316'];
|
||||
var raw = String((player && player.id) || index), hash = 0;
|
||||
for (var i = 0; i < raw.length; i++) hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0;
|
||||
return palette[hash % palette.length];
|
||||
}
|
||||
|
||||
function sceneFromGameState(state, opts) {
|
||||
state = state || {};
|
||||
opts = opts || {};
|
||||
opts = normalizeGameStateOpts(opts);
|
||||
var visible = state.visibleTiles || {};
|
||||
var explored = state.exploredTiles || {};
|
||||
var lighting = state.lighting || {};
|
||||
@@ -167,8 +225,12 @@ function sceneFromGameState(state, opts) {
|
||||
var rows = state.dungeonRows || visible.length || dims.rows;
|
||||
var cols = state.dungeonCols || dims.cols;
|
||||
|
||||
// Player cell — explored MEMORY fades to black with distance from here (see below).
|
||||
var _pl = state.player || {};
|
||||
var matchPlayers = Array.isArray(state.players) ? state.players : [];
|
||||
var cameraPlayer = pickCameraPlayer(matchPlayers, state, opts);
|
||||
|
||||
// Player cell — explored MEMORY fades to black with distance from here (see below). Multiplayer
|
||||
// states use `players[]`, while the original single-player protocol uses singular `player`.
|
||||
var _pl = state.player || cameraPlayer || {};
|
||||
var _plx = typeof _pl.x === 'number' ? _pl.x : (cols / 2);
|
||||
var _ply = typeof _pl.y === 'number' ? _pl.y : (rows / 2);
|
||||
|
||||
@@ -215,15 +277,19 @@ function sceneFromGameState(state, opts) {
|
||||
function inView(x, y) { return !!(visible[y] && visible[y][x] !== undefined); }
|
||||
|
||||
// Entrance / exit / treasure as entities (so they layer above tiles) — only once explored.
|
||||
if (state.entrance && seen(state.entrance[0], state.entrance[1])) {
|
||||
entities.push({ id: 'entrance', x: state.entrance[0], y: state.entrance[1], kind: 'feature', char: '<', color: '#3fb950', label: null });
|
||||
// Solo uses `[x,y]`; match state uses `{x,y}` for treasure, so accept both point shapes.
|
||||
var entrance = pointOf(state.entrance);
|
||||
var exit = pointOf(state.exit);
|
||||
var treasure = pointOf(state.treasure);
|
||||
if (entrance && seen(entrance.x, entrance.y)) {
|
||||
entities.push({ id: 'entrance', x: entrance.x, y: entrance.y, kind: 'feature', char: '<', color: '#3fb950', label: null });
|
||||
}
|
||||
if (state.exit && seen(state.exit[0], state.exit[1])) {
|
||||
entities.push({ id: 'exit', x: state.exit[0], y: state.exit[1], kind: 'feature', char: '>', color: '#d29922', label: null });
|
||||
if (exit && seen(exit.x, exit.y)) {
|
||||
entities.push({ id: 'exit', x: exit.x, y: exit.y, kind: 'feature', char: '>', color: '#d29922', label: null });
|
||||
}
|
||||
if (state.treasure && seen(state.treasure[0], state.treasure[1])) {
|
||||
if (treasure && seen(treasure.x, treasure.y) && !(state.treasure && state.treasure.carrierId)) {
|
||||
var tChar = opts.cryptoType === 'XMR' ? '$M' : '$W';
|
||||
entities.push({ id: 'treasure', x: state.treasure[0], y: state.treasure[1], kind: 'feature', char: tChar, color: '#fbbf24', label: null });
|
||||
entities.push({ id: 'treasure', x: treasure.x, y: treasure.y, kind: 'feature', char: tChar, color: '#fbbf24', label: null });
|
||||
}
|
||||
|
||||
// Items — only once explored.
|
||||
@@ -246,15 +312,34 @@ function sceneFromGameState(state, opts) {
|
||||
});
|
||||
}
|
||||
|
||||
// Player.
|
||||
if (state.player) {
|
||||
// Multiplayer racers. Preserve public status fields useful to HUD/render extensions while
|
||||
// keeping the shared entity contract identical to solo (`kind: player`, appearance, facing).
|
||||
if (matchPlayers.length) {
|
||||
var viewerId = opts.viewerId || opts.socketId || null;
|
||||
for (var pi = 0; pi < matchPlayers.length; pi++) {
|
||||
var rp = matchPlayers[pi];
|
||||
if (!rp || typeof rp.x !== 'number' || typeof rp.y !== 'number') continue;
|
||||
var isYou = viewerId ? rp.id === viewerId : rp.you === true;
|
||||
var appearance = rp.appearance || (rp.avatar ? { avatar: rp.avatar } : null) || { avatar: 'default' };
|
||||
entities.push({
|
||||
id: rp.id || ('player:' + pi), x: rp.x, y: rp.y,
|
||||
kind: 'player', char: '@', color: racerColor(rp, pi, isYou),
|
||||
facing: rp.facing || null, label: rp.name || null,
|
||||
avatar: appearance.avatar || rp.avatar || 'default', appearance: appearance,
|
||||
you: isYou, cameraTarget: rp === cameraPlayer,
|
||||
alive: rp.alive !== false, finished: !!rp.finished, escaped: !!rp.escaped,
|
||||
hasTreasure: !!rp.hasTreasure, placement: rp.placement == null ? null : rp.placement
|
||||
});
|
||||
}
|
||||
// Original single-player protocol.
|
||||
} else if (state.player) {
|
||||
var playerEntity = {
|
||||
id: 'player', x: state.player.x, y: state.player.y,
|
||||
kind: 'player', char: '@', color: '#9aa4b2',
|
||||
// No bogus default: the SP server doesn't send facing, so leave it null and let the iso/3D
|
||||
// renderers INFER facing from movement. A hardcoded 'down' made them always face down/SW.
|
||||
facing: state.player.facing || null, label: null,
|
||||
you: !state.isSpectating
|
||||
you: !state.isSpectating, cameraTarget: true
|
||||
};
|
||||
// Attach the player's appearance if available (for render-kit character rendering).
|
||||
if (opts.playerAppearance) {
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
|
||||
SP.setMode = function (mode) {
|
||||
if (RK.canUseMode && !RK.canUseMode(mode)) return false;
|
||||
if (SP._mode !== mode) SP._zoom = null; // let the new projection use its legible default
|
||||
SP._mode = mode;
|
||||
if (RK.saveMode) RK.saveMode(mode);
|
||||
SP._destroyRenderer();
|
||||
@@ -71,6 +72,42 @@
|
||||
SP._renderer = null;
|
||||
};
|
||||
|
||||
SP._setZoom = function (next) {
|
||||
SP._zoom = Math.max(0.4, Math.min(4, Number(next) || SP._defaultZoom()));
|
||||
var readout = doc() && doc().getElementById('rk-sp-camera-zoom');
|
||||
if (readout) readout.textContent = Math.round(SP._zoom * 100) + '%';
|
||||
SP._applyCamera();
|
||||
};
|
||||
|
||||
SP._mountCameraControls = function (host) {
|
||||
if (!host || host._rkCameraControls || !doc()) return;
|
||||
var controls = doc().createElement('div');
|
||||
controls.id = 'rk-sp-camera-controls';
|
||||
controls.className = 'rk-camera-controls';
|
||||
controls.setAttribute('aria-label', 'Dungeon camera controls');
|
||||
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
|
||||
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
|
||||
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px monospace;';
|
||||
function addButton(label, title, action) {
|
||||
var b = doc().createElement('button');
|
||||
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
|
||||
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
|
||||
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
|
||||
b.addEventListener('click', function (e) { e.stopPropagation(); action(); });
|
||||
controls.appendChild(b);
|
||||
}
|
||||
addButton('−', 'Zoom out', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) / 1.18); });
|
||||
var readout = doc().createElement('span');
|
||||
readout.id = 'rk-sp-camera-zoom';
|
||||
readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
|
||||
controls.appendChild(readout);
|
||||
addButton('+', 'Zoom in', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * 1.18); });
|
||||
addButton('⌂', 'Reset camera', function () { SP._zoom = null; SP._camX = null; SP._setZoom(SP._defaultZoom()); });
|
||||
host.appendChild(controls);
|
||||
host._rkCameraControls = controls;
|
||||
readout.textContent = Math.round(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * 100) + '%';
|
||||
};
|
||||
|
||||
SP._ensureRenderer = function () {
|
||||
if (SP._renderer) return SP._renderer;
|
||||
var host = rkHost();
|
||||
@@ -116,17 +153,42 @@
|
||||
// NOT a hardcoded 1.7 — otherwise the first scroll on iso (default 1.0) jumped up to
|
||||
// 1.7 and BOTH directions read as "zoom in".
|
||||
var base = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
|
||||
SP._zoom = Math.max(0.4, Math.min(4, base * Math.exp(-ev.deltaY * 0.0015)));
|
||||
SP._applyCamera();
|
||||
SP._setZoom(base * Math.exp(-ev.deltaY * 0.0015));
|
||||
}, { passive: false });
|
||||
host.addEventListener('dblclick', function (ev) {
|
||||
if (ev.target && ev.target.closest && ev.target.closest('.rk-camera-controls')) return;
|
||||
SP._zoom = null; SP._camX = null; SP._setZoom(SP._defaultZoom());
|
||||
});
|
||||
var points = {}, pinchDistance = null;
|
||||
host.addEventListener('pointerdown', function (ev) {
|
||||
if (ev.target && ev.target.closest && ev.target.closest('.rk-camera-controls')) return;
|
||||
points[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
|
||||
try { host.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||
});
|
||||
host.addEventListener('pointermove', function (ev) {
|
||||
if (!points[ev.pointerId]) return;
|
||||
points[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
|
||||
var ids = Object.keys(points);
|
||||
if (ids.length < 2) return;
|
||||
var a = points[ids[0]], b = points[ids[1]];
|
||||
var distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
if (pinchDistance) SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * (distance / pinchDistance));
|
||||
pinchDistance = distance;
|
||||
});
|
||||
function release(ev) { delete points[ev.pointerId]; pinchDistance = null; }
|
||||
host.addEventListener('pointerup', release);
|
||||
host.addEventListener('pointercancel', release);
|
||||
}
|
||||
SP._mountCameraControls(host);
|
||||
return SP._renderer;
|
||||
};
|
||||
|
||||
// Mode-aware default zoom: iso tiles are large (84px) so they want less magnification than the
|
||||
// small tiled/ascii cells (~24px).
|
||||
SP._defaultZoom = function () {
|
||||
return (SP._renderer && SP._renderer.name === 'iso') ? 1.0 : 1.7;
|
||||
if (SP._renderer && SP._renderer.name === 'iso') return 1.0;
|
||||
if (SP._renderer && SP._renderer.name === '3d') return 1.05;
|
||||
return 1.7;
|
||||
};
|
||||
|
||||
// Centre the (whole-scene) canvas on the player via a CSS transform, clipped to the host —
|
||||
@@ -138,6 +200,7 @@
|
||||
// The 3D renderer fills the host itself (setSize) and its OWN THREE camera follows the
|
||||
// player — so DON'T apply a CSS transform (it would double-transform the WebGL view).
|
||||
if (r.name === '3d') {
|
||||
if (r.setZoom) r.setZoom((SP._zoom != null) ? SP._zoom : SP._defaultZoom());
|
||||
r.canvas.style.transform = 'none';
|
||||
r.canvas.style.left = '0';
|
||||
r.canvas.style.top = '0';
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
targetX: x, targetY: y,
|
||||
moveCooldown: 0,
|
||||
sitTimer: 0,
|
||||
speechTimer: 0,
|
||||
// Stagger the first lines so entering the room feels conversational instead of every
|
||||
// patron opening a bubble on the same frame.
|
||||
speechTimer: opts.speechTimer == null ? 1800 + Math.random() * 7000 : opts.speechTimer,
|
||||
speech: null, speechUntil: 0,
|
||||
color: opts.color || '#d7dbe0',
|
||||
label: opts.label || null,
|
||||
@@ -334,6 +336,7 @@
|
||||
// Get the screen position of an entity for a given renderer/cell size.
|
||||
// This is renderer-agnostic: top-down uses grid coords * cell; iso uses projection.
|
||||
TavernLife.prototype._entityScreenPos = function (e, scene, cell, mode) {
|
||||
var pos;
|
||||
if (mode === 'iso') {
|
||||
// Match IsoRenderer._project: originX = margin + rows * tileW/2 + tileW; originY = margin + imageH
|
||||
var tileW = (RK.isoAssets && RK.isoAssets.tile && RK.isoAssets.tile.w) || 84;
|
||||
@@ -342,18 +345,21 @@
|
||||
var margin = 28;
|
||||
var originX = margin + scene.rows * tileW / 2 + tileW;
|
||||
var originY = margin + imageH;
|
||||
return {
|
||||
pos = {
|
||||
x: originX + (e.x - e.y) * tileW / 2,
|
||||
y: originY + (e.x + e.y) * tileH / 2
|
||||
};
|
||||
}
|
||||
if (mode === '3d') {
|
||||
} else if (mode === '3d') {
|
||||
// 3D renderer uses Three.js — we can't easily get screen coords.
|
||||
// Skip speech bubbles for 3D mode.
|
||||
return null;
|
||||
} else {
|
||||
// top-down / ascii / fancy: grid coords * cell
|
||||
pos = { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
}
|
||||
// top-down / ascii / fancy: grid coords * cell
|
||||
return { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
var view = this._viewTransform;
|
||||
if (view) return { x: view.left + pos.x * view.scaleX, y: view.top + pos.y * view.scaleY };
|
||||
return pos;
|
||||
};
|
||||
|
||||
TavernLife.prototype._drawBubbles = function (scene, cell, mode, now) {
|
||||
@@ -369,7 +375,7 @@
|
||||
var remaining = npc.speechUntil - now;
|
||||
if (remaining < BUBBLE_FADE) alpha = remaining / BUBBLE_FADE;
|
||||
var pos = this._entityScreenPos(npc, scene, cell, mode);
|
||||
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3, npc.speech, alpha);
|
||||
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3 * ((this._viewTransform && this._viewTransform.scaleY) || 1), npc.speech, alpha);
|
||||
}
|
||||
|
||||
// Real player bubbles (from chat).
|
||||
@@ -386,7 +392,7 @@
|
||||
var rRemaining = bubble.until - now;
|
||||
if (rRemaining < BUBBLE_FADE) rAlpha = rRemaining / BUBBLE_FADE;
|
||||
var rPos = this._entityScreenPos(e, scene, cell, mode);
|
||||
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3, bubble.text, rAlpha);
|
||||
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3 * ((this._viewTransform && this._viewTransform.scaleY) || 1), bubble.text, rAlpha);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,7 +425,26 @@
|
||||
// Draw speech bubbles on the overlay.
|
||||
var rendererCanvas = this.host.querySelector('.rk-canvas');
|
||||
if (rendererCanvas) {
|
||||
this._syncOverlaySize(rendererCanvas.width, rendererCanvas.height);
|
||||
this._syncOverlaySize(this.host.clientWidth || rendererCanvas.width, this.host.clientHeight || rendererCanvas.height);
|
||||
// Keep the overlay at viewport scale (so chat remains readable), but project each
|
||||
// world-space head position through the camera-transformed canvas rectangle.
|
||||
var hostRect = this.host.getBoundingClientRect ? this.host.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
var canvasRect = rendererCanvas.getBoundingClientRect ? rendererCanvas.getBoundingClientRect() : {
|
||||
left: 0, top: 0, width: rendererCanvas.width, height: rendererCanvas.height
|
||||
};
|
||||
this._viewTransform = {
|
||||
left: canvasRect.left - hostRect.left,
|
||||
top: canvasRect.top - hostRect.top,
|
||||
scaleX: canvasRect.width / (rendererCanvas.width || 1),
|
||||
scaleY: canvasRect.height / (rendererCanvas.height || 1)
|
||||
};
|
||||
this._overlay.style.inset = '0';
|
||||
this._overlay.style.left = '0';
|
||||
this._overlay.style.top = '0';
|
||||
this._overlay.style.maxWidth = 'none';
|
||||
this._overlay.style.maxHeight = 'none';
|
||||
this._overlay.style.transform = '';
|
||||
this._overlay.style.transition = '';
|
||||
}
|
||||
this._drawBubbles(scene, cell, mode, now);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
this.mixers = [];
|
||||
this.entities = {};
|
||||
this.last = {};
|
||||
this.zoom = 1;
|
||||
this.panX = 0;
|
||||
this.panY = 0;
|
||||
this._sizeKey = null;
|
||||
this._init();
|
||||
}
|
||||
@@ -40,7 +43,13 @@
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
this.renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
this.renderer.domElement.className = 'rk-canvas';
|
||||
this.canvas = this.renderer.domElement;
|
||||
this.renderer.setPixelRatio(Math.min(2, root.devicePixelRatio || 1));
|
||||
if (T.SRGBColorSpace) this.renderer.outputColorSpace = T.SRGBColorSpace;
|
||||
if (T.ACESFilmicToneMapping != null) {
|
||||
this.renderer.toneMapping = T.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.08;
|
||||
}
|
||||
this.host.appendChild(this.renderer.domElement);
|
||||
|
||||
var hemi = new T.HemisphereLight(0xfff3d2, 0x283044, 2.1);
|
||||
@@ -48,6 +57,11 @@
|
||||
var key = new T.DirectionalLight(0xffffff, 1.6);
|
||||
key.position.set(6, 10, 5);
|
||||
this.scene.add(key);
|
||||
// A subtle player-carried torch adds depth and keeps the followed avatar readable without
|
||||
// washing out the dungeon's black fog-of-war background.
|
||||
this.playerLight = new T.PointLight(0xffb35c, 2.2, 8, 2);
|
||||
this.playerLight.position.set(0, 3.2, 0);
|
||||
this.scene.add(this.playerLight);
|
||||
this.world = new T.Group();
|
||||
this.scene.add(this.world);
|
||||
this.clock = new T.Clock();
|
||||
@@ -65,7 +79,7 @@
|
||||
if (w === this._cw && h === this._ch) return;
|
||||
this._cw = w; this._ch = h;
|
||||
this.renderer.setSize(w, h, true); // updateStyle:true → the canvas CSS-fills the host
|
||||
var aspect = w / h, span = 8; // fixed zoom = a screenful around the player
|
||||
var aspect = w / h, span = 8 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
|
||||
this.camera.left = -span * aspect;
|
||||
this.camera.right = span * aspect;
|
||||
this.camera.top = span;
|
||||
@@ -73,6 +87,24 @@
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype.setZoom = function (zoom) {
|
||||
var next = Math.max(0.55, Math.min(3.2, Number(zoom) || 1));
|
||||
if (Math.abs(next - this.zoom) < 0.001) return;
|
||||
this.zoom = next;
|
||||
this._cw = null; // force projection update even when the host dimensions did not change
|
||||
this._fitToHost();
|
||||
};
|
||||
|
||||
// `attachCamera` reports drag pan in viewport pixels. Keep that contract for 3D and convert the
|
||||
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
|
||||
// position is available. Bound it to one viewport so a stray gesture cannot lose the dungeon.
|
||||
ThreeRenderer.prototype.setPan = function (x, y) {
|
||||
var w = (this.host && (this.host.clientWidth || this.host.offsetWidth)) || 640;
|
||||
var h = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
|
||||
this.panX = Math.max(-w, Math.min(w, Number(x) || 0));
|
||||
this.panY = Math.max(-h, Math.min(h, Number(y) || 0));
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype._mat = function (hex) {
|
||||
var T = this.THREE;
|
||||
return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 });
|
||||
@@ -297,11 +329,21 @@
|
||||
var pcam = null;
|
||||
for (var pid in this.entities) {
|
||||
var pen = this.entities[pid];
|
||||
if (pen.e && (pen.e.you || pen.e.kind === 'player')) { pcam = pen.obj; break; }
|
||||
if (pen.e && (pen.e.you || pen.e.cameraTarget)) { pcam = pen.obj; break; }
|
||||
if (!pcam && pen.e && pen.e.kind === 'player') pcam = pen.obj;
|
||||
}
|
||||
if (pcam) {
|
||||
this.camera.position.set(pcam.position.x + 8, 9, pcam.position.z + 8);
|
||||
this.camera.lookAt(pcam.position.x, 0, pcam.position.z);
|
||||
// Dragging the viewport should move the rendered dungeon with the pointer. Translate the
|
||||
// camera target opposite the corresponding screen-space axes while retaining the fixed
|
||||
// isometric camera offset and player-follow smoothing.
|
||||
var viewportHeight = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
|
||||
var unitsPerPixel = (16 / (this.zoom || 1)) / Math.max(1, viewportHeight);
|
||||
var diagonal = unitsPerPixel / Math.sqrt(2);
|
||||
var targetX = pcam.position.x + (-this.panX - this.panY) * diagonal;
|
||||
var targetZ = pcam.position.z + (this.panX - this.panY) * diagonal;
|
||||
this.camera.position.set(targetX + 8, 9, targetZ + 8);
|
||||
this.camera.lookAt(targetX, 0, targetZ);
|
||||
if (this.playerLight) this.playerLight.position.set(pcam.position.x, 3.2, pcam.position.z);
|
||||
}
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,11 +41,15 @@
|
||||
// The player/you entity that carries the torch (for the light composite + embers).
|
||||
function findPlayer(scene) {
|
||||
if (!scene || !scene.entities) return null;
|
||||
var live = null, fallback = null;
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
if (e.kind === 'player' || e.you) return e;
|
||||
if (!(e.kind === 'player' || e.you)) continue;
|
||||
if (e.you || e.cameraTarget) return e;
|
||||
if (!live && e.alive !== false && !e.finished) live = e;
|
||||
if (!fallback) fallback = e;
|
||||
}
|
||||
return null;
|
||||
return live || fallback;
|
||||
}
|
||||
|
||||
// Real-tile coord for a dungeon feature entity, keyed off its glyph.
|
||||
|
||||
@@ -37,4 +37,239 @@
|
||||
RK.applyZoom(host);
|
||||
});
|
||||
};
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function rendererCanvas(renderer) {
|
||||
return renderer && (renderer.canvas || (renderer.app && renderer.app.view) ||
|
||||
(renderer.renderer && renderer.renderer.domElement));
|
||||
}
|
||||
|
||||
// Player-follow camera for fixed viewports (single/multiplayer races and spectator views).
|
||||
// Renderers only need to expose `canvas` + `focusPoint`; ThreeRenderer instead handles zoom and
|
||||
// following internally through setZoom(). The older attachZoom API above remains unchanged for
|
||||
// whole-room previews that intentionally do not follow an avatar.
|
||||
RK.attachCamera = function (host, opts) {
|
||||
if (!host) return null;
|
||||
if (host._rkCamera) return host._rkCamera.api;
|
||||
opts = opts || {};
|
||||
var state = host._rkCamera = {
|
||||
zoom: clamp(Number(opts.zoom) || 1.35, Number(opts.min) || 0.55, Number(opts.max) || 3.2),
|
||||
defaultZoom: Number(opts.zoom) || 1.35,
|
||||
fitRatio: Number(opts.fitRatio) || 0,
|
||||
autoFit: false,
|
||||
min: Number(opts.min) || 0.55,
|
||||
max: Number(opts.max) || 3.2,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
renderer: null,
|
||||
pointers: {},
|
||||
pinchDistance: null,
|
||||
listeners: [],
|
||||
destroyed: false
|
||||
};
|
||||
host.style.position = host.style.position || 'relative';
|
||||
host.style.overflow = 'hidden';
|
||||
host.style.touchAction = 'none';
|
||||
host.style.cursor = 'grab';
|
||||
|
||||
function updateReadout() {
|
||||
if (state.readout) state.readout.textContent = Math.round(state.zoom * 100) + '%';
|
||||
}
|
||||
function listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
state.listeners.push({ target: target, type: type, handler: handler, options: options });
|
||||
}
|
||||
function fitZoom(renderer, ratio) {
|
||||
if (!renderer || renderer.name === '3d') return clamp(state.defaultZoom, state.min, state.max);
|
||||
var canvas = rendererCanvas(renderer);
|
||||
if (!canvas) return clamp(state.defaultZoom, state.min, state.max);
|
||||
var cw = canvas.width || canvas.offsetWidth || 1;
|
||||
var ch = canvas.height || canvas.offsetHeight || 1;
|
||||
var w = host.clientWidth || host.offsetWidth || 640;
|
||||
var h = host.clientHeight || host.offsetHeight || 400;
|
||||
return clamp(Math.min(w / cw, h / ch) * (Number(ratio) || state.fitRatio || 0.72), state.min, state.max);
|
||||
}
|
||||
function setZoom(next, manual) {
|
||||
if (state.destroyed) return;
|
||||
if (manual) state.autoFit = false;
|
||||
state.zoom = clamp(Number(next) || 1, state.min, state.max);
|
||||
updateReadout();
|
||||
RK.updateCamera(host, state.renderer);
|
||||
}
|
||||
function reset() {
|
||||
if (state.destroyed) return;
|
||||
state.panX = 0;
|
||||
state.panY = 0;
|
||||
state.autoFit = !!(state.fitRatio && state.renderer);
|
||||
setZoom(state.autoFit ? fitZoom(state.renderer) : state.defaultZoom);
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
if (state.destroyed) return;
|
||||
e.preventDefault();
|
||||
setZoom(state.zoom * Math.exp(-e.deltaY * 0.0015), true);
|
||||
}
|
||||
function onDoubleClick(e) {
|
||||
if (state.destroyed) return;
|
||||
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
|
||||
reset();
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (state.destroyed) return;
|
||||
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
|
||||
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
|
||||
state.dragId = e.pointerId;
|
||||
host.style.cursor = 'grabbing';
|
||||
try { host.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
}
|
||||
function onPointerMove(e) {
|
||||
if (state.destroyed) return;
|
||||
var prev = state.pointers[e.pointerId];
|
||||
if (!prev) return;
|
||||
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
|
||||
var ids = Object.keys(state.pointers);
|
||||
if (ids.length > 1) {
|
||||
var a = state.pointers[ids[0]], b = state.pointers[ids[1]];
|
||||
var dist = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
if (state.pinchDistance) setZoom(state.zoom * (dist / state.pinchDistance));
|
||||
state.pinchDistance = dist;
|
||||
} else {
|
||||
state.autoFit = false;
|
||||
state.panX += e.clientX - prev.x;
|
||||
state.panY += e.clientY - prev.y;
|
||||
RK.updateCamera(host, state.renderer);
|
||||
}
|
||||
}
|
||||
function releasePointer(e) {
|
||||
if (state.destroyed) return;
|
||||
delete state.pointers[e.pointerId];
|
||||
state.pinchDistance = null;
|
||||
if (!Object.keys(state.pointers).length) host.style.cursor = 'grab';
|
||||
}
|
||||
listen(host, 'wheel', onWheel, { passive: false });
|
||||
listen(host, 'dblclick', onDoubleClick);
|
||||
listen(host, 'pointerdown', onPointerDown);
|
||||
listen(host, 'pointermove', onPointerMove);
|
||||
listen(host, 'pointerup', releasePointer);
|
||||
listen(host, 'pointercancel', releasePointer);
|
||||
|
||||
if (opts.controls !== false && root.document) {
|
||||
var controls = root.document.createElement('div');
|
||||
controls.className = 'rk-camera-controls';
|
||||
controls.setAttribute('aria-label', 'Camera controls');
|
||||
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
|
||||
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
|
||||
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px ui-monospace,monospace;';
|
||||
function button(label, title, fn) {
|
||||
var b = root.document.createElement('button');
|
||||
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
|
||||
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
|
||||
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
|
||||
listen(b, 'click', function (e) { e.stopPropagation(); fn(); });
|
||||
controls.appendChild(b);
|
||||
}
|
||||
button('−', 'Zoom out', function () { setZoom(state.zoom / 1.18, true); });
|
||||
state.readout = root.document.createElement('span');
|
||||
state.readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
|
||||
controls.appendChild(state.readout);
|
||||
button('+', 'Zoom in', function () { setZoom(state.zoom * 1.18, true); });
|
||||
button('⌂', 'Reset camera', reset);
|
||||
host.appendChild(controls);
|
||||
state.controls = controls;
|
||||
}
|
||||
updateReadout();
|
||||
|
||||
if (typeof root.ResizeObserver === 'function') {
|
||||
state.observer = new root.ResizeObserver(function () {
|
||||
if (state.destroyed) return;
|
||||
if (state.autoFit) setZoom(fitZoom(state.renderer));
|
||||
else RK.updateCamera(host, state.renderer);
|
||||
});
|
||||
state.observer.observe(host);
|
||||
}
|
||||
state.api = {
|
||||
update: function (renderer) {
|
||||
if (!state.destroyed) RK.updateCamera(host, renderer);
|
||||
},
|
||||
setZoom: setZoom,
|
||||
setDefaultZoom: function (zoom) {
|
||||
if (!state.destroyed) state.defaultZoom = Number(zoom) || state.defaultZoom;
|
||||
},
|
||||
fit: function (renderer, ratio) {
|
||||
if (state.destroyed) return;
|
||||
state.renderer = renderer || state.renderer;
|
||||
if (ratio != null) state.fitRatio = Number(ratio) || state.fitRatio;
|
||||
state.panX = 0;
|
||||
state.panY = 0;
|
||||
state.autoFit = !!state.renderer;
|
||||
setZoom(fitZoom(state.renderer));
|
||||
},
|
||||
reset: reset,
|
||||
getZoom: function () { return state.zoom; },
|
||||
destroy: function () {
|
||||
if (state.destroyed) return;
|
||||
state.destroyed = true;
|
||||
state.listeners.splice(0).forEach(function (entry) {
|
||||
if (entry.target && typeof entry.target.removeEventListener === 'function') {
|
||||
entry.target.removeEventListener(entry.type, entry.handler, entry.options);
|
||||
}
|
||||
});
|
||||
Object.keys(state.pointers).forEach(function (pointerId) {
|
||||
try {
|
||||
if (typeof host.releasePointerCapture === 'function') {
|
||||
host.releasePointerCapture(Number(pointerId));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
state.pointers = {};
|
||||
state.pinchDistance = null;
|
||||
host.style.cursor = 'grab';
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
if (state.controls && state.controls.parentNode) state.controls.parentNode.removeChild(state.controls);
|
||||
state.controls = null;
|
||||
state.readout = null;
|
||||
state.renderer = null;
|
||||
if (host._rkCamera === state) delete host._rkCamera;
|
||||
}
|
||||
};
|
||||
return state.api;
|
||||
};
|
||||
|
||||
RK.updateCamera = function (host, renderer) {
|
||||
var state = host && host._rkCamera;
|
||||
if (!state || !renderer) return;
|
||||
state.renderer = renderer;
|
||||
if (renderer.name === '3d') {
|
||||
if (typeof renderer.setZoom === 'function') renderer.setZoom(state.zoom);
|
||||
if (typeof renderer.setPan === 'function') renderer.setPan(state.panX, state.panY);
|
||||
return;
|
||||
}
|
||||
var canvas = rendererCanvas(renderer);
|
||||
if (!canvas) return;
|
||||
var cw = canvas.width || canvas.offsetWidth || 1;
|
||||
var ch = canvas.height || canvas.offsetHeight || 1;
|
||||
var w = host.clientWidth || host.offsetWidth || 640;
|
||||
var h = host.clientHeight || host.offsetHeight || 400;
|
||||
var focus = renderer.focusPoint || { x: cw / 2, y: ch / 2 };
|
||||
var z = state.zoom;
|
||||
var tx = w / 2 - focus.x * z + state.panX;
|
||||
var ty = h / 2 - focus.y * z + state.panY;
|
||||
var sw = cw * z, sh = ch * z;
|
||||
tx = sw <= w ? (w - sw) / 2 : clamp(tx, w - sw, 0);
|
||||
ty = sh <= h ? (h - sh) / 2 : clamp(ty, h - sh, 0);
|
||||
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.maxWidth = 'none';
|
||||
canvas.style.maxHeight = 'none';
|
||||
canvas.style.transformOrigin = '0 0';
|
||||
canvas.style.imageRendering = 'pixelated';
|
||||
canvas.style.transition = 'transform 110ms ease-out';
|
||||
canvas.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + z + ')';
|
||||
};
|
||||
})(window);
|
||||
|
||||
+13
-6
@@ -33,6 +33,16 @@ const HelpModal = {
|
||||
|
||||
updateConfig: function(config) {
|
||||
this._config = config;
|
||||
const payoutsOn = !!(config && (config.directPayoutsEnabled || config.creditsPayoutsEnabled));
|
||||
if (payoutsOn) {
|
||||
$('#helpPayoutTitle').text('🎁 Payouts');
|
||||
$('#helpPayoutCopy').text('Set your payout address with “Manage Payout Address.” An escape pays the configured reward automatically, and escaping with the treasure earns the higher multiplier.');
|
||||
$('#helpTreasureLegend').text('Treasure - collect for the higher payout and score bonus');
|
||||
} else {
|
||||
$('#helpPayoutTitle').text('🏅 Prestige rewards');
|
||||
$('#helpPayoutCopy').text('This server does not pay crypto winnings. Paid entries and credits qualify scores for the Hall of Champions; free runs stay on the separate Pleb leaderboard.');
|
||||
$('#helpTreasureLegend').text('Treasure - collect for a score bonus');
|
||||
}
|
||||
},
|
||||
|
||||
show: function() {
|
||||
@@ -73,7 +83,7 @@ const HelpModal = {
|
||||
// discount; 1 credit = 1 run either way.
|
||||
const price = config.singleGamePriceFormatted || '1';
|
||||
const packages = config.creditPackages || [];
|
||||
const payoutsOn = !!(config.payoutsEnabled || config.directPayoutsEnabled || config.creditsPayoutsEnabled);
|
||||
const payoutsOn = !!(config.directPayoutsEnabled || config.creditsPayoutsEnabled);
|
||||
|
||||
const buy = [];
|
||||
if (directOn) buy.push(`<strong>1 credit</strong> — ${price} ${currency}`);
|
||||
@@ -82,16 +92,13 @@ const HelpModal = {
|
||||
const bonus = pkg.bonus > 0 ? ` +${pkg.bonus}` : '';
|
||||
return `${pkg.credits}${bonus} for ${pkg.priceFormatted}`;
|
||||
}).join(' · ');
|
||||
buy.push(`<strong>Bundles</strong> — ${bundles} ${currency} <span style="color:#888;">(cheaper per run)</span>`);
|
||||
buy.push(`<strong>Bundles</strong> — ${bundles} ${currency}`);
|
||||
}
|
||||
|
||||
// Reward line is config-driven so it stays honest on prestige (no-payout) deployments.
|
||||
let reward;
|
||||
if (payoutsOn) {
|
||||
const m = (config.payoutMultipliers && (config.payoutMultipliers.direct || config.payoutMultipliers.credits)) || {};
|
||||
const esc = m.escape || 2;
|
||||
const tre = m.escapeWithTreasure || m.treasure || 3;
|
||||
reward = `<span style="color:#4ade80;">💰 Escape pays <strong>${esc}×</strong> · with treasure <strong>${tre}×</strong>.</span>`;
|
||||
reward = '<span style="color:#4ade80;">💰 Exact escape and treasure rewards are shown before entry and locked for that run.</span>';
|
||||
} else {
|
||||
reward = `<span style="color:#fbbf24;">🏅 No crypto payout — escape for the win; top scores enter the <strong>Hall of Champions</strong>.</span>`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const Leaderboard = {
|
||||
_isLoading: false,
|
||||
_currentPeriod: 'all',
|
||||
_currentBoard: 'champions', // 'champions' (paid) | 'pleb' (free)
|
||||
_currentBoard: 'champions', // solo paid | solo/free match | credits-prestige PvP
|
||||
|
||||
init: function() {
|
||||
$('#leaderboardButton').on('click', function() {
|
||||
@@ -33,16 +33,18 @@ const Leaderboard = {
|
||||
if ($('#leaderboard-panel').length) return;
|
||||
|
||||
var panel = $(
|
||||
'<div id="leaderboard-panel" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:2100; background:rgba(10,10,20,0.97); border:2px solid #f59e0b; padding:20px; color:#e0e0e0; min-width:380px; max-width:500px; max-height:80vh; overflow-y:auto; box-shadow:0 0 20px rgba(245,158,11,0.3); border-radius:6px;">' +
|
||||
'<div id="leaderboard-panel" role="dialog" aria-modal="false" aria-labelledby="leaderboard-title" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:2100; background:rgba(10,10,20,0.97); border:2px solid #f59e0b; padding:20px; color:#e0e0e0; min-width:380px; max-width:500px; max-height:80vh; overflow-y:auto; box-shadow:0 0 20px rgba(245,158,11,0.3); border-radius:6px;">' +
|
||||
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">' +
|
||||
'<strong id="leaderboard-title" style="color:#fbbf24; font-size:1.1em;">🏅 Hall of Champions</strong>' +
|
||||
'<button id="close-leaderboard" style="background:#500; color:#f00; border:1px solid #f00; padding:2px 8px; cursor:pointer; font-size:14px;">✕</button>' +
|
||||
'<button id="close-leaderboard" type="button" aria-label="Close scores" style="background:#500; color:#f00; border:1px solid #f00; padding:2px 8px; cursor:pointer; font-size:14px;">✕</button>' +
|
||||
'</div>' +
|
||||
'<div id="leaderboard-boards" style="display:flex; gap:4px; margin-bottom:8px;">' +
|
||||
'<button class="lb-board" data-board="champions" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🏅 Champions</button>' +
|
||||
'<button class="lb-board" data-board="pleb" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🪙 Pleb</button>' +
|
||||
'<div id="leaderboard-boards" role="group" aria-label="Score board" style="display:flex; gap:4px; margin-bottom:8px;">' +
|
||||
'<button type="button" class="lb-board" data-board="champions" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🏅 Champions</button>' +
|
||||
'<button type="button" class="lb-board" data-board="prestige" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">✨ Prestige</button>' +
|
||||
'<button type="button" class="lb-board" data-board="pleb" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🪙 Free / Pleb</button>' +
|
||||
'</div>' +
|
||||
'<div id="leaderboard-tabs" style="display:flex; gap:4px; margin-bottom:12px;">' +
|
||||
'<div id="leaderboard-board-note" style="margin:0 0 8px;min-height:24px;color:#9ca3af;font-size:10px;line-height:1.25;"></div>' +
|
||||
'<div id="leaderboard-tabs" role="group" aria-label="Score period" style="display:flex; gap:4px; margin-bottom:12px;">' +
|
||||
'<button class="lb-tab" data-period="all" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">All Time</button>' +
|
||||
'<button class="lb-tab" data-period="month" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Month</button>' +
|
||||
'<button class="lb-tab" data-period="week" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Week</button>' +
|
||||
@@ -84,13 +86,30 @@ const Leaderboard = {
|
||||
});
|
||||
var board = this._currentBoard;
|
||||
$('.lb-board').each(function() {
|
||||
if ($(this).data('board') === board) {
|
||||
var selected = $(this).data('board') === board;
|
||||
$(this).attr('aria-pressed', selected ? 'true' : 'false');
|
||||
if (selected) {
|
||||
$(this).css({ background: '#92400e', color: '#fff', borderColor: '#f59e0b' });
|
||||
} else {
|
||||
$(this).css({ background: '#1a1a2e', color: '#aaa', borderColor: '#555' });
|
||||
}
|
||||
});
|
||||
$('#leaderboard-title').text(board === 'pleb' ? '🪙 Pleb Board' : '🏅 Hall of Champions');
|
||||
var boardMeta = {
|
||||
champions: {
|
||||
title: '🏅 Hall of Champions',
|
||||
note: 'Paid solo and configured crypto-stakes competitive results; never mixed with Free or Prestige.'
|
||||
},
|
||||
prestige: {
|
||||
title: '✨ PvP Prestige',
|
||||
note: 'Credit-entry competitive PvP scores only; never mixed with Free or the Hall of Champions.'
|
||||
},
|
||||
pleb: {
|
||||
title: '🪙 Free / Pleb Board',
|
||||
note: 'Free solo and free competitive results only; no paid entries or payouts.'
|
||||
}
|
||||
}[board] || {};
|
||||
$('#leaderboard-title').text(boardMeta.title || 'Scores');
|
||||
$('#leaderboard-board-note').text(boardMeta.note || '');
|
||||
},
|
||||
|
||||
_fetchAndRender: function() {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
var MODES = {
|
||||
race: {
|
||||
label: 'Dungeon Race',
|
||||
description: 'Outrun the other delvers, survive the shared dungeon, and reach the exit first.',
|
||||
badge: 'FIRST TO EXIT',
|
||||
objective: 'Reach the exit (→) first.',
|
||||
participant: 'racer',
|
||||
complete: 'Race complete',
|
||||
victory: '🏆 You escaped first!'
|
||||
},
|
||||
'last-alive': {
|
||||
label: 'Last Delver Standing',
|
||||
description: 'Rivals can strike each other down. Survive the dungeon and be the last contender alive.',
|
||||
badge: 'LAST ALIVE',
|
||||
objective: 'Stay alive; move into a rival to strike.',
|
||||
participant: 'contender',
|
||||
complete: 'Last-delver round complete',
|
||||
victory: '🏆 You are the last delver standing!'
|
||||
},
|
||||
'score-attack': {
|
||||
label: 'Score Attack',
|
||||
description: 'Build the strongest run before the arena clock expires.',
|
||||
badge: 'HIGH SCORE',
|
||||
objective: 'Build the highest score before time expires.',
|
||||
participant: 'delver',
|
||||
complete: 'Score attack complete',
|
||||
victory: '🏆 You set the high score!'
|
||||
},
|
||||
'coop-escape': {
|
||||
label: 'Co-op Escape',
|
||||
description: 'Work together and get every surviving delver to safety.',
|
||||
badge: 'TEAM ESCAPE',
|
||||
objective: 'Work together and get every surviving delver to the exit (→).',
|
||||
participant: 'teammate',
|
||||
complete: 'Co-op expedition complete',
|
||||
victory: '🤝 Team escaped!'
|
||||
}
|
||||
};
|
||||
|
||||
function cleanId(raw) {
|
||||
var id = raw && (raw.id || raw.rulesetId);
|
||||
return Object.prototype.hasOwnProperty.call(MODES, id) ? id : 'race';
|
||||
}
|
||||
|
||||
function normalize(raw) {
|
||||
raw = raw || {};
|
||||
var id = cleanId(raw);
|
||||
var base = MODES[id];
|
||||
var win = raw.winCondition;
|
||||
if (win && typeof win === 'object') win = win.type;
|
||||
return {
|
||||
id: id,
|
||||
label: raw.label || base.label,
|
||||
description: raw.description || raw.summary || base.description,
|
||||
badge: String(win || raw.win || base.badge).replace(/[-_]/g, ' ').toUpperCase(),
|
||||
objective: base.objective,
|
||||
participant: base.participant,
|
||||
complete: base.complete,
|
||||
victory: base.victory,
|
||||
cooperative: id === 'coop-escape'
|
||||
};
|
||||
}
|
||||
|
||||
function fromPayload(data, fallback) {
|
||||
data = data || {};
|
||||
var state = data.state || data.gameState || {};
|
||||
return normalize(data.ruleset || state.ruleset || fallback || {});
|
||||
}
|
||||
|
||||
function plural(mode, count) {
|
||||
var word = normalize(mode).participant;
|
||||
return word + (Number(count) === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function playerFor(players, viewerId) {
|
||||
players = Array.isArray(players) ? players : [];
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var player = players[i];
|
||||
if (player && (player.you || (viewerId && player.id === viewerId))) return player;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function winnerFor(data, players) {
|
||||
players = Array.isArray(players) ? players : [];
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
if (players[i] && players[i].id === data.winnerId) return players[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function finalResult(data, viewerId, fallbackRuleset) {
|
||||
data = data || {};
|
||||
var players = Array.isArray(data.players) ? data.players : [];
|
||||
var mode = fromPayload(data, fallbackRuleset);
|
||||
var me = playerFor(players, viewerId);
|
||||
var reason = data.reason || data.endReason || '';
|
||||
|
||||
if (mode.cooperative) {
|
||||
var escaped = reason === 'all_escaped';
|
||||
var detail = '';
|
||||
if (me) detail = me.escaped ? 'you reached safety' : 'you did not escape';
|
||||
return {
|
||||
mode: mode,
|
||||
won: escaped,
|
||||
headline: escaped ? mode.victory : '☠️ The expedition was lost',
|
||||
detail: detail,
|
||||
text: (escaped ? mode.victory : '☠️ The expedition was lost') + (detail ? ' · ' + detail : '')
|
||||
};
|
||||
}
|
||||
|
||||
var winner = winnerFor(data, players);
|
||||
var won = !!me && (me.id === data.winnerId || (!data.winnerId && Number(me.placement) === 1));
|
||||
var details = [];
|
||||
if (me && me.placement) details.push('you placed #' + me.placement);
|
||||
if (mode.id === 'score-attack' && me && Number.isFinite(Number(me.score))) {
|
||||
details.push('score ' + Number(me.score));
|
||||
}
|
||||
if (!won && winner) details.push('winner: ' + (winner.name || 'another delver'));
|
||||
var headline = won ? mode.victory : mode.complete;
|
||||
return {
|
||||
mode: mode,
|
||||
won: won,
|
||||
headline: headline,
|
||||
detail: details.join(' · '),
|
||||
text: headline + (details.length ? ' · ' + details.join(' · ') : '')
|
||||
};
|
||||
}
|
||||
|
||||
function liveSummary(state, viewerId, fallbackRuleset) {
|
||||
state = state || {};
|
||||
var players = Array.isArray(state.players) ? state.players : [];
|
||||
var mode = fromPayload({ state: state }, fallbackRuleset);
|
||||
var me = playerFor(players, viewerId);
|
||||
var alive = players.filter(function (p) { return p && p.alive !== false && !p.finished; }).length;
|
||||
var escaped = players.filter(function (p) { return p && p.escaped; }).length;
|
||||
|
||||
if (mode.id === 'last-alive') {
|
||||
return alive + ' ' + plural(mode, alive) + ' still in' + (me && me.alive === false ? ' · you are out' : '');
|
||||
}
|
||||
if (mode.id === 'score-attack') {
|
||||
return alive + ' ' + plural(mode, alive) + ' still scoring' + (me && me.alive === false ? ' · you are out' : '');
|
||||
}
|
||||
if (mode.cooperative) {
|
||||
return escaped + '/' + players.length + ' safe · ' + alive + ' still exploring';
|
||||
}
|
||||
if (me && me.escaped) return 'You escaped';
|
||||
if (me && me.alive === false) return 'You are out · ' + alive + ' still racing';
|
||||
return players.length + ' ' + plural(mode, players.length) + ' in the dungeon';
|
||||
}
|
||||
|
||||
root.MatchUiCopy = {
|
||||
modes: MODES,
|
||||
normalize: normalize,
|
||||
fromPayload: fromPayload,
|
||||
plural: plural,
|
||||
finalResult: finalResult,
|
||||
liveSummary: liveSummary
|
||||
};
|
||||
})(window);
|
||||
+50
-24
@@ -143,6 +143,23 @@ const PaymentUI = {
|
||||
return `<div style="font-size:0.78em;color:#fbbf24;margin-top:4px;margin-left:${options.indent || 0}px;">Includes: ${this.escapeHtml(parts.join(' • '))}</div>`;
|
||||
},
|
||||
|
||||
payoutSummaryHtml: function(mode, currency) {
|
||||
const outcomes = this.config?.payoutOutcomes?.[mode];
|
||||
if (!outcomes?.escape || !outcomes?.escapeWithTreasure) {
|
||||
return '<span style="color:#f87171;">Exact payout terms are unavailable. Reload before entering paid play.</span>';
|
||||
}
|
||||
const describe = (label, outcome) => {
|
||||
if (!outcome.payable) {
|
||||
const why = outcome.suppressedReason === 'below_minimum' ? ' (below configured minimum)' : '';
|
||||
return `${label}: no payout${why}`;
|
||||
}
|
||||
const amount = `${this.escapeHtml(outcome.amountFormatted)} ${this.escapeHtml(currency)}`;
|
||||
if (outcome.capApplied) return `${label}: ${amount} (configured cap applied)`;
|
||||
return `${label}: ${this.escapeHtml(outcome.multiplier)}× = ${amount}`;
|
||||
};
|
||||
return `<span style="color:#4ade80;">💰 ${describe('Escape', outcomes.escape)} • ${describe('Treasure', outcomes.escapeWithTreasure)}</span>`;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
if (!this.config) return;
|
||||
|
||||
@@ -172,15 +189,10 @@ const PaymentUI = {
|
||||
// === SECTION 1: Use existing credits (if available) ===
|
||||
if (hasCredits && this.config.creditsModeEnabled) {
|
||||
const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled;
|
||||
const creditsMultipliers = this.config.payoutMultipliers?.credits || { escape: 2, escapeWithTreasure: 3 };
|
||||
const creditsBaseValue = this.config.creditsPayoutBaseValue || this.config.singleGamePrice;
|
||||
|
||||
let payoutNote = '';
|
||||
if (creditsPayoutsEnabled) {
|
||||
const baseFormatted = this.formatPrice(creditsBaseValue);
|
||||
const escapeWin = (parseFloat(baseFormatted) * creditsMultipliers.escape).toFixed(2);
|
||||
const treasureWin = (parseFloat(baseFormatted) * creditsMultipliers.escapeWithTreasure).toFixed(2);
|
||||
payoutNote = `<span style="color:#4ade80;">💰 Win: ${creditsMultipliers.escape}x (${escapeWin} ${currency}) • Treasure: ${creditsMultipliers.escapeWithTreasure}x (${treasureWin} ${currency})</span><br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
|
||||
payoutNote = `${this.payoutSummaryHtml('credits', currency)}<br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
|
||||
} else {
|
||||
payoutNote = '<span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>';
|
||||
}
|
||||
@@ -204,7 +216,6 @@ const PaymentUI = {
|
||||
// === SECTION 2: Direct payment (pay per game) ===
|
||||
if (this.config.directModeEnabled) {
|
||||
const price = this.config.singleGamePriceFormatted || this.formatPrice(this.config.singleGamePrice);
|
||||
const multipliers = this.config.payoutMultipliers?.direct || { escape: 2, escapeWithTreasure: 3 };
|
||||
// Payout-honest: only show multipliers when payouts are actually on (default off, not on).
|
||||
const directPayoutsEnabled = this.config.directPayoutsEnabled != null
|
||||
? !!this.config.directPayoutsEnabled
|
||||
@@ -212,9 +223,7 @@ const PaymentUI = {
|
||||
|
||||
let payoutInfo = '';
|
||||
if (directPayoutsEnabled) {
|
||||
const escapeWin = (parseFloat(price) * multipliers.escape).toFixed(2);
|
||||
const treasureWin = (parseFloat(price) * multipliers.escapeWithTreasure).toFixed(2);
|
||||
payoutInfo = `<span style="color:#4ade80;">Win: ${multipliers.escape}x (${escapeWin} ${currency}) • Treasure: ${multipliers.escapeWithTreasure}x (${treasureWin} ${currency})</span><br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
|
||||
payoutInfo = `${this.payoutSummaryHtml('direct', currency)}<br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
|
||||
} else {
|
||||
payoutInfo = '<span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>';
|
||||
}
|
||||
@@ -241,16 +250,11 @@ const PaymentUI = {
|
||||
if (this.config.creditsModeEnabled && creditPackages.length > 0) {
|
||||
const singlePrice = this.config.singleGamePrice;
|
||||
const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled;
|
||||
const creditsMultipliers = this.config.payoutMultipliers?.credits || { escape: 2, escapeWithTreasure: 3 };
|
||||
const creditsBaseValue = this.config.creditsPayoutBaseValue || this.config.singleGamePrice;
|
||||
|
||||
// Build payout info for credits mode
|
||||
let creditsPayoutInfo = '';
|
||||
if (creditsPayoutsEnabled) {
|
||||
const baseFormatted = this.formatPrice(creditsBaseValue);
|
||||
const escapeWin = (parseFloat(baseFormatted) * creditsMultipliers.escape).toFixed(2);
|
||||
const treasureWin = (parseFloat(baseFormatted) * creditsMultipliers.escapeWithTreasure).toFixed(2);
|
||||
creditsPayoutInfo = `<div style="font-size:0.85em;color:#4ade80;margin-bottom:10px;">💰 Win: ${creditsMultipliers.escape}x (${escapeWin} ${currency}) • Treasure: ${creditsMultipliers.escapeWithTreasure}x (${treasureWin} ${currency})</div>`;
|
||||
creditsPayoutInfo = `<div style="font-size:0.85em;margin-bottom:10px;">${this.payoutSummaryHtml('credits', currency)}</div>`;
|
||||
} else {
|
||||
creditsPayoutInfo = `<div style="font-size:0.85em;color:#fbbf24;margin-bottom:10px;">🏅 High scores enter the Hall of Champions • bulk discount</div>`;
|
||||
}
|
||||
@@ -283,7 +287,7 @@ const PaymentUI = {
|
||||
packagesHtml += `
|
||||
<label style="display:block;padding:10px;margin:5px 0;background:#252540;border:1px solid #444;border-radius:4px;cursor:pointer;"
|
||||
class="package-option" data-package-id="${pkg.id}">
|
||||
<input type="radio" name="credit-package" value="${pkg.id}" ${checked} style="margin-right:10px;">
|
||||
<input type="radio" name="credit-package" value="${this.escapeHtml(pkg.id)}" ${checked} style="margin-right:10px;">
|
||||
<strong>${pkg.credits} credits${bonusText}</strong>${discountBadge}
|
||||
<span style="float:right;color:#fbbf24;font-weight:bold;">${priceFormatted} ${currency}</span>
|
||||
<div style="font-size:0.8em;color:#888;margin-top:4px;margin-left:22px;">
|
||||
@@ -298,7 +302,7 @@ const PaymentUI = {
|
||||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
|
||||
<div style="margin-bottom:10px;">
|
||||
<strong style="color:#f0f0f0;">🎫 Buy Credits</strong>
|
||||
<span style="font-size:0.85em;color:#888;margin-left:10px;">Bulk discount • Never expire</span>
|
||||
<span style="font-size:0.85em;color:#888;margin-left:10px;">Bulk discount • tied to this browser session</span>
|
||||
</div>
|
||||
${creditsPayoutInfo}
|
||||
<div class="packages-list">
|
||||
@@ -370,24 +374,39 @@ const PaymentUI = {
|
||||
}
|
||||
},
|
||||
|
||||
handleModeSelection: function(mode, action, packageId) {
|
||||
handleModeSelection: function(mode, action, packageId, acknowledgementReady, legalAcknowledgement) {
|
||||
console.log("Mode selected:", mode, "Action:", action, "Package:", packageId);
|
||||
|
||||
if (mode === 'FREE') {
|
||||
$('#payment-ui').hide();
|
||||
if (window.socket) {
|
||||
if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
// play_free records the game as FREE (Pleb leaderboard, no payout) even on
|
||||
// an instance that also sells credits/entry.
|
||||
window.socket.emit('play_free');
|
||||
SocketHandlers.emitFairGameStart('play_free', { free: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A checkbox in the browser is not a legal/identity verification, but requiring a fresh
|
||||
// per-session statement prevents the UI from silently opening a value-bearing action.
|
||||
if (!acknowledgementReady) {
|
||||
if (typeof CommerceConsent === 'undefined' || !CommerceConsent.require) {
|
||||
$('#payment-status').text('Paid-play disclosures are unavailable. No payment was requested.');
|
||||
return;
|
||||
}
|
||||
CommerceConsent.require(function(acknowledgement) {
|
||||
PaymentUI.handleModeSelection(mode, action, packageId, true, acknowledgement);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If using existing credits, just start the game
|
||||
if (action === 'use_credit' && this.userCredits >= 1) {
|
||||
$('#payment-ui').hide();
|
||||
if (window.socket) {
|
||||
window.socket.emit('enter_game');
|
||||
if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
SocketHandlers.emitFairGameStart('auto_start', {
|
||||
legalAcknowledgement: legalAcknowledgement
|
||||
}, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -401,6 +420,7 @@ const PaymentUI = {
|
||||
}
|
||||
|
||||
const requestData = { type: paymentType };
|
||||
if (legalAcknowledgement) requestData.legalAcknowledgement = legalAcknowledgement;
|
||||
if (packageId) {
|
||||
if (paymentType === 'cosmetic_pack') {
|
||||
requestData.productId = packageId;
|
||||
@@ -410,7 +430,13 @@ const PaymentUI = {
|
||||
}
|
||||
|
||||
if (window.socket) {
|
||||
window.socket.emit('request_payment', requestData);
|
||||
if (paymentType === 'single_game' && typeof SocketHandlers !== 'undefined' && SocketHandlers.fairnessAttempt) {
|
||||
var fairRequest = SocketHandlers.fairnessAttempt(requestData);
|
||||
if (!fairRequest) return;
|
||||
window.socket.emit('request_payment', fairRequest);
|
||||
} else {
|
||||
window.socket.emit('request_payment', requestData);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
$('.game-modes').hide();
|
||||
|
||||
@@ -46,11 +46,17 @@
|
||||
var modeRow = doc().createElement('div');
|
||||
modeRow.className = 'rp-row';
|
||||
RK.RENDER_MODES.forEach(function (m) {
|
||||
var usable = !RK.canUseMode || RK.canUseMode(m.id);
|
||||
var availability = RK.modeAvailability
|
||||
? RK.modeAvailability(m.id)
|
||||
: { usable: !RK.canUseMode || RK.canUseMode(m.id), runtimeAvailable: true };
|
||||
var usable = availability.usable;
|
||||
var b = doc().createElement('button');
|
||||
b.className = 'rp-btn' + (m.id === current ? ' active' : '') + (usable ? '' : ' locked');
|
||||
b.textContent = m.label + (m.premium && !usable ? ' 🔒' : '');
|
||||
b.title = usable ? m.label : (m.label + ' — unlock with credits');
|
||||
b.textContent = m.label + (!availability.runtimeAvailable ? ' ⛔' : (m.premium && !usable ? ' 🔒' : ''));
|
||||
b.title = !availability.runtimeAvailable
|
||||
? (m.label + ' — unavailable on this server (optional renderer dependency disabled)')
|
||||
: (usable ? m.label : (m.label + ' — unlock with credits'));
|
||||
b.setAttribute('aria-disabled', usable ? 'false' : 'true');
|
||||
b.onclick = function () {
|
||||
if (!usable) return;
|
||||
if (RK.SPGame.setMode(m.id)) build();
|
||||
|
||||
+123
-28
@@ -3,71 +3,166 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wownerogue — Match Race</title>
|
||||
<title>Wownerogue — Multiplayer Arena</title>
|
||||
<link rel="stylesheet" href="styles/commerce-consent.css">
|
||||
<style>
|
||||
:root { --bg: #0a0c0f; --text: #c9d1d9; --accent: #58a65c; --line: #30363d; }
|
||||
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
|
||||
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator-Bold.ttf') format('truetype'); font-weight: 700; font-display: swap; }
|
||||
:root { --bg: #07090c; --panel: #11161b; --panel2: #171d24; --text: #d7dde5; --muted: #8b97a7;
|
||||
--accent: #55d96b; --gold: #f2b84b; --cyan: #62d8ff; --purple: #a98bff; --line: #303946; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#app { max-width: 900px; margin: 0 auto; padding: 16px; }
|
||||
h1 { margin: 0 0 8px; font-size: 1.4rem; }
|
||||
.muted { color: #8b949e; font-size: 0.85rem; }
|
||||
#panel { background: #11161b; border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
button { background: #21262d; border: 1px solid var(--line); color: var(--text); padding: 8px 14px; border-radius: 6px; cursor: pointer; }
|
||||
button.primary { background: var(--accent); color: #08130a; border-color: var(--accent); font-weight: 600; }
|
||||
body { min-height: 100vh; margin: 0; background:
|
||||
radial-gradient(circle at 50% -20%, rgba(73,118,84,.28), transparent 42%),
|
||||
linear-gradient(180deg, #0b0e12, var(--bg) 42%); color: var(--text);
|
||||
font-family: PixelOperator, ui-monospace, monospace; }
|
||||
body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.17;
|
||||
background-image:linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);
|
||||
background-size:24px 24px; mask-image:linear-gradient(to bottom,#000,transparent 75%); }
|
||||
#app { position:relative; max-width: 1040px; margin: 0 auto; padding: 18px clamp(12px,3vw,28px) 32px; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:28px; }
|
||||
.brand { color:#fff; font-weight:700; letter-spacing:.08em; }
|
||||
.brand span { color:var(--accent); }
|
||||
nav { display:flex; gap:8px; }
|
||||
nav a { color:var(--muted); text-decoration:none; border:1px solid var(--line); border-radius:5px; padding:6px 10px; font-size:13px; }
|
||||
nav a:hover, nav a:focus-visible { color:#fff; border-color:#627083; }
|
||||
.hero { margin-bottom:14px; }
|
||||
.eyebrow { color:var(--accent); font-size:11px; letter-spacing:.18em; text-transform:uppercase; }
|
||||
h1 { margin: 5px 0 6px; color:#fff; font-size:clamp(24px,5vw,42px); line-height:1; letter-spacing:.02em; }
|
||||
.hero p { max-width:720px; margin:0; color:var(--muted); line-height:1.45; }
|
||||
.mode-summary { display:flex; align-items:center; flex-wrap:wrap; gap:7px; margin-top:12px; }
|
||||
.tag { display:inline-flex; align-items:center; min-height:24px; padding:3px 8px; border:1px solid #344b3a; border-radius:999px;
|
||||
color:#a7f3b4; background:rgba(40,110,55,.12); font-size:12px; }
|
||||
#rulesetCatalog { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
#rulesetCatalog .tag { color:var(--muted); border-color:var(--line); background:rgba(255,255,255,.025); }
|
||||
#rulesetCatalog .tag.active { color:#a7f3b4; border-color:#3d7b49; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
#panel { overflow:hidden; background:rgba(17,22,27,.96); border:1px solid var(--line); border-radius:12px;
|
||||
box-shadow:0 22px 70px rgba(0,0,0,.38), inset 0 1px rgba(255,255,255,.035); }
|
||||
#queuePanel { padding:clamp(16px,3vw,26px); }
|
||||
.section-title { margin:0 0 3px; color:#fff; font-size:17px; }
|
||||
button { font:inherit; background:#212832; border:1px solid #3b4653; color:var(--text); padding:9px 14px; border-radius:6px; cursor:pointer; }
|
||||
button:hover:not(:disabled), button:focus-visible { border-color:#718095; filter:brightness(1.1); }
|
||||
button.primary { background:linear-gradient(#67e17a,#42b957); color:#061108; border-color:#7af08b; font-weight:700; box-shadow:0 0 18px rgba(85,217,107,.14); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.economies { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.economy { padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; }
|
||||
.economy.active { border-color: var(--accent); background: rgba(88,166,92,0.12); }
|
||||
.economies { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin:14px 0; }
|
||||
.economy { min-height:92px; padding:12px; text-align:left; border:1px solid var(--line); border-radius:8px; background:var(--panel2); cursor:pointer; }
|
||||
.economy strong, .economy small { display:block; }
|
||||
.economy strong { color:#fff; margin-bottom:5px; font-size:14px; }
|
||||
.economy small { color:var(--muted); font-size:11px; line-height:1.35; }
|
||||
.economy .availability { margin-top:8px; color:#7ee38d; font-size:10px; letter-spacing:.08em; }
|
||||
.economy.active { border-color:var(--accent); background:linear-gradient(145deg,rgba(66,185,87,.16),rgba(23,29,36,.95)); box-shadow:0 0 0 1px rgba(85,217,107,.18); }
|
||||
.economy[data-economy="credits_prestige"].active { border-color:var(--purple); box-shadow:0 0 0 1px rgba(169,139,255,.18); }
|
||||
.economy[data-economy="crypto_race"].active { border-color:var(--gold); box-shadow:0 0 0 1px rgba(242,184,75,.18); }
|
||||
.economy.locked { opacity: 0.55; cursor: not-allowed; }
|
||||
#status { min-height: 24px; margin: 8px 0; }
|
||||
#stage { width: 100%; aspect-ratio: 16 / 10; background: #000; border: 1px solid var(--line); border-radius: 8px; }
|
||||
#hud { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 0.85rem; }
|
||||
#hud span { background: #161b22; padding: 4px 8px; border-radius: 4px; }
|
||||
#status { min-height:24px; margin:10px 0; color:#b8c1cc; }
|
||||
.queue-actions { display:flex; gap:8px; }
|
||||
#racePanel { position:relative; }
|
||||
.race-head { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 12px; border-bottom:1px solid var(--line); background:#0e1318; }
|
||||
#hud { display:flex; gap:6px; flex-wrap:wrap; font-size:12px; }
|
||||
#hud span { background:#192029; border:1px solid #2b3541; padding:4px 8px; border-radius:4px; }
|
||||
#racePlace { color:var(--gold); }
|
||||
#renderModes { display:flex; gap:4px; }
|
||||
#renderModes button { padding:4px 8px; font-size:11px; }
|
||||
#renderModes button.active { border-color:var(--cyan); color:var(--cyan); background:#10222a; }
|
||||
#renderModes button.locked { opacity:.42; }
|
||||
#stage { position:relative; width:100%; height:clamp(360px,64vh,650px); background:#000;
|
||||
border-bottom:1px solid var(--line); isolation:isolate; }
|
||||
#stage::after { content:""; position:absolute; inset:0; pointer-events:none; z-index:10; box-shadow:inset 0 0 70px rgba(0,0,0,.62); }
|
||||
#stage canvas { display:block; }
|
||||
.race-foot { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 12px; }
|
||||
#raceResult { position:absolute; z-index:30; inset:50% auto auto 50%; transform:translate(-50%,-50%); min-width:min(360px,86%);
|
||||
padding:18px; text-align:center; color:#fff; background:rgba(8,11,14,.94); border:1px solid var(--gold); border-radius:9px; box-shadow:0 18px 60px #000; }
|
||||
.touch-pad { display:none; grid-template-areas:". up ." "left down right"; gap:5px; }
|
||||
.touch-pad button { width:44px; height:40px; padding:0; touch-action:manipulation; }
|
||||
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
|
||||
.hidden { display: none !important; }
|
||||
@media (max-width:700px), (pointer:coarse) {
|
||||
#app { padding-inline:10px; }
|
||||
.topbar { margin-bottom:20px; }
|
||||
.economies { grid-template-columns:1fr; }
|
||||
.economy { min-height:0; }
|
||||
#stage { height:min(58vh,520px); }
|
||||
.race-head { align-items:flex-start; flex-direction:column; }
|
||||
.race-foot { align-items:flex-end; }
|
||||
.touch-pad { display:grid; }
|
||||
}
|
||||
@media (max-width:430px) { nav a { padding:5px 7px; font-size:11px; } .brand { font-size:13px; } #renderModes { max-width:100%; overflow-x:auto; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; animation:none!important; } }
|
||||
</style>
|
||||
|
||||
<!-- Render kit -->
|
||||
<script src="js/render/sceneModel.js"></script>
|
||||
<script src="js/render/fxLayer.js"></script>
|
||||
<script src="js/render/skins.js"></script>
|
||||
<script src="js/render/charCustomize.js"></script>
|
||||
<script src="js/render/atlas.js"></script>
|
||||
<script src="js/render/charSprites.js"></script>
|
||||
<script src="js/render/zoomControl.js"></script>
|
||||
<script src="js/render/packRegistry.js"></script>
|
||||
<script src="js/render/assetPacks.js"></script>
|
||||
<script src="js/render/avatarVisuals.js"></script>
|
||||
<script src="js/render/asciiRenderer.js"></script>
|
||||
<script src="js/render/tileRenderer.js"></script>
|
||||
<script src="js/render/isoRenderer.js"></script>
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/ui/matchUiCopy.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>🏁 Race Queue</h1>
|
||||
<p class="muted">Multiplayer dungeon race. A new race starts every block when at least two players are queued.</p>
|
||||
<header class="topbar">
|
||||
<div class="brand">WOWNERO<span>GUE</span> / ARENA</div>
|
||||
<nav aria-label="Main navigation"><a href="/">← Lobby</a><a href="/tavern">Tavern</a><a href="/responsible-play">Responsible Play</a></nav>
|
||||
</header>
|
||||
|
||||
<section class="hero" aria-labelledby="modeTitle">
|
||||
<div class="eyebrow">Operator-selected live mode</div>
|
||||
<h1 id="modeTitle">Dungeon Race</h1>
|
||||
<p id="modeDescription">Outrun the other delvers, survive the shared dungeon, and reach the exit first.</p>
|
||||
<div class="mode-summary"><span id="modeBadge" class="tag">FIRST TO EXIT</span><div id="rulesetCatalog" aria-label="Available multiplayer modes"></div></div>
|
||||
</section>
|
||||
|
||||
<div id="panel">
|
||||
<div id="queuePanel">
|
||||
<div class="muted">Select economy:</div>
|
||||
<div id="economies" class="economies"></div>
|
||||
<div id="status">Connect to server to see available economies.</div>
|
||||
<h2 class="section-title">Choose your entry mode</h2>
|
||||
<div id="economyHelp" class="muted">Free, Prestige, and Champions results remain on separate boards.</div>
|
||||
<div id="economies" class="economies" role="group" aria-label="Match entry mode"></div>
|
||||
<div id="status" role="status" aria-live="polite">Connecting to the arena…</div>
|
||||
|
||||
<div style="display:flex; gap:8px;">
|
||||
<div class="queue-actions">
|
||||
<button id="queueBtn" class="primary">Join Queue</button>
|
||||
<button id="leaveBtn" class="hidden">Leave Queue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="racePanel" class="hidden">
|
||||
<div id="hud">
|
||||
<span id="raceEconomy"></span>
|
||||
<span id="raceTimer"></span>
|
||||
<span id="racePlace"></span>
|
||||
<div class="race-head">
|
||||
<div id="hud" aria-label="Match status">
|
||||
<span id="raceMode"></span>
|
||||
<span id="raceEconomy"></span>
|
||||
<span id="raceTimer"></span>
|
||||
<span id="racePlace"></span>
|
||||
</div>
|
||||
<div id="renderModes" aria-label="Render style"></div>
|
||||
</div>
|
||||
<div id="stage" aria-label="Live multiplayer dungeon"></div>
|
||||
<div id="raceResult" class="hidden" role="status"></div>
|
||||
<div class="race-foot">
|
||||
<p id="objectiveHint" class="muted">Arrow keys / WASD · reach the exit (→) first · drag or pinch the camera</p>
|
||||
<div id="matchDpad" class="touch-pad" role="group" aria-label="Match movement controls">
|
||||
<button type="button" data-dir="up" aria-label="Move up">▲</button>
|
||||
<button type="button" data-dir="left" aria-label="Move left">◀</button>
|
||||
<button type="button" data-dir="down" aria-label="Move down">▼</button>
|
||||
<button type="button" data-dir="right" aria-label="Move right">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stage"></div>
|
||||
<p class="muted">Move with arrow keys or WASD. Reach the exit (→) first.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="js/legal/commerceConsent.js"></script>
|
||||
<script src="js/matchClient.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.commerce-consent-overlay { position:fixed; inset:0; z-index:100000; display:grid; place-items:center; padding:16px; background:rgba(0,0,0,.82); }
|
||||
.commerce-consent-dialog { width:min(620px,100%); max-height:calc(100vh - 32px); overflow:auto; padding:20px; border:1px solid #9a6b25; border-radius:10px; background:#11161b; color:#d7dde5; box-shadow:0 24px 80px #000; font:14px/1.45 system-ui,-apple-system,sans-serif; }
|
||||
.commerce-consent-dialog h2 { margin:0 0 10px; color:#fff; font-size:1.4rem; }
|
||||
.commerce-summary { padding:10px 12px; border-left:3px solid #efb64b; background:#211a0f; }
|
||||
.commerce-check { display:flex; align-items:flex-start; gap:10px; margin:10px 0; padding:10px; border:1px solid #303944; border-radius:6px; background:#171d24; cursor:pointer; }
|
||||
.commerce-check input { flex:0 0 auto; width:18px; height:18px; margin-top:1px; }
|
||||
.commerce-links,.commerce-links a { color:#99e9a8; }
|
||||
.commerce-status { color:#96a0ad; font-size:12px; }
|
||||
.commerce-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; }
|
||||
.commerce-actions button,.commerce-consent-dialog>button { padding:8px 12px; border:1px solid #46515e; border-radius:6px; font:inherit; cursor:pointer; }
|
||||
.commerce-cancel { background:#202730; color:#d7dde5; }
|
||||
.commerce-accept { background:#55d96b; color:#071209; font-weight:700; }
|
||||
.commerce-accept:disabled { opacity:.45; cursor:not-allowed; }
|
||||
@@ -0,0 +1,18 @@
|
||||
:root { color-scheme: dark; --bg:#090b0f; --panel:#14191f; --line:#303944; --text:#d7dbe0; --muted:#96a0ad; --accent:#64db79; --gold:#efb64b; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; background:radial-gradient(circle at 50% -15%,#25352a 0,transparent 38%),var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,sans-serif; }
|
||||
main { width:min(860px,calc(100% - 28px)); margin:0 auto; padding:24px 0 48px; }
|
||||
nav { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:28px; }
|
||||
nav a,a { color:#9eeaac; }
|
||||
nav a { padding:6px 10px; border:1px solid var(--line); border-radius:6px; text-decoration:none; }
|
||||
nav a:hover,nav a:focus-visible { border-color:var(--accent); }
|
||||
header { margin-bottom:20px; }
|
||||
h1 { margin:.15em 0; color:#fff; font-size:clamp(2rem,6vw,3.4rem); line-height:1; }
|
||||
h2 { color:#fff; font-size:1.15rem; }
|
||||
.eyebrow { margin:0; color:var(--accent); font-size:.75rem; font-weight:700; letter-spacing:.15em; text-transform:uppercase; }
|
||||
header>p:last-child,footer { color:var(--muted); }
|
||||
section { margin:12px 0; padding:18px clamp(16px,4vw,24px); border:1px solid var(--line); border-radius:10px; background:rgba(20,25,31,.96); }
|
||||
.notice { border-color:#7a5c28; background:rgba(78,55,16,.24); }
|
||||
li+li { margin-top:.45rem; }
|
||||
footer { padding:12px 4px; }
|
||||
strong { color:#fff; }
|
||||
+432
-60
@@ -3,11 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tavern - Wownerogue</title>
|
||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
||||
<title>Wownerogue — Tavern</title>
|
||||
<link rel="stylesheet" href="styles/commerce-consent.css">
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="js/legal/commerceConsent.js"></script>
|
||||
<script src="js/smirkChatSign.js"></script>
|
||||
<!-- PixiJS (+ @pixi/unsafe-eval for the CSP) is lazy-loaded by renderModes.js only when the
|
||||
Fancy mode is first selected, so ASCII/Tiled users never download the WebGL library. -->
|
||||
<!-- Optional external renderers are lazy-loaded only when the server's runtime policy opts in. -->
|
||||
<script src="js/render/sceneModel.js"></script>
|
||||
<script src="js/render/fxLayer.js"></script>
|
||||
<script src="js/render/skins.js"></script>
|
||||
@@ -26,35 +27,47 @@
|
||||
<script src="js/render/isoRenderer.js"></script>
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="js/render/tavernLife.js"></script>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/ui/matchUiCopy.js"></script>
|
||||
<style>
|
||||
:root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #d7dbe0; --accent: #3fb950; }
|
||||
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
|
||||
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator-Bold.ttf') format('truetype'); font-weight: 700; font-display: swap; }
|
||||
:root { --bg: #090b0f; --panel: #151a20; --panel2:#1b2129; --line: #303944; --text: #d7dbe0;
|
||||
--muted:#8b97a7; --accent: #51d569; --gold:#efb64b; --purple:#a98bff; --cyan:#62d8ff; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 16px; }
|
||||
h1 { font-size: 18px; margin: 4px 0 12px; }
|
||||
.muted { color: #8b949e; font-size: 13px; }
|
||||
.rk-canvas { background: #0a0c0f; border: 1px solid var(--line); border-radius: 6px;
|
||||
display: block; max-width: 100%; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 12px; margin-bottom: 12px; }
|
||||
body { min-height:100vh; margin:0; background:radial-gradient(circle at 50% -15%,rgba(116,70,32,.25),transparent 38%),
|
||||
linear-gradient(180deg,#111015,var(--bg) 48%); color:var(--text); font-family:PixelOperator,ui-monospace,monospace; }
|
||||
body::before { content:"";position:fixed;inset:0;pointer-events:none;opacity:.14;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:24px 24px; }
|
||||
.wrap { position:relative; max-width:980px; margin:0 auto; padding:18px clamp(10px,3vw,26px) 32px; }
|
||||
.tavern-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px; }
|
||||
h1 { color:#fff;font-size:clamp(24px,4vw,34px);margin:0;letter-spacing:.03em; }
|
||||
.backlink { color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:5px;padding:6px 10px;font-size:13px; }
|
||||
.backlink:hover { color:#fff;border-color:#667386; }
|
||||
.muted { color:var(--muted);font-size:13px; }
|
||||
.rk-canvas { background:#050609;display:block;max-width:100%;image-rendering:pixelated; }
|
||||
.panel { background:rgba(21,26,32,.97);border:1px solid var(--line);border-radius:9px;padding:14px;margin-bottom:12px;box-shadow:inset 0 1px rgba(255,255,255,.025); }
|
||||
label { font-size: 13px; margin-right: 6px; }
|
||||
input, select, button { font: inherit; background: #0a0c0f; color: var(--text);
|
||||
border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
|
||||
button { background: var(--accent); color: #08130a; border: 0; cursor: pointer; font-weight: 600; }
|
||||
input, select, button { font:inherit;background:#0d1116;color:var(--text);border:1px solid var(--line);border-radius:5px;padding:7px 9px; }
|
||||
input:focus,button:focus-visible { outline:2px solid rgba(98,216,255,.7);outline-offset:2px; }
|
||||
button { background:linear-gradient(#63df77,#43b859);color:#071209;border-color:#78eb8a;cursor:pointer;font-weight:700; }
|
||||
button:hover:not(:disabled) { filter:brightness(1.08); }
|
||||
button.secondary { background:#202731;color:var(--text);border-color:#414d5c; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
#status { min-height: 18px; font-size: 13px; }
|
||||
.hidden { display: none; }
|
||||
#modebar { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.hidden { display: none !important; }
|
||||
#modebar { display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap; }
|
||||
.modebtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-weight: 500; }
|
||||
.modebtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
|
||||
.modebtn.locked { opacity: 0.55; }
|
||||
#packbar { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; }
|
||||
#packbar { display:flex;gap:6px;margin-bottom:8px;align-items:center;flex-wrap:wrap; }
|
||||
#packbar .packlabel { color: var(--muted); font-size: 12px; margin-right: 2px; }
|
||||
.packbtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-size: 12px; padding: 3px 10px; border-radius: 5px; cursor: pointer; }
|
||||
.packbtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
|
||||
#chatPanel { margin-top: 12px; }
|
||||
#stage,.spectator-render { position:relative;width:100%;height:clamp(360px,62vh,620px);overflow:hidden;background:#000;
|
||||
border:1px solid var(--line);border-radius:9px;box-shadow:0 18px 55px rgba(0,0,0,.38),inset 0 0 60px rgba(0,0,0,.5);isolation:isolate; }
|
||||
#stage::after,.spectator-render::after { content:"";position:absolute;inset:0;pointer-events:none;z-index:10;box-shadow:inset 0 0 70px rgba(0,0,0,.58); }
|
||||
#chatPanel { margin-top:12px; }
|
||||
#chatLog { height: 160px; overflow-y: auto; border: 1px solid var(--line); border-radius: 4px;
|
||||
padding: 8px; background: #0a0c0f; font-size: 13px; line-height: 1.45; }
|
||||
#chatLog .cname { color: var(--accent); font-weight: 600; }
|
||||
@@ -62,11 +75,28 @@
|
||||
#chatLog .csys { color: #8b949e; font-style: italic; }
|
||||
#chatForm { display: flex; gap: 6px; margin-top: 8px; }
|
||||
#chatInput { flex: 1; }
|
||||
.actionbar { display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 12px; }
|
||||
.actionbar button { margin:0!important; }
|
||||
#watchGamesBtn { background:#102b35;color:var(--cyan);border-color:#367287; }
|
||||
#matchQueueBtn { background:#271d3b;color:#d4c5ff;border-color:#7156a6; }
|
||||
.panel h3,.panel h4 { color:#fff; }
|
||||
.gamerow { display:flex;justify-content:space-between;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line); }
|
||||
.gamerow:last-child { border-bottom:0; }
|
||||
.spectator-head { display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px; }
|
||||
.touch-pad { display:none;grid-template-areas:". up ." "left down right";gap:5px;justify-content:end;margin:8px 0; }
|
||||
.touch-pad button { width:44px;height:40px;padding:0;touch-action:manipulation;background:rgba(25,45,31,.94);color:#93f3a2;border-color:#4f9a5c; }
|
||||
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
|
||||
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} #joinPanel{display:flex;flex-wrap:wrap;gap:7px;align-items:center} #joinPanel label{width:100%} #name{min-width:0;flex:1} }
|
||||
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head .muted{display:none}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after{animation:none!important;transition:none!important} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Tavern <span class="muted">— early preview</span></h1>
|
||||
<header class="tavern-head">
|
||||
<h1>🔥 The Tavern <span class="muted">social hall</span></h1>
|
||||
<span><a class="backlink" href="/responsible-play">Responsible Play</a> <a class="backlink" href="/">← Lobby</a></span>
|
||||
</header>
|
||||
|
||||
<div id="joinPanel" class="panel">
|
||||
<label for="name">Name</label>
|
||||
@@ -78,9 +108,15 @@
|
||||
|
||||
<div id="modebar" class="hidden"></div>
|
||||
<div id="packbar" class="hidden"></div>
|
||||
<div id="stage" class="hidden"></div>
|
||||
<p id="status" class="muted"></p>
|
||||
<p class="muted">Move with arrow keys or WASD. ★ modes are premium (credit unlock).</p>
|
||||
<div id="stage" class="hidden" aria-label="Shared tavern room"></div>
|
||||
<div id="tavernDpad" class="touch-pad hidden" role="group" aria-label="Tavern movement controls">
|
||||
<button type="button" data-dir="up" aria-label="Move up">▲</button>
|
||||
<button type="button" data-dir="left" aria-label="Move left">◀</button>
|
||||
<button type="button" data-dir="down" aria-label="Move down">▼</button>
|
||||
<button type="button" data-dir="right" aria-label="Move right">▶</button>
|
||||
</div>
|
||||
<p id="status" class="muted" role="status" aria-live="polite"></p>
|
||||
<p id="movementHint" class="muted">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
|
||||
|
||||
<div id="chatPanel" class="panel hidden">
|
||||
<div id="chatLog" aria-live="polite"></div>
|
||||
@@ -91,34 +127,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Spectator camera: watch live dungeon runs from inside the tavern -->
|
||||
<button id="watchGamesBtn" class="hidden" style="margin-top:8px;">👁️ Watch Live Games</button>
|
||||
<div class="actionbar">
|
||||
<button id="watchGamesBtn" class="hidden">👁 Watch Solo Runs</button>
|
||||
<button id="matchQueueBtn" class="hidden">🎮 Multiplayer Arena</button>
|
||||
</div>
|
||||
<div id="spectatorPanel" class="panel hidden">
|
||||
<h3 style="margin:0 0 8px;font-size:14px;">Live Games</h3>
|
||||
<div id="gameList" style="max-height:200px;overflow-y:auto;font-size:13px;"></div>
|
||||
<button id="closeGameListBtn" style="margin-top:8px;">Close</button>
|
||||
<button id="closeGameListBtn" class="secondary" style="margin-top:8px;">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Match mode panel -->
|
||||
<button id="matchQueueBtn" class="hidden" style="margin-top:8px;">🏁 Queue for Race</button>
|
||||
<div id="matchPanel" class="panel hidden">
|
||||
<h3 style="margin:0 0 8px;font-size:14px;">Race Queue</h3>
|
||||
<h3 id="tavernMatchTitle" style="margin:0 0 4px;font-size:16px;">Multiplayer Arena</h3>
|
||||
<div id="tavernMatchDescription" class="muted" style="margin-bottom:10px;">Choose a leaderboard for the server's active mode.</div>
|
||||
<div id="matchEconomies" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;"></div>
|
||||
<div id="matchQueueStatus" class="muted" style="margin-bottom:8px;">Choose an economy.</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="joinMatchQueueBtn">Join Queue</button>
|
||||
<button id="leaveMatchQueueBtn" class="hidden">Leave Queue</button>
|
||||
<button id="leaveMatchQueueBtn" class="hidden secondary">Leave Queue</button>
|
||||
</div>
|
||||
<hr style="border-color:var(--line);margin:12px 0;">
|
||||
<h4 style="margin:0 0 8px;font-size:13px;">Active Races</h4>
|
||||
<h4 style="margin:0 0 8px;font-size:13px;">Active Matches</h4>
|
||||
<div id="activeMatchList" style="max-height:160px;overflow-y:auto;font-size:13px;"></div>
|
||||
<button id="closeMatchPanelBtn" style="margin-top:8px;">Close</button>
|
||||
<button id="closeMatchPanelBtn" class="secondary" style="margin-top:8px;">Close</button>
|
||||
</div>
|
||||
<div id="spectatorStage" class="hidden">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<div class="spectator-head">
|
||||
<span id="spectatorLabel" class="muted"></span>
|
||||
<button id="leaveSpectateBtn">← Back to tavern</button>
|
||||
<button id="leaveSpectateBtn" class="secondary">← Back to tavern</button>
|
||||
</div>
|
||||
<div id="spectatorRender" class="spectator-render"></div>
|
||||
<div id="spectatorDpad" class="touch-pad hidden" role="group" aria-label="Match movement controls">
|
||||
<button type="button" data-dir="up" aria-label="Move up">▲</button>
|
||||
<button type="button" data-dir="left" aria-label="Move left">◀</button>
|
||||
<button type="button" data-dir="down" aria-label="Move down">▼</button>
|
||||
<button type="button" data-dir="right" aria-label="Move right">▶</button>
|
||||
</div>
|
||||
<div id="spectatorRender" style="position:relative;min-height:300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,6 +176,7 @@
|
||||
} catch (_) {}
|
||||
var socket = io(socketOpts);
|
||||
var RK = window.RK;
|
||||
var MatchCopy = window.MatchUiCopy;
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var joinPanel = document.getElementById('joinPanel');
|
||||
@@ -141,6 +187,18 @@
|
||||
var chatLog = document.getElementById('chatLog');
|
||||
var chatForm = document.getElementById('chatForm');
|
||||
var chatInput = document.getElementById('chatInput');
|
||||
var chatPanel = document.getElementById('chatPanel');
|
||||
var movementHint = document.getElementById('movementHint');
|
||||
var tavernDpad = document.getElementById('tavernDpad');
|
||||
var watchGamesBtn = document.getElementById('watchGamesBtn');
|
||||
var spectatorPanel = document.getElementById('spectatorPanel');
|
||||
var gameList = document.getElementById('gameList');
|
||||
var closeGameListBtn = document.getElementById('closeGameListBtn');
|
||||
var spectatorStage = document.getElementById('spectatorStage');
|
||||
var spectatorLabel = document.getElementById('spectatorLabel');
|
||||
var spectatorRender = document.getElementById('spectatorRender');
|
||||
var spectatorDpad = document.getElementById('spectatorDpad');
|
||||
var leaveSpectateBtn = document.getElementById('leaveSpectateBtn');
|
||||
|
||||
// Character customization — persisted appearance identity, chosen before entering.
|
||||
var customizeBtn = document.getElementById('customizeBtn');
|
||||
@@ -165,6 +223,19 @@
|
||||
var currentScene = null;
|
||||
var tavernLife = null;
|
||||
var rafActive = false;
|
||||
var mainCamera = null;
|
||||
var rendererRequest = 0;
|
||||
var cryptoType = 'WOW';
|
||||
var lastGameModeInfo = null;
|
||||
var spectatorKind = null; // solo | match | player-match
|
||||
var spectatorRenderer = null;
|
||||
var spectatorCamera = null;
|
||||
var spectatorPendingState = null;
|
||||
var spectatorRequest = 0;
|
||||
var currentSpectatingMatchId = null;
|
||||
var activeMatchSnapshots = {};
|
||||
var activeMatchTickMs = 250;
|
||||
var currentMatchRuleset = MatchCopy.normalize({ id: 'race' });
|
||||
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
function applyEntitlements(data) {
|
||||
@@ -185,7 +256,10 @@
|
||||
if (!currentScene) return;
|
||||
// Inject client-side NPCs (bartender, patrons, cat) into the scene.
|
||||
if (tavernLife) tavernLife.injectInto(currentScene);
|
||||
if (currentRenderer) currentRenderer.render(currentScene);
|
||||
if (currentRenderer) {
|
||||
currentRenderer.render(currentScene);
|
||||
if (mainCamera) mainCamera.update(currentRenderer);
|
||||
}
|
||||
}
|
||||
|
||||
// rAF loop for smooth NPC animation + speech bubbles.
|
||||
@@ -199,13 +273,16 @@
|
||||
if (!currentScene._npcInjected) tavernLife.injectInto(currentScene);
|
||||
tavernLife.update(currentScene, currentRenderer && currentRenderer.cell || 24, currentMode || 'tiles');
|
||||
}
|
||||
root.requestAnimationFrame(loop);
|
||||
window.requestAnimationFrame(loop);
|
||||
}
|
||||
root.requestAnimationFrame(loop);
|
||||
window.requestAnimationFrame(loop);
|
||||
}
|
||||
function lockedModeMessage(mode) {
|
||||
var meta = RK.modeMeta ? RK.modeMeta(mode) : null;
|
||||
var label = (meta && meta.label) || mode;
|
||||
if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode)) {
|
||||
return label + ' is unavailable on this server because its optional renderer dependency is disabled.';
|
||||
}
|
||||
if (meta && meta.pack && RK.pack) {
|
||||
var pack = RK.pack(meta.pack);
|
||||
return label + ' is locked. Buy any credits or unlock ' + ((pack && pack.label) || 'the pack') + '.';
|
||||
@@ -214,6 +291,7 @@
|
||||
}
|
||||
function setMode(mode) {
|
||||
if (!RK.canUseMode(mode)) { setStatus(lockedModeMessage(mode)); return; }
|
||||
var request = ++rendererRequest;
|
||||
if (currentRenderer) { currentRenderer.destroy(); currentRenderer = null; }
|
||||
currentMode = mode;
|
||||
if (tavernLife) tavernLife.setMode(mode);
|
||||
@@ -222,12 +300,21 @@
|
||||
if (mode === 'fancy' && !RK.pixiReady()) setStatus('Loading Fancy renderer…');
|
||||
// Async: Fancy lazy-loads PixiJS on first use.
|
||||
RK.createRendererAsync(mode, stageEl, {}, function (r) {
|
||||
if (currentMode !== mode) { r.destroy(); return; } // switched again while loading
|
||||
if (currentMode !== mode || request !== rendererRequest) { r.destroy(); return; } // switched while loading
|
||||
currentRenderer = r;
|
||||
draw();
|
||||
if (RK.attachZoom) { RK.attachZoom(stageEl); RK.applyZoom(stageEl); } // wheel/pinch zoom, all modes
|
||||
var activeMode = r.name || mode;
|
||||
var defaultZoom = activeMode === 'iso' ? 0.8 : (activeMode === '3d' ? 1.05 : 1.25);
|
||||
if (!mainCamera && RK.attachCamera) {
|
||||
mainCamera = RK.attachCamera(stageEl, { zoom: defaultZoom, fitRatio: 0.75, min: 0.5, max: 3.2 });
|
||||
}
|
||||
if (mainCamera) {
|
||||
if (mainCamera.setDefaultZoom) mainCamera.setDefaultZoom(defaultZoom);
|
||||
if (activeMode === '3d' || !mainCamera.fit) mainCamera.setZoom(defaultZoom);
|
||||
else mainCamera.fit(r, 0.75);
|
||||
}
|
||||
buildPackbar(); // show pack choices for this mode's projection
|
||||
var active = r.name || mode;
|
||||
var active = activeMode;
|
||||
setStatus(active === mode
|
||||
? 'Render mode: ' + active
|
||||
: '“' + mode + '” unavailable here — using “' + active + '”.');
|
||||
@@ -236,10 +323,11 @@
|
||||
function buildModebar() {
|
||||
modebar.innerHTML = '';
|
||||
RK.RENDER_MODES.forEach(function (m) {
|
||||
var runtimeAvailable = !RK.isModeRuntimeAvailable || RK.isModeRuntimeAvailable(m.id);
|
||||
var b = document.createElement('button');
|
||||
b.className = 'modebtn';
|
||||
b.dataset.mode = m.id;
|
||||
b.textContent = m.label + (m.premium ? ' ★' : '');
|
||||
b.textContent = m.label + (!runtimeAvailable ? ' ⛔' : (m.premium ? ' ★' : ''));
|
||||
if (!RK.canUseMode(m.id)) { b.classList.add('locked'); b.title = lockedModeMessage(m.id); }
|
||||
b.addEventListener('click', function () { setMode(m.id); });
|
||||
modebar.appendChild(b);
|
||||
@@ -299,8 +387,9 @@
|
||||
setStatus((e && e.message) || 'Could not enter the tavern.');
|
||||
});
|
||||
socket.on('game_mode_info', function (data) {
|
||||
lastGameModeInfo = data || null;
|
||||
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
||||
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
|
||||
if (data && data.cryptoType) cryptoType = data.cryptoType;
|
||||
});
|
||||
socket.on('credits_update', applyEntitlements);
|
||||
socket.on('session_token', function (data) {
|
||||
@@ -329,7 +418,8 @@
|
||||
joinPanel.classList.add('hidden');
|
||||
modebar.classList.remove('hidden');
|
||||
stageEl.classList.remove('hidden');
|
||||
document.getElementById('chatPanel').classList.remove('hidden');
|
||||
chatPanel.classList.remove('hidden');
|
||||
tavernDpad.classList.remove('hidden');
|
||||
rebuildScene();
|
||||
buildModebar();
|
||||
setMode(RK.loadMode('tiles'));
|
||||
@@ -435,20 +525,171 @@
|
||||
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||
w: 'up', s: 'down', a: 'left', d: 'right', W: 'up', S: 'down', A: 'left', D: 'right'
|
||||
};
|
||||
function sendDirection(dir) {
|
||||
if (spectatorKind === 'player-match') {
|
||||
var delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir];
|
||||
if (delta) socket.emit('match_move', { dx: delta[0], dy: delta[1] });
|
||||
return;
|
||||
}
|
||||
if (!spectatorKind && state.you) socket.emit('tavern_move', { dir: dir });
|
||||
}
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
|
||||
var dir = KEYS[e.key];
|
||||
if (!dir || !state.you) return;
|
||||
if (!dir || (!state.you && spectatorKind !== 'player-match')) return;
|
||||
e.preventDefault();
|
||||
socket.emit('tavern_move', { dir: dir });
|
||||
sendDirection(dir);
|
||||
});
|
||||
function bindDpad(el) {
|
||||
if (!el) return;
|
||||
Array.prototype.forEach.call(el.querySelectorAll('[data-dir]'), function (b) {
|
||||
// Native click includes touch/mouse plus Enter and Space for keyboard and AT users.
|
||||
b.addEventListener('click', function () { sendDirection(b.dataset.dir); });
|
||||
});
|
||||
}
|
||||
bindDpad(tavernDpad);
|
||||
bindDpad(spectatorDpad);
|
||||
|
||||
setStatus('Connecting…');
|
||||
socket.on('connect', function () {
|
||||
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
|
||||
socket.emit('identity:get');
|
||||
socket.emit('match_reconnect', {});
|
||||
setStatus('Connected. Choose a name and enter.');
|
||||
});
|
||||
|
||||
// ---- Shared spectator viewport (solo runs + public PvP races) -----------------
|
||||
function spectatorZoom(mode) { return mode === 'iso' ? 0.78 : (mode === '3d' ? 1.05 : 1.3); }
|
||||
function destroySpectatorRenderer() {
|
||||
spectatorRequest++;
|
||||
if (spectatorRenderer && spectatorRenderer.destroy) {
|
||||
try { spectatorRenderer.destroy(); } catch (_) {}
|
||||
}
|
||||
spectatorRenderer = null;
|
||||
}
|
||||
function renderSpectatorState(gameState) {
|
||||
if (!gameState) return;
|
||||
spectatorPendingState = gameState;
|
||||
if (!spectatorRenderer) return;
|
||||
var safeState = Object.assign({}, gameState, { isSpectating: spectatorKind !== 'player-match' });
|
||||
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(safeState, {
|
||||
viewerId: spectatorKind === 'player-match' ? socket.id : null,
|
||||
cryptoType: cryptoType
|
||||
}) : null;
|
||||
if (!scene) return;
|
||||
spectatorRenderer.render(scene);
|
||||
if (spectatorCamera) spectatorCamera.update(spectatorRenderer);
|
||||
}
|
||||
function openSpectatorView(kind, label, initialState) {
|
||||
spectatorKind = kind;
|
||||
spectatorPendingState = initialState || null;
|
||||
joinPanel.classList.add('hidden');
|
||||
stageEl.classList.add('hidden');
|
||||
modebar.classList.add('hidden');
|
||||
packbar.classList.add('hidden');
|
||||
tavernDpad.classList.add('hidden');
|
||||
movementHint.classList.add('hidden');
|
||||
spectatorPanel.classList.add('hidden');
|
||||
matchPanel.classList.add('hidden');
|
||||
spectatorStage.classList.remove('hidden');
|
||||
spectatorDpad.classList.toggle('hidden', kind !== 'player-match');
|
||||
spectatorLabel.textContent = label || 'Live dungeon';
|
||||
leaveSpectateBtn.textContent = kind === 'player-match' ? 'Forfeit match' : '← Back to tavern';
|
||||
destroySpectatorRenderer();
|
||||
var request = ++spectatorRequest;
|
||||
var renderMode = currentMode || (RK.loadMode && RK.loadMode('tiles')) || 'tiles';
|
||||
RK.createRendererAsync(renderMode, spectatorRender, {}, function (r) {
|
||||
if (request !== spectatorRequest || !spectatorKind) { r.destroy(); return; }
|
||||
spectatorRenderer = r;
|
||||
if (!spectatorCamera && RK.attachCamera) {
|
||||
spectatorCamera = RK.attachCamera(spectatorRender, { zoom: spectatorZoom(r.name || renderMode), min: 0.5, max: 3.2 });
|
||||
}
|
||||
if (spectatorCamera) spectatorCamera.setZoom(spectatorZoom(r.name || renderMode));
|
||||
renderSpectatorState(spectatorPendingState);
|
||||
});
|
||||
}
|
||||
function closeSpectatorView(notifyServer) {
|
||||
var previous = spectatorKind;
|
||||
if (notifyServer && previous === 'solo') socket.emit('leave_spectate');
|
||||
spectatorKind = null;
|
||||
currentSpectatingMatchId = null;
|
||||
spectatorPendingState = null;
|
||||
destroySpectatorRenderer();
|
||||
spectatorStage.classList.add('hidden');
|
||||
spectatorDpad.classList.add('hidden');
|
||||
if (state.you) {
|
||||
stageEl.classList.remove('hidden');
|
||||
modebar.classList.remove('hidden');
|
||||
tavernDpad.classList.remove('hidden');
|
||||
movementHint.classList.remove('hidden');
|
||||
buildPackbar();
|
||||
draw();
|
||||
} else {
|
||||
joinPanel.classList.remove('hidden');
|
||||
}
|
||||
leaveSpectateBtn.textContent = '← Back to tavern';
|
||||
}
|
||||
|
||||
function populateSoloGames(data) {
|
||||
gameList.innerHTML = '';
|
||||
var games = data && Array.isArray(data.games) ? data.games : [];
|
||||
if (!games.length) {
|
||||
gameList.textContent = 'No live solo runs right now.';
|
||||
return;
|
||||
}
|
||||
games.forEach(function (game) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'gamerow';
|
||||
var text = document.createElement('span');
|
||||
var seconds = Number(game.durationSeconds) || 0;
|
||||
text.textContent = 'Delver ' + (game.playerId || '??????') + ' · ' + (game.difficulty || 'normal') + ' · ' + seconds + 's';
|
||||
var watch = document.createElement('button');
|
||||
watch.type = 'button'; watch.textContent = 'Watch'; watch.className = 'secondary';
|
||||
watch.addEventListener('click', function () {
|
||||
setStatus('Joining live solo run…');
|
||||
socket.emit('spectate_game', { gameId: game.gameId });
|
||||
});
|
||||
row.appendChild(text); row.appendChild(watch); gameList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
watchGamesBtn.addEventListener('click', function () {
|
||||
spectatorPanel.classList.toggle('hidden');
|
||||
if (!spectatorPanel.classList.contains('hidden')) {
|
||||
gameList.textContent = 'Loading live runs…';
|
||||
socket.emit('get_active_games', { page: 1, pageSize: 20 });
|
||||
}
|
||||
});
|
||||
closeGameListBtn.addEventListener('click', function () { spectatorPanel.classList.add('hidden'); });
|
||||
socket.on('active_games', populateSoloGames);
|
||||
socket.on('spectate_start', function (data) {
|
||||
if (!data || !data.initialState) return;
|
||||
openSpectatorView('solo', '👁 Watching delver ' + (data.playerId || 'live'), data.initialState);
|
||||
setStatus('Watching a live solo run.');
|
||||
});
|
||||
socket.on('spectator_update', function (data) {
|
||||
if (spectatorKind === 'solo' && data && data.gameState) renderSpectatorState(data.gameState);
|
||||
});
|
||||
socket.on('spectate_ended', function (data) {
|
||||
if (spectatorKind !== 'solo') return;
|
||||
closeSpectatorView(false);
|
||||
setStatus(data && data.reason === 'game_over' ? 'That run has ended.' : 'Back in the tavern.');
|
||||
});
|
||||
socket.on('spectate_error', function (data) {
|
||||
setStatus((data && data.message) || 'Could not watch that run.');
|
||||
});
|
||||
leaveSpectateBtn.addEventListener('click', function () {
|
||||
if (spectatorKind === 'player-match') {
|
||||
if (!window.confirm('Forfeit this match and return to the tavern?')) return;
|
||||
socket.emit('match_leave');
|
||||
closeSpectatorView(false);
|
||||
setStatus('You forfeited the match.');
|
||||
return;
|
||||
}
|
||||
closeSpectatorView(spectatorKind === 'solo');
|
||||
setStatus('Back in the tavern.');
|
||||
});
|
||||
|
||||
// ---- Tavern Match spectator / queue UI -------------------------------
|
||||
var matchQueueBtn = document.getElementById('matchQueueBtn');
|
||||
var matchPanel = document.getElementById('matchPanel');
|
||||
@@ -458,13 +699,24 @@
|
||||
var leaveMatchQueueBtn = document.getElementById('leaveMatchQueueBtn');
|
||||
var activeMatchList = document.getElementById('activeMatchList');
|
||||
var closeMatchPanelBtn = document.getElementById('closeMatchPanelBtn');
|
||||
var tavernMatchTitle = document.getElementById('tavernMatchTitle');
|
||||
var tavernMatchDescription = document.getElementById('tavernMatchDescription');
|
||||
var selectedMatchEconomy = 'free';
|
||||
var inMatchQueue = false;
|
||||
var matchEndTimer = null;
|
||||
|
||||
function activeRulesetFrom(info) {
|
||||
var match = info && info.modes && info.modes.match || {};
|
||||
var raw = match.activeRuleset || { id: match.rulesetId || 'race' };
|
||||
return MatchCopy.normalize(raw);
|
||||
}
|
||||
|
||||
function buildMatchEconomies(info) {
|
||||
if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return;
|
||||
var economies = info.modes.match.economies || {};
|
||||
var labels = { free: 'Free', credits_prestige: 'Prestige', crypto_race: 'Crypto' };
|
||||
var labels = { free: 'Pleb · Free', credits_prestige: 'Prestige · Credits', crypto_race: 'Stagenet · Stakes' };
|
||||
var available = Object.keys(labels).filter(function (key) { return !!economies[key]; });
|
||||
if (!economies[selectedMatchEconomy] && available.length) selectedMatchEconomy = available[0];
|
||||
matchEconomies.innerHTML = '';
|
||||
Object.keys(labels).forEach(function (key) {
|
||||
if (!economies[key]) return;
|
||||
@@ -478,20 +730,29 @@
|
||||
});
|
||||
matchEconomies.appendChild(b);
|
||||
});
|
||||
joinMatchQueueBtn.disabled = available.length === 0;
|
||||
}
|
||||
|
||||
socket.on('game_mode_info', function (data) {
|
||||
// Extend existing handler: also build match economies.
|
||||
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
||||
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
|
||||
lastGameModeInfo = data || lastGameModeInfo;
|
||||
if (data && data.cryptoType) cryptoType = data.cryptoType;
|
||||
if (data && data.modes && data.modes.match && data.modes.match.enabled) {
|
||||
var ruleset = activeRulesetFrom(data);
|
||||
tavernMatchTitle.textContent = '🎮 ' + ruleset.label;
|
||||
tavernMatchDescription.textContent = ruleset.description + (ruleset.cooperative
|
||||
? ' This is a team result, not an individual leaderboard win.'
|
||||
: ' Choose a separate leaderboard below.');
|
||||
matchQueueBtn.textContent = '🎮 ' + ruleset.label;
|
||||
matchQueueBtn.classList.remove('hidden');
|
||||
buildMatchEconomies(data);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('match_queue_joined', function (res) {
|
||||
res = res || {};
|
||||
joinMatchQueueBtn.disabled = false;
|
||||
if (res.success) {
|
||||
selectedMatchEconomy = res.economy || selectedMatchEconomy;
|
||||
inMatchQueue = true;
|
||||
joinMatchQueueBtn.classList.add('hidden');
|
||||
leaveMatchQueueBtn.classList.remove('hidden');
|
||||
@@ -502,6 +763,7 @@
|
||||
});
|
||||
|
||||
socket.on('match_queue_left', function (res) {
|
||||
res = res || {};
|
||||
inMatchQueue = false;
|
||||
joinMatchQueueBtn.classList.remove('hidden');
|
||||
leaveMatchQueueBtn.classList.add('hidden');
|
||||
@@ -510,39 +772,134 @@
|
||||
|
||||
socket.on('tavern_match_list', function (list) {
|
||||
activeMatchList.innerHTML = '';
|
||||
activeMatchSnapshots = {};
|
||||
if (!list || list.length === 0) {
|
||||
activeMatchList.textContent = 'No active races right now.';
|
||||
activeMatchList.textContent = 'No active multiplayer matches right now.';
|
||||
return;
|
||||
}
|
||||
list.forEach(function (m) {
|
||||
if (!m || !m.matchId) return;
|
||||
activeMatchSnapshots[m.matchId] = m;
|
||||
var row = document.createElement('div');
|
||||
row.className = 'gamerow';
|
||||
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--line);';
|
||||
row.innerHTML = '<span>' + (m.economy || 'race') + ' — ' + (m.players ? m.players.length : 0) + ' players</span> <button class="watchMatchBtn" data-matchid="' + m.matchId + '">Watch</button>';
|
||||
var summary = document.createElement('span');
|
||||
var mode = MatchCopy.fromPayload(m, { id: m.rulesetId || m.variant || 'race' });
|
||||
var count = m.players ? m.players.length : 0;
|
||||
summary.textContent = mode.label + ' · ' + (m.economy || 'free') + ' · ' + count + ' ' + MatchCopy.plural(mode, count);
|
||||
var watch = document.createElement('button');
|
||||
watch.type = 'button'; watch.className = 'watchMatchBtn secondary'; watch.dataset.matchid = m.matchId; watch.textContent = 'Watch';
|
||||
row.appendChild(summary); row.appendChild(watch);
|
||||
activeMatchList.appendChild(row);
|
||||
});
|
||||
Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var matchId = b.dataset.matchid;
|
||||
// Switch spectator renderer to the public match state stream.
|
||||
enterSpectatorMode(matchId);
|
||||
var snap = activeMatchSnapshots[matchId];
|
||||
currentSpectatingMatchId = matchId;
|
||||
currentMatchRuleset = MatchCopy.fromPayload(snap, { id: snap && (snap.rulesetId || snap.variant) || 'race' });
|
||||
var count = (snap && snap.players && snap.players.length) || 0;
|
||||
openSpectatorView('match', '👁 ' + currentMatchRuleset.label + ' · ' + count + ' ' + MatchCopy.plural(currentMatchRuleset, count), snap);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('tavern_match_tick', function (state) {
|
||||
if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) {
|
||||
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, null) : null;
|
||||
if (scene && spectatorRenderer) spectatorRenderer.render(scene);
|
||||
renderSpectatorState(state);
|
||||
currentMatchRuleset = MatchCopy.fromPayload(state, currentMatchRuleset);
|
||||
spectatorLabel.textContent = '👁 ' + currentMatchRuleset.label + ' · '
|
||||
+ MatchCopy.liveSummary(state, null, currentMatchRuleset) + ' · tick ' + (state.tick || 0);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('tavern_match_end', function (data) {
|
||||
if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) {
|
||||
spectatorLabel.textContent = 'Race ended. Winner: ' + (data.winnerId ? data.winnerId.slice(0, 6) : 'none');
|
||||
var priorPlayers = spectatorPendingState && spectatorPendingState.players || [];
|
||||
var namedPlayers = (data.players || []).map(function (player) {
|
||||
var prior = priorPlayers.filter(function (candidate) { return candidate.id === player.id; })[0];
|
||||
return Object.assign({}, prior || {}, player);
|
||||
});
|
||||
var result = MatchCopy.finalResult(Object.assign({}, data, { players: namedPlayers }), null, currentMatchRuleset);
|
||||
spectatorLabel.textContent = result.text;
|
||||
}
|
||||
});
|
||||
|
||||
// A player can queue without leaving the tavern. When that queue becomes a match, reuse the
|
||||
// same viewport as a first-class playable arena (local camera + movement), not a blind queue.
|
||||
socket.on('match_joined', function (data) {
|
||||
if (!data) return;
|
||||
selectedMatchEconomy = data.economy || selectedMatchEconomy;
|
||||
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
|
||||
currentMatchRuleset = ruleset;
|
||||
var count = data.players ? data.players.length : 0;
|
||||
matchQueueStatus.textContent = 'Match found: ' + ruleset.label + ' · ' + count + ' ' + MatchCopy.plural(ruleset, count) + ' · starting in ' + ((Number(data.countdownMs) || 0) / 1000).toFixed(1) + 's.';
|
||||
setStatus(ruleset.objective + ' Get ready…');
|
||||
});
|
||||
socket.on('match_start', function (data) {
|
||||
data = data || {};
|
||||
if (matchEndTimer) clearTimeout(matchEndTimer);
|
||||
activeMatchTickMs = Math.max(50, Number(data.tickMs) || 250);
|
||||
currentSpectatingMatchId = data.matchId || null;
|
||||
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
|
||||
currentMatchRuleset = ruleset;
|
||||
openSpectatorView('player-match', '⚔️ ' + ruleset.label + ' · GO', null);
|
||||
setStatus('Match live. ' + ruleset.objective);
|
||||
});
|
||||
socket.on('match_rejoined', function (data) {
|
||||
if (!data || !data.state) return;
|
||||
currentSpectatingMatchId = data.matchId || data.state.matchId || null;
|
||||
selectedMatchEconomy = data.state.economy || selectedMatchEconomy;
|
||||
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.state.ruleset || data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
|
||||
currentMatchRuleset = ruleset;
|
||||
openSpectatorView('player-match', '⚔️ Rejoined ' + ruleset.label, data.state);
|
||||
setStatus('Rejoined your active multiplayer match.');
|
||||
});
|
||||
socket.on('match_tick', function (data) {
|
||||
if (!data || !data.state) return;
|
||||
if (spectatorKind !== 'player-match') {
|
||||
currentSpectatingMatchId = data.matchId || data.state.matchId || null;
|
||||
openSpectatorView('player-match', '⚔️ Multiplayer match', data.state);
|
||||
} else {
|
||||
renderSpectatorState(data.state);
|
||||
}
|
||||
currentMatchRuleset = MatchCopy.fromPayload(data, currentMatchRuleset);
|
||||
var elapsed = ((Number(data.tick) || Number(data.state.tick) || 0) * activeMatchTickMs / 1000).toFixed(1);
|
||||
spectatorLabel.textContent = '⚔️ ' + currentMatchRuleset.label + ' · ' + elapsed + 's · '
|
||||
+ MatchCopy.liveSummary(data.state, socket.id, currentMatchRuleset);
|
||||
});
|
||||
socket.on('match_end', function (data) {
|
||||
if (spectatorKind !== 'player-match') return;
|
||||
data = data || {};
|
||||
var result = MatchCopy.finalResult(data, socket.id, currentMatchRuleset);
|
||||
currentMatchRuleset = result.mode;
|
||||
spectatorKind = 'match-result';
|
||||
spectatorDpad.classList.add('hidden');
|
||||
leaveSpectateBtn.textContent = '← Back to tavern';
|
||||
spectatorLabel.textContent = result.text;
|
||||
setStatus(result.mode.cooperative
|
||||
? 'Team result recorded; co-op does not create an individual leaderboard winner.'
|
||||
: 'Competitive result recorded on its separate leaderboard.');
|
||||
matchEndTimer = setTimeout(function () {
|
||||
if (spectatorKind === 'match-result') closeSpectatorView(false);
|
||||
}, 5000);
|
||||
});
|
||||
socket.on('match_error', function (data) {
|
||||
var message = data && (data.message || data.reason) || 'Could not update the multiplayer match.';
|
||||
matchQueueStatus.textContent = message;
|
||||
setStatus(message);
|
||||
});
|
||||
socket.on('commerce_ack_required', function (data) {
|
||||
if (!window.CommerceConsent || !window.CommerceConsent.require) return;
|
||||
var refreshed = window.CommerceConsent.reject
|
||||
? window.CommerceConsent.reject(data)
|
||||
: Promise.resolve();
|
||||
refreshed.catch(function () { return null; }).then(function () {
|
||||
window.CommerceConsent.require(function () {
|
||||
matchQueueStatus.textContent = 'Disclosures acknowledged. Retry the paid queue when ready.';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
matchQueueBtn.addEventListener('click', function () {
|
||||
matchPanel.classList.toggle('hidden');
|
||||
if (!matchPanel.classList.contains('hidden')) socket.emit('tavern_match_list');
|
||||
@@ -551,7 +908,19 @@
|
||||
|
||||
joinMatchQueueBtn.addEventListener('click', function () {
|
||||
if (inMatchQueue) return;
|
||||
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'join' });
|
||||
function join(acknowledgement) {
|
||||
joinMatchQueueBtn.disabled = true;
|
||||
matchQueueStatus.textContent = 'Joining queue…';
|
||||
var payload = { economy: selectedMatchEconomy, action: 'join' };
|
||||
if (acknowledgement) payload.legalAcknowledgement = acknowledgement;
|
||||
socket.emit('match_queue', payload);
|
||||
setTimeout(function () { if (!inMatchQueue) joinMatchQueueBtn.disabled = false; }, 1500);
|
||||
}
|
||||
if (selectedMatchEconomy !== 'free' && window.CommerceConsent) {
|
||||
window.CommerceConsent.require(join);
|
||||
} else {
|
||||
join(null);
|
||||
}
|
||||
});
|
||||
leaveMatchQueueBtn.addEventListener('click', function () {
|
||||
if (!inMatchQueue) return;
|
||||
@@ -560,7 +929,10 @@
|
||||
|
||||
// ---- End match UI ----------------------------------------------------
|
||||
|
||||
socket.on('disconnect', function () { setStatus('Disconnected.'); });
|
||||
socket.on('disconnect', function () {
|
||||
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
|
||||
setStatus('Disconnected.');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user