mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
1051 lines
57 KiB
HTML
1051 lines
57 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<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>
|
|
<!-- 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>
|
|
<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/catSprites.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/designedRoom.js"></script>
|
|
<script src="js/render/asciiRenderer.js"></script>
|
|
<script src="js/render/tileRenderer.js"></script>
|
|
<script src="js/render/fancyRenderer.js"></script>
|
|
<script src="js/render/isoRenderer.js"></script>
|
|
<script src="js/render/threeRenderer.js"></script>
|
|
<script src="js/render/tavernLife.js"></script>
|
|
<script src="js/render/chainAmbience.js"></script>
|
|
<script src="/runtime-config.js"></script>
|
|
<script type="importmap">
|
|
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
|
|
</script>
|
|
<script src="js/render/renderModes.js"></script>
|
|
<script src="js/ui/matchUiCopy.js"></script>
|
|
<style>
|
|
@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 { 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); }
|
|
#joinPanel { padding:18px; }
|
|
.join-copy { display:flex;align-items:baseline;gap:10px;margin-bottom:13px; }
|
|
.join-copy strong { color:#fff;font-size:18px;letter-spacing:.02em; }
|
|
.join-fields { display:grid;grid-template-columns:auto minmax(150px,1fr) auto auto;gap:8px;align-items:center; }
|
|
#joinStatus { display:flex;align-items:center;gap:7px;margin:12px 0 0;min-height:18px;color:var(--muted);font-size:13px; }
|
|
#joinStatus::before { content:"";width:8px;height:8px;flex:0 0 auto;border-radius:50%;background:var(--gold);box-shadow:0 0 10px rgba(239,182,75,.5); }
|
|
#joinStatus.connected::before { background:var(--accent);box-shadow:0 0 10px rgba(81,213,105,.55); }
|
|
#joinStatus.error::before { background:#ff6b6b;box-shadow:0 0 10px rgba(255,107,107,.5); }
|
|
label { font-size: 13px; margin-right: 6px; }
|
|
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 !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;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); }
|
|
#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; }
|
|
#chatLog .cline { margin: 2px 0; word-break: break-word; }
|
|
#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)} .join-copy{display:block}.join-copy .muted{display:block;margin-top:5px}.join-fields{grid-template-columns:auto minmax(0,1fr)}.join-fields #customizeBtn,.join-fields #joinBtn{grid-column:auto / span 1}.join-fields label{grid-column:1}.join-fields #name{grid-column:2} }
|
|
@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">
|
|
<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>
|
|
|
|
<form id="joinPanel" class="panel" autocomplete="off">
|
|
<div class="join-copy">
|
|
<strong>Walk in anonymously</strong>
|
|
<span class="muted">No account required. Pick an optional display name and character.</span>
|
|
</div>
|
|
<div class="join-fields">
|
|
<label for="name">Name</label>
|
|
<input id="name" maxlength="16" placeholder="anon" autocomplete="nickname">
|
|
<button id="customizeBtn" type="button">🎭 Customize</button>
|
|
<button id="joinBtn" type="submit" disabled>Connecting…</button>
|
|
</div>
|
|
<span id="appearancePreview" class="muted"></span>
|
|
<p id="joinStatus" role="status" aria-live="polite">Connecting to the tavern…</p>
|
|
</form>
|
|
|
|
<div id="modebar" class="hidden"></div>
|
|
<div id="packbar" class="hidden"></div>
|
|
<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 hidden">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>
|
|
<form id="chatForm" autocomplete="off">
|
|
<input id="chatInput" maxlength="200" placeholder="Say something…">
|
|
<button type="submit">Send</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Spectator camera: watch live dungeon runs from inside the tavern -->
|
|
<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" class="secondary" style="margin-top:8px;">Close</button>
|
|
</div>
|
|
|
|
<!-- Match mode panel -->
|
|
<div id="matchPanel" class="panel hidden">
|
|
<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 secondary">Leave Queue</button>
|
|
</div>
|
|
<hr style="border-color:var(--line);margin:12px 0;">
|
|
<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" class="secondary" style="margin-top:8px;">Close</button>
|
|
</div>
|
|
<div id="spectatorStage" class="hidden">
|
|
<div class="spectator-head">
|
|
<span id="spectatorLabel" class="muted"></span>
|
|
<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>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
var socketOpts = {};
|
|
try {
|
|
var resumeToken = localStorage.getItem('wownerogue_token');
|
|
if (resumeToken) socketOpts.auth = { resumeToken: resumeToken };
|
|
} catch (_) {}
|
|
var socket = io(socketOpts);
|
|
var RK = window.RK;
|
|
var MatchCopy = window.MatchUiCopy;
|
|
|
|
var statusEl = document.getElementById('status');
|
|
var joinPanel = document.getElementById('joinPanel');
|
|
var joinBtn = document.getElementById('joinBtn');
|
|
var joinStatus = document.getElementById('joinStatus');
|
|
var stageEl = document.getElementById('stage');
|
|
var modebar = document.getElementById('modebar');
|
|
var packbar = document.getElementById('packbar');
|
|
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');
|
|
var appearancePreview = document.getElementById('appearancePreview');
|
|
var selectedAppearance = ((RK.loadIdentity && RK.loadIdentity()) || {}).appearance || RK.normalizeAppearance('default');
|
|
function updateAppearancePreview() {
|
|
var a = RK.appearance ? RK.appearance(selectedAppearance) : null;
|
|
appearancePreview.textContent = a ? ('- ' + a.label + (a.premium ? ' *' : '')) : '';
|
|
}
|
|
customizeBtn.addEventListener('click', function () {
|
|
RK.openCustomize(selectedAppearance, function (appearance) {
|
|
selectedAppearance = RK.normalizeAppearance(appearance);
|
|
updateAppearancePreview();
|
|
socket.emit('identity:update', { appearance: selectedAppearance });
|
|
});
|
|
});
|
|
updateAppearancePreview();
|
|
|
|
var state = { layout: null, cols: 0, rows: 0, occupants: [], you: null, channel: null };
|
|
var currentRenderer = null;
|
|
var currentMode = null;
|
|
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' });
|
|
var joinPending = false;
|
|
var joinReady = false;
|
|
var joinTimer = null;
|
|
|
|
function setStatus(msg, kind) {
|
|
statusEl.textContent = msg;
|
|
joinStatus.textContent = msg;
|
|
var connectionKind = kind || (socket.connected ? 'connected' : '');
|
|
joinStatus.classList.toggle('connected', connectionKind === 'connected');
|
|
joinStatus.classList.toggle('error', connectionKind === 'error');
|
|
}
|
|
function updateJoinButton() {
|
|
joinBtn.disabled = joinPending || !socket.connected || !joinReady;
|
|
joinBtn.textContent = joinPending
|
|
? 'Entering…'
|
|
: (!socket.connected ? 'Connecting…' : (joinReady ? 'Enter tavern' : 'Preparing…'));
|
|
}
|
|
function clearJoinAttempt() {
|
|
if (joinTimer) window.clearTimeout(joinTimer);
|
|
joinTimer = null;
|
|
joinPending = false;
|
|
updateJoinButton();
|
|
}
|
|
function markJoinReady() {
|
|
if (!socket.connected || joinReady) return;
|
|
joinReady = true;
|
|
updateJoinButton();
|
|
setStatus('Connected. Choose a name and enter.', 'connected');
|
|
}
|
|
function applyEntitlements(data) {
|
|
if (RK.setEntitlementSnapshot) RK.setEntitlementSnapshot(data || {});
|
|
updateAppearancePreview();
|
|
if (!modebar.classList.contains('hidden')) {
|
|
buildModebar();
|
|
if (currentMode && !RK.canUseMode(currentMode)) setMode('tiles');
|
|
}
|
|
}
|
|
|
|
// ---- Chain ambience ----------------------------------------------------------
|
|
// Decorative only: it consumes the public `blockheight` broadcast. The last payload is
|
|
// retained so a tip that arrives before the player enters still shows once mounted.
|
|
var chainAmbience = null;
|
|
var lastChainTip = null;
|
|
function mountChainAmbience() {
|
|
if (chainAmbience || !RK.mountChainAmbience) return;
|
|
chainAmbience = RK.mountChainAmbience(stageEl);
|
|
if (chainAmbience && lastChainTip) chainAmbience.setTip(lastChainTip);
|
|
}
|
|
|
|
// ---- Rendering ---------------------------------------------------------------
|
|
function rebuildScene() {
|
|
if (!state.layout) return;
|
|
currentScene = RK.scene.sceneFromTavern(state, state.you);
|
|
}
|
|
function draw() {
|
|
if (!currentScene) return;
|
|
// Inject client-side NPCs (bartender, patrons, cat) into the scene.
|
|
if (tavernLife) tavernLife.injectInto(currentScene);
|
|
if (currentRenderer) {
|
|
currentRenderer.render(currentScene);
|
|
if (mainCamera) mainCamera.update(currentRenderer);
|
|
}
|
|
}
|
|
|
|
// rAF loop for smooth NPC animation + speech bubbles.
|
|
function startRafLoop() {
|
|
if (rafActive) return;
|
|
rafActive = true;
|
|
function loop() {
|
|
if (!rafActive) return;
|
|
if (currentScene && tavernLife) {
|
|
// Re-inject NPCs if the scene was rebuilt by a snapshot.
|
|
if (!currentScene._npcInjected) tavernLife.injectInto(currentScene);
|
|
tavernLife.update(currentScene, currentRenderer && currentRenderer.cell || 24, currentMode || 'tiles');
|
|
}
|
|
window.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') + '.';
|
|
}
|
|
return label + ' is locked. Buy any credits to unlock premium modes.';
|
|
}
|
|
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);
|
|
RK.saveMode(mode);
|
|
updateModeButtons();
|
|
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 || request !== rendererRequest) { r.destroy(); return; } // switched while loading
|
|
currentRenderer = r;
|
|
draw();
|
|
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 = activeMode;
|
|
setStatus(active === mode
|
|
? 'Render mode: ' + active
|
|
: '“' + mode + '” unavailable here, using “' + active + '”.');
|
|
});
|
|
}
|
|
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 + (!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);
|
|
});
|
|
updateModeButtons();
|
|
buildPackbar();
|
|
}
|
|
|
|
// The render projection a mode belongs to (topdown themes / iso packs / 3d skins).
|
|
function projForMode(mode) {
|
|
if (mode === 'iso') return 'iso';
|
|
if (mode === '3d') return '3d';
|
|
if (mode === 'ascii') return null; // ASCII draws glyphs; it consumes no tile pack
|
|
return 'topdown';
|
|
}
|
|
|
|
// Pack picker: when the current projection has more than one UNLOCKED pack, show a selector
|
|
// to switch between them. This is the "packs are products" engine made visible.
|
|
function buildPackbar() {
|
|
if (!packbar || !RK.unlockedPacks) return;
|
|
packbar.innerHTML = '';
|
|
var proj = projForMode(currentMode || 'tiles');
|
|
var packs = proj ? RK.unlockedPacks(proj) : [];
|
|
if (packs.length < 2) { packbar.classList.add('hidden'); return; }
|
|
packbar.classList.remove('hidden');
|
|
var activeId = RK.activePackId(proj);
|
|
var lbl = document.createElement('span');
|
|
lbl.className = 'packlabel';
|
|
lbl.textContent = 'Pack:';
|
|
packbar.appendChild(lbl);
|
|
packs.forEach(function (p) {
|
|
var b = document.createElement('button');
|
|
b.className = 'packbtn' + (p.id === activeId ? ' active' : '');
|
|
b.textContent = p.label;
|
|
b.addEventListener('click', function () {
|
|
if (RK.setActivePack(p.id)) { buildPackbar(); setMode(currentMode); }
|
|
});
|
|
packbar.appendChild(b);
|
|
});
|
|
}
|
|
function updateModeButtons() {
|
|
Array.prototype.forEach.call(modebar.children, function (b) {
|
|
b.classList.toggle('active', b.dataset.mode === currentMode);
|
|
});
|
|
}
|
|
|
|
// ---- Join / state ------------------------------------------------------------
|
|
joinPanel.addEventListener('submit', function (event) {
|
|
event.preventDefault();
|
|
if (joinPending) return;
|
|
if (!socket.connected || !joinReady) {
|
|
setStatus('The anonymous session is not ready yet. Retrying automatically…', 'error');
|
|
updateJoinButton();
|
|
return;
|
|
}
|
|
var name = document.getElementById('name').value || '';
|
|
var appearance = RK.normalizeAppearance(selectedAppearance);
|
|
joinPending = true;
|
|
updateJoinButton();
|
|
setStatus('Entering the tavern…', 'connected');
|
|
socket.emit('tavern_join', { name: name, avatar: appearance.avatar, appearance: appearance });
|
|
joinTimer = window.setTimeout(function () {
|
|
if (!joinPending) return;
|
|
clearJoinAttempt();
|
|
setStatus('The tavern did not respond. Check the connection and try again.', 'error');
|
|
}, 10000);
|
|
});
|
|
|
|
socket.on('tavern_error', function (e) {
|
|
clearJoinAttempt();
|
|
setStatus((e && e.message) || 'Could not enter the tavern.', 'error');
|
|
});
|
|
socket.on('game_mode_info', function (data) {
|
|
lastGameModeInfo = data || null;
|
|
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
|
if (data && data.cryptoType) cryptoType = data.cryptoType;
|
|
});
|
|
socket.on('credits_update', applyEntitlements);
|
|
socket.on('session_token', function (data) {
|
|
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
|
|
markJoinReady();
|
|
});
|
|
socket.on('session_resumed', function (data) {
|
|
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
|
|
applyEntitlements(data || {});
|
|
markJoinReady();
|
|
});
|
|
socket.on('identity_update', function (data) {
|
|
if (!data) return;
|
|
if (data.entitlements) applyEntitlements(data.entitlements);
|
|
if (data.appearance) {
|
|
selectedAppearance = RK.normalizeAppearance(data.appearance);
|
|
RK.saveIdentity(selectedAppearance);
|
|
updateAppearancePreview();
|
|
}
|
|
markJoinReady();
|
|
});
|
|
socket.on('identity_error', function (data) {
|
|
if (data && data.message) setStatus(data.message);
|
|
});
|
|
|
|
socket.on('tavern_joined', function (data) {
|
|
clearJoinAttempt();
|
|
state.you = data.you;
|
|
applyFull(data.state);
|
|
joinPanel.classList.add('hidden');
|
|
modebar.classList.remove('hidden');
|
|
stageEl.classList.remove('hidden');
|
|
chatPanel.classList.remove('hidden');
|
|
tavernDpad.classList.remove('hidden');
|
|
movementHint.classList.remove('hidden');
|
|
stageEl.setAttribute('tabindex', '-1');
|
|
rebuildScene();
|
|
buildModebar();
|
|
setMode(RK.loadMode('tiles'));
|
|
// Spawn client-side NPCs (bartender, patrons, cat) for a lively tavern.
|
|
tavernLife = new RK.TavernLife(stageEl, function () { return currentScene; }, function () {
|
|
return currentRenderer && currentRenderer.cell || 24;
|
|
});
|
|
tavernLife.setMode(currentMode || 'tiles');
|
|
tavernLife.spawn();
|
|
tavernLife.welcomeBubbles();
|
|
// The chain drifting around the frame: hash fragments and the height, with a ripple
|
|
// whenever a block lands. It sits over the stage and never intercepts input.
|
|
mountChainAmbience();
|
|
startRafLoop();
|
|
// Load the designed room (if the server is using one); re-render when ready.
|
|
if (state.roomUrl) RK.loadRoom(state.roomUrl, function (room) {
|
|
if (room && room.desc && room.desc.walkable) state.walkable = room.desc.walkable;
|
|
rebuildScene();
|
|
draw();
|
|
});
|
|
setStatus('You are in the tavern.', 'connected');
|
|
watchGamesBtn.classList.remove('hidden');
|
|
});
|
|
|
|
socket.on('blockheight', function (data) {
|
|
lastChainTip = data;
|
|
if (chainAmbience) chainAmbience.setTip(data);
|
|
});
|
|
|
|
socket.on('tavern_update', function (snap) {
|
|
if (!snap) return;
|
|
state.occupants = snap.occupants || [];
|
|
rebuildScene();
|
|
draw();
|
|
});
|
|
|
|
function applyFull(s) {
|
|
state.layout = s.layout || [];
|
|
state.walkable = s.walkable || null;
|
|
state.cols = s.cols || (state.layout[0] ? state.layout[0].length : 0);
|
|
state.rows = s.rows || state.layout.length;
|
|
state.occupants = s.occupants || [];
|
|
state.channel = 'tavern:' + (s.roomId || 'main');
|
|
state.roomUrl = s.roomUrl || null;
|
|
}
|
|
|
|
// ---- Chat --------------------------------------------------------------------
|
|
socket.on('chat_broadcast', function (m) {
|
|
if (!m) return;
|
|
// Accept the unified GLOBAL chat (no scope / 'global') as well as this tavern's room.
|
|
var isGlobal = !m.scope || m.scope === 'global';
|
|
if (!isGlobal && m.scope !== state.channel) return;
|
|
addChatLine(m.username, m.message);
|
|
// Speech bubble above the speaker's avatar.
|
|
//
|
|
// This used to key off `m.socketId`, which the chat provider deliberately stopped
|
|
// sending (it publishes a short `publicId` instead so the raw socket id never leaves the
|
|
// server), so the bubble branch simply never ran and nobody in the tavern ever spoke
|
|
// visibly. Resolve the speaker from the occupant list by display name instead, which
|
|
// needs no additional identity on the wire.
|
|
if (tavernLife) {
|
|
var speaker = null;
|
|
for (var oi = 0; oi < state.occupants.length; oi++) {
|
|
if (state.occupants[oi] && state.occupants[oi].name === m.username) {
|
|
speaker = state.occupants[oi];
|
|
break;
|
|
}
|
|
}
|
|
if (speaker) {
|
|
// m.message is server-escaped HTML; strip tags for the bubble.
|
|
var text = m.message ? m.message.replace(/<[^>]*>/g, '') : '';
|
|
tavernLife.showPlayerBubble(speaker.id, text);
|
|
}
|
|
}
|
|
});
|
|
socket.on('tavern_notice', function (n) {
|
|
if (n && n.message) addSystemLine(n.message);
|
|
});
|
|
// Global chat backlog sent on join: render it so the tavern shows history, not a blank log.
|
|
socket.on('chat_history', function (payload) {
|
|
var msgs = payload && payload.messages;
|
|
if (!msgs || !msgs.length) return;
|
|
for (var i = 0; i < msgs.length; i++) {
|
|
var mm = msgs[i] || {};
|
|
var uname = mm.username || mm.playerId || (mm.socketId ? String(mm.socketId).slice(0, 6) : '???');
|
|
addChatLine(uname, mm.message || '');
|
|
}
|
|
});
|
|
function addSystemLine(text) {
|
|
var line = document.createElement('div');
|
|
line.className = 'cline csys';
|
|
line.textContent = text;
|
|
chatLog.appendChild(line);
|
|
while (chatLog.childElementCount > 200) chatLog.removeChild(chatLog.firstChild);
|
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
}
|
|
function addChatLine(name, message) {
|
|
var line = document.createElement('div');
|
|
line.className = 'cline';
|
|
line.innerHTML = '<span class="cname"></span> ';
|
|
line.firstChild.textContent = name || '???';
|
|
line.insertAdjacentHTML('beforeend', message); // server-escaped
|
|
chatLog.appendChild(line);
|
|
while (chatLog.childElementCount > 200) chatLog.removeChild(chatLog.firstChild);
|
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
}
|
|
chatForm.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var text = chatInput.value;
|
|
if (text && text.trim()) {
|
|
// Try to post under the player's OWN Smirk npub (per-player identity); fall back to
|
|
// the normal unsigned send if Smirk signing isn't available/declined.
|
|
if (window.smirkChatSign) {
|
|
window.smirkChatSign(socket, text.trim(), 'wowngeon-global').then(function (signed) {
|
|
if (!signed) socket.emit('tavern_chat', { text: text });
|
|
});
|
|
} else {
|
|
socket.emit('tavern_chat', { text: text });
|
|
}
|
|
}
|
|
chatInput.value = '';
|
|
});
|
|
|
|
// ---- Input -------------------------------------------------------------------
|
|
var KEYS = {
|
|
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 && spectatorKind !== 'player-match')) return;
|
|
e.preventDefault();
|
|
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 to the tavern…');
|
|
updateJoinButton();
|
|
socket.on('connect', function () {
|
|
joinReady = false;
|
|
clearJoinAttempt();
|
|
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
|
|
socket.emit('identity:get');
|
|
socket.emit('match_reconnect', {});
|
|
setStatus('Connected. Preparing your anonymous session…', 'connected');
|
|
});
|
|
socket.on('disconnect', function () {
|
|
joinReady = false;
|
|
clearJoinAttempt();
|
|
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
|
|
setStatus('Connection lost. Reconnecting automatically…', 'error');
|
|
});
|
|
socket.on('connect_error', function () {
|
|
joinReady = false;
|
|
clearJoinAttempt();
|
|
setStatus('Could not connect. Retrying automatically…', 'error');
|
|
});
|
|
|
|
// ---- 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');
|
|
var matchEconomies = document.getElementById('matchEconomies');
|
|
var matchQueueStatus = document.getElementById('matchQueueStatus');
|
|
var joinMatchQueueBtn = document.getElementById('joinMatchQueueBtn');
|
|
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: 'Pleb · Free', credits_prestige: 'Prestige · Credits', crypto_race: 'Stagenet · Crypto entry' };
|
|
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;
|
|
var b = document.createElement('button');
|
|
b.textContent = labels[key];
|
|
b.className = 'modebtn' + (key === selectedMatchEconomy ? ' active' : '');
|
|
b.addEventListener('click', function () {
|
|
selectedMatchEconomy = key;
|
|
Array.prototype.forEach.call(matchEconomies.children, function (c) { c.classList.remove('active'); });
|
|
b.classList.add('active');
|
|
});
|
|
matchEconomies.appendChild(b);
|
|
});
|
|
joinMatchQueueBtn.disabled = available.length === 0;
|
|
}
|
|
|
|
socket.on('game_mode_info', function (data) {
|
|
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');
|
|
matchQueueStatus.textContent = 'Queued for ' + selectedMatchEconomy + ' (position ' + (res.position || '?') + ').';
|
|
} else {
|
|
matchQueueStatus.textContent = 'Queue failed: ' + (res.reason || 'unknown');
|
|
}
|
|
});
|
|
|
|
socket.on('match_queue_left', function (res) {
|
|
res = res || {};
|
|
inMatchQueue = false;
|
|
joinMatchQueueBtn.classList.remove('hidden');
|
|
leaveMatchQueueBtn.classList.add('hidden');
|
|
matchQueueStatus.textContent = res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown');
|
|
});
|
|
|
|
socket.on('tavern_match_list', function (list) {
|
|
activeMatchList.innerHTML = '';
|
|
activeMatchSnapshots = {};
|
|
if (!list || list.length === 0) {
|
|
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';
|
|
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;
|
|
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) {
|
|
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) {
|
|
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');
|
|
});
|
|
closeMatchPanelBtn.addEventListener('click', function () { matchPanel.classList.add('hidden'); });
|
|
|
|
joinMatchQueueBtn.addEventListener('click', function () {
|
|
if (inMatchQueue) return;
|
|
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;
|
|
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'leave' });
|
|
});
|
|
|
|
// ---- End match UI ----------------------------------------------------
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|