Files
wownerogue/html/tavern.html
T
jwinterm 11de46a7e4 feat(fx): RK.fx juice engine — animated torch/hearth flame + glow, hazard tiles
The shared "juice" layer, pure-canvas (CSP-safe, no WebGL/shaders), driven off a
clock so both renderers light a scene the same way:

- html/js/render/fxLayer.js (NEW): RK.fx — layered-sine flame flicker, additive
  warm glow, a dancing two-layer canvas flame, and pulsing hazard overlays
  (lava/poison/spikes) with a footprint-clip callback so it fits square cells
  and iso diamonds alike.
- Tavern now has FIRE: 4 corner braziers + a hearth on the left wall throw real
  flickering light. Placed along walls, clear of spawn/aisles/tables.
- Both renderers: torch/hearth/hazard tiles draw only their FLOOR base in the
  static pass (no more solid amber block), then RK.fx paints the animated flame
  / hazard pulse in the live loop. Tiled caches emitter positions and draws them
  over the vignette so light punches through the dark edges; iso adds them as
  depth-sorted fx items and keeps its RAF alive while any fire/hazard is onscreen.
- Dungeon hazard zones wired end-to-end in the scene model (lava L / poison P /
  spikes ^ chars -> pulsing tiles) — they render the moment the generator emits
  them; no client-authoritative fakes.
- fxLayer.js included in tavern.html + match.html.

Suite 58/419 green; render files node --check clean.
2026-07-11 17:00:43 -04:00

