mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.
Private material moved out of the public tree (docs/private/, git-ignored):
- the wallet keyfile recovery runbook and its helper scripts, which together
published the mainnet wallet file path, the service account, backup snapshot
filenames, and the wallet RPC listener state. The helper duplicated the same
constants, so relocating the document alone would not have removed them.
- the stagenet parallel-candidate runbook and its fixtures.
- the dated production-state record, the internal remediation plan, and the
TODO list. None are reference documentation for an outside reader.
Redacted or genericised:
- operator home-directory paths in shipped scripts and units. The asset build
script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
- private LAN addresses in tests, env examples and deploy docs, replaced with
RFC 5737 documentation addresses.
- the default alert sender and the notification hub example.
- the Monero wallet unit now matches the Wownero one: a dedicated service
account and a config file, instead of the operator's login account with the
wallet password in argv where any local user can read it from /proc.
Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.
Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.
Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.
Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.
127 suites / 1015 tests pass.
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>
|