568 lines
28 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tavern - Wownerogue</title>
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<script src="js/smirkChatSign.js"></script>
<!-- PixiJS (+ @pixi/unsafe-eval for the CSP) is lazy-loaded by renderModes.js only when the
Fancy mode is first selected, so ASCII/Tiled users never download the WebGL library. -->
<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/renderModes.js"></script>
<style>
:root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #d7dbe0; --accent: #3fb950; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font-family: ui-monospace, Menlo, Consolas, monospace; }
.wrap { max-width: 760px; margin: 0 auto; padding: 16px; }
h1 { font-size: 18px; margin: 4px 0 12px; }
.muted { color: #8b949e; font-size: 13px; }
.rk-canvas { background: #0a0c0f; border: 1px solid var(--line); border-radius: 6px;
display: block; max-width: 100%; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
padding: 12px; margin-bottom: 12px; }
label { font-size: 13px; margin-right: 6px; }
input, select, button { font: inherit; background: #0a0c0f; color: var(--text);
border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
button { background: var(--accent); color: #08130a; border: 0; cursor: pointer; font-weight: 600; }
button:disabled { opacity: 0.5; cursor: default; }
#status { min-height: 18px; font-size: 13px; }
.hidden { display: none; }
#modebar { display: flex; gap: 6px; margin-bottom: 8px; }
.modebtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-weight: 500; }
.modebtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
.modebtn.locked { opacity: 0.55; }
#packbar { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; }
#packbar .packlabel { color: var(--muted); font-size: 12px; margin-right: 2px; }
.packbtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-size: 12px; padding: 3px 10px; border-radius: 5px; cursor: pointer; }
.packbtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
#chatPanel { margin-top: 12px; }
#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; }
</style>
</head>
<body>
<div class="wrap">
<h1>Tavern <span class="muted">— early preview</span></h1>
<div id="joinPanel" class="panel">
<label for="name">Name</label>
<input id="name" maxlength="16" placeholder="anon" autocomplete="off">
<button id="customizeBtn" type="button">🎭 Customize</button>
<span id="appearancePreview" class="muted"></span>
<button id="joinBtn">Enter tavern</button>
</div>
<div id="modebar" class="hidden"></div>
<div id="packbar" class="hidden"></div>
<div id="stage" class="hidden"></div>
<p id="status" class="muted"></p>
<p class="muted">Move with arrow keys or WASD. ★ modes are premium (credit unlock).</p>
<div id="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 -->
<button id="watchGamesBtn" class="hidden" style="margin-top:8px;">👁️ Watch Live Games</button>
<div id="spectatorPanel" class="panel hidden">
<h3 style="margin:0 0 8px;font-size:14px;">Live Games</h3>
<div id="gameList" style="max-height:200px;overflow-y:auto;font-size:13px;"></div>
<button id="closeGameListBtn" style="margin-top:8px;">Close</button>
</div>
<!-- Match mode panel -->
<button id="matchQueueBtn" class="hidden" style="margin-top:8px;">🏁 Queue for Race</button>
<div id="matchPanel" class="panel hidden">
<h3 style="margin:0 0 8px;font-size:14px;">Race Queue</h3>
<div id="matchEconomies" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div id="matchQueueStatus" class="muted" style="margin-bottom:8px;">Choose an economy.</div>
<div style="display:flex;gap:6px;">
<button id="joinMatchQueueBtn">Join Queue</button>
<button id="leaveMatchQueueBtn" class="hidden">Leave Queue</button>
</div>
<hr style="border-color:var(--line);margin:12px 0;">
<h4 style="margin:0 0 8px;font-size:13px;">Active Races</h4>
<div id="activeMatchList" style="max-height:160px;overflow-y:auto;font-size:13px;"></div>
<button id="closeMatchPanelBtn" style="margin-top:8px;">Close</button>
</div>
<div id="spectatorStage" class="hidden">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span id="spectatorLabel" class="muted"></span>
<button id="leaveSpectateBtn">← Back to tavern</button>
</div>
<div id="spectatorRender" style="position:relative;min-height:300px;"></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 statusEl = document.getElementById('status');
var joinPanel = document.getElementById('joinPanel');
var joinBtn = document.getElementById('joinBtn');
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');
// 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;
function setStatus(msg) { statusEl.textContent = msg; }
function applyEntitlements(data) {
if (RK.setEntitlementSnapshot) RK.setEntitlementSnapshot(data || {});
updateAppearancePreview();
if (!modebar.classList.contains('hidden')) {
buildModebar();
if (currentMode && !RK.canUseMode(currentMode)) setMode('tiles');
}
}
// ---- 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);
}
// 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');
}
root.requestAnimationFrame(loop);
}
root.requestAnimationFrame(loop);
}
function lockedModeMessage(mode) {
var meta = RK.modeMeta ? RK.modeMeta(mode) : null;
var label = (meta && meta.label) || mode;
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; }
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) { r.destroy(); return; } // switched again while loading
currentRenderer = r;
draw();
if (RK.attachZoom) { RK.attachZoom(stageEl); RK.applyZoom(stageEl); } // wheel/pinch zoom, all modes
buildPackbar(); // show pack choices for this mode's projection
var active = r.name || mode;
setStatus(active === mode
? 'Render mode: ' + active
: '“' + mode + '” unavailable here — using “' + active + '”.');
});
}
function buildModebar() {
modebar.innerHTML = '';
RK.RENDER_MODES.forEach(function (m) {
var b = document.createElement('button');
b.className = 'modebtn';
b.dataset.mode = m.id;
b.textContent = m.label + (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 ------------------------------------------------------------
joinBtn.addEventListener('click', function () {
var name = document.getElementById('name').value || '';
var appearance = RK.normalizeAppearance(selectedAppearance);
joinBtn.disabled = true;
socket.emit('tavern_join', { name: name, avatar: appearance.avatar, appearance: appearance });
});
socket.on('tavern_error', function (e) {
joinBtn.disabled = false;
setStatus((e && e.message) || 'Could not enter the tavern.');
});
socket.on('game_mode_info', function (data) {
if (data && data.entitlements) applyEntitlements(data.entitlements);
if (data && data.cryptoType) SocketHandlers_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 (_) {}
});
socket.on('session_resumed', function (data) {
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
applyEntitlements(data || {});
});
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();
}
});
socket.on('identity_error', function (data) {
if (data && data.message) setStatus(data.message);
});
socket.on('tavern_joined', function (data) {
state.you = data.you;
applyFull(data.state);
joinPanel.classList.add('hidden');
modebar.classList.remove('hidden');
stageEl.classList.remove('hidden');
document.getElementById('chatPanel').classList.remove('hidden');
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();
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.');
watchGamesBtn.classList.remove('hidden');
});
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);
// Show a speech bubble above the speaker's avatar.
if (tavernLife && m.socketId) {
// m.message is server-escaped HTML; strip tags for the bubble.
var text = m.message ? m.message.replace(/<[^>]*>/g, '') : '';
tavernLife.showPlayerBubble(m.socketId, 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'
};
window.addEventListener('keydown', function (e) {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
var dir = KEYS[e.key];
if (!dir || !state.you) return;
e.preventDefault();
socket.emit('tavern_move', { dir: dir });
});
setStatus('Connecting…');
socket.on('connect', function () {
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
socket.emit('identity:get');
setStatus('Connected. Choose a name and enter.');
});
// ---- 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 selectedMatchEconomy = 'free';
var inMatchQueue = false;
function buildMatchEconomies(info) {
if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return;
var economies = info.modes.match.economies || {};
var labels = { free: 'Free', credits_prestige: 'Prestige', crypto_race: 'Crypto' };
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);
});
}
socket.on('game_mode_info', function (data) {
// Extend existing handler: also build match economies.
if (data && data.entitlements) applyEntitlements(data.entitlements);
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
if (data && data.modes && data.modes.match && data.modes.match.enabled) {
matchQueueBtn.classList.remove('hidden');
buildMatchEconomies(data);
}
});
socket.on('match_queue_joined', function (res) {
if (res.success) {
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) {
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 = '';
if (!list || list.length === 0) {
activeMatchList.textContent = 'No active races right now.';
return;
}
list.forEach(function (m) {
var row = document.createElement('div');
row.className = 'gamerow';
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--line);';
row.innerHTML = '<span>' + (m.economy || 'race') + ' — ' + (m.players ? m.players.length : 0) + ' players</span> <button class="watchMatchBtn" data-matchid="' + m.matchId + '">Watch</button>';
activeMatchList.appendChild(row);
});
Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) {
b.addEventListener('click', function () {
var matchId = b.dataset.matchid;
// Switch spectator renderer to the public match state stream.
enterSpectatorMode(matchId);
});
});
});
socket.on('tavern_match_tick', function (state) {
if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) {
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, null) : null;
if (scene && spectatorRenderer) spectatorRenderer.render(scene);
}
});
socket.on('tavern_match_end', function (data) {
if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) {
spectatorLabel.textContent = 'Race ended. Winner: ' + (data.winnerId ? data.winnerId.slice(0, 6) : 'none');
}
});
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;
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'join' });
});
leaveMatchQueueBtn.addEventListener('click', function () {
if (!inMatchQueue) return;
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'leave' });
});
// ---- End match UI ----------------------------------------------------
socket.on('disconnect', function () { setStatus('Disconnected.'); });
})();
</script>
</body>
</html>