Identifiers, comments, and log/console messages in auth.go, i18n.go, counter.go, totals.go, and overlay.html's script/CSS comments — the files the TLS commit didn't already touch. No functional changes. locales/*.json and overlay.html's own FALLBACK/data-i18n strings stay bilingual on purpose: that's end-user text for the overlay/panel, a different audience than the source code, served by the existing i18n system rather than this convention.
380 lines
18 KiB
HTML
380 lines
18 KiB
HTML
<!doctype html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Elden Ring Death Counter</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0b0906;
|
||
--bg-elevated:#17130f;
|
||
--bg-card:#1d1712;
|
||
--gold:#c9a24b;
|
||
--gold-bright:#e8c874;
|
||
--ash-red:#8a2a2a;
|
||
--ash-red-bright:#b23a3a;
|
||
--parchment:#e9e2cf;
|
||
--muted:#948a76;
|
||
--rule:#3a3226;
|
||
--ok:#7fae6e;
|
||
--shadow:0 20px 60px rgba(0,0,0,.55);
|
||
}
|
||
*{box-sizing:border-box;}
|
||
html,body{background:var(--bg);}
|
||
body{
|
||
margin:0; min-height:100vh; background:var(--bg); color:var(--parchment);
|
||
font-family:'EB Garamond', Georgia, 'Times New Roman', serif;
|
||
-webkit-font-smoothing:antialiased;
|
||
}
|
||
[hidden]{display:none!important;}
|
||
.display,h1,.num{font-family:'Cinzel', Georgia, serif;}
|
||
.eyebrow{font-family:'Cinzel', Georgia, serif;font-size:.72rem;letter-spacing:.32em;text-transform:uppercase;color:var(--muted);}
|
||
.num{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}
|
||
@media (prefers-reduced-motion: reduce){*{animation-duration:.001ms!important;transition-duration:.001ms!important;}}
|
||
|
||
/* overlay (OBS) */
|
||
html.mode-overlay,html.mode-overlay body{background:transparent;}
|
||
#overlay{background:transparent;min-height:100vh;display:flex;align-items:flex-start;justify-content:flex-start;padding:16px;}
|
||
.plate{
|
||
background:rgba(9,7,5,.9); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
|
||
border-top:1px solid var(--gold); border-bottom:1px solid var(--gold);
|
||
padding:18px 24px; width:600px; box-sizing:border-box; box-shadow:var(--shadow);
|
||
}
|
||
/* With a single player the plate narrows; the OBS canvas doesn't
|
||
change, it just takes up less of it. So the dimensions get set once. */
|
||
.plate.solo{width:420px;}
|
||
.players{display:flex;align-items:stretch;justify-content:center;}
|
||
.pcol{flex:1 1 0;min-width:0;text-align:center;}
|
||
.pname{
|
||
font-family:'Cinzel', Georgia, serif;font-size:.78rem;letter-spacing:.24em;text-transform:uppercase;
|
||
color:var(--gold);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||
}
|
||
.pnum{
|
||
display:block;font-family:'Cinzel', Georgia, serif;font-variant-numeric:tabular-nums;
|
||
font-size:3.6rem;line-height:1.05;font-weight:600;color:var(--parchment);margin-top:.25rem;
|
||
transition:color .15s ease, transform .15s ease;
|
||
}
|
||
.pnum.pulse{color:var(--gold-bright);transform:scale(1.06);}
|
||
/* A single line under the number: says "deaths" and, when there's
|
||
something else to report (boss, no connection), appends it right
|
||
there. That way the word is always present and the overlay's height
|
||
never changes. */
|
||
.pnote{
|
||
font-family:'Cinzel', Georgia, serif;font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;
|
||
color:var(--muted);min-height:1.5em;margin-top:.35rem;
|
||
}
|
||
.pnote .st{color:var(--ash-red-bright);font-style:italic;}
|
||
.pnote .sep{opacity:.5;margin:0 .45em;}
|
||
.pcol.off .pname,.pcol.off .pnum{color:var(--muted);}
|
||
.pdiv{width:1px;flex:0 0 1px;margin:0 20px;background:linear-gradient(180deg,transparent,var(--gold),transparent);opacity:.75;}
|
||
|
||
/* control / status */
|
||
#control{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:3rem 1.25rem;}
|
||
.card{width:100%;max-width:560px;background:var(--bg-card);border:1px solid var(--rule);box-shadow:var(--shadow);padding:2.4rem 2.4rem 2rem;position:relative;}
|
||
.card::before,.card::after{content:"";position:absolute;left:2.4rem;right:2.4rem;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent);opacity:.6;}
|
||
.card::before{top:1.35rem;} .card::after{bottom:1.35rem;}
|
||
.title{text-align:center;font-size:1.05rem;letter-spacing:.28em;text-transform:uppercase;color:var(--gold);margin:0 0 .3rem;}
|
||
.title small{display:block;font-family:'EB Garamond', Georgia, serif;letter-spacing:0;text-transform:none;font-style:italic;color:var(--muted);font-size:.95rem;margin-top:.3rem;}
|
||
.hero{text-align:center;margin:2.1rem 0 1.6rem;}
|
||
.hero .num{font-size:6rem;line-height:1;font-weight:700;color:var(--parchment);transition:color .15s ease, transform .15s ease;}
|
||
.hero .num.pulse{color:var(--gold-bright);transform:scale(1.05);}
|
||
.hero .eyebrow{margin-top:.5rem;}
|
||
.stats{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--rule);margin:1.6rem 0;border:1px solid var(--rule);}
|
||
.stat{background:var(--bg-elevated);padding:.9rem 1rem;text-align:center;}
|
||
.stat .stat-num{font-size:1.5rem;font-weight:600;color:var(--gold);}
|
||
.stat .stat-label{font-size:.65rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-top:.2rem;}
|
||
.field{margin:1.4rem 0;}
|
||
.field label{display:block;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:.4rem;}
|
||
.field .row{display:flex;gap:.5rem;}
|
||
.field input[type=number],.field input[type=text]{
|
||
width:100%;background:var(--bg-elevated);border:1px solid var(--rule);color:var(--parchment);
|
||
font-family:'EB Garamond', Georgia, serif;font-size:1.05rem;padding:.55rem .75rem;
|
||
}
|
||
.field input:focus{border-color:var(--gold);outline:none;}
|
||
.btn{border:1px solid var(--gold);background:transparent;color:var(--gold-bright);padding:.6rem 1rem;font-size:.85rem;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .15s ease;}
|
||
.btn:hover{background:rgba(201,162,75,.12);}
|
||
.hint{font-size:.78rem;color:var(--muted);margin-top:.5rem;line-height:1.5;}
|
||
.obs-box{margin-top:1.8rem;padding-top:1.3rem;border-top:1px solid var(--rule);}
|
||
.obs-url-row{display:flex;gap:.5rem;}
|
||
.obs-url-row input{flex:1;background:var(--bg-elevated);border:1px solid var(--rule);color:var(--muted);font-family:'EB Garamond', monospace;font-size:.85rem;padding:.5rem .6rem;}
|
||
.copy-btn{border:1px solid var(--rule);background:transparent;color:var(--muted);padding:.5rem .9rem;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;}
|
||
.copy-btn:hover{border-color:var(--gold);color:var(--gold);}
|
||
.status{text-align:center;font-size:.75rem;color:var(--muted);margin-top:1.3rem;letter-spacing:.05em;}
|
||
.status .dot{display:inline-block;width:.45em;height:.45em;border-radius:50%;background:var(--muted);margin-right:.4em;}
|
||
.status.live .dot{background:var(--ok);}
|
||
.status.err .dot{background:var(--ash-red-bright);}
|
||
.build{text-align:center;font-size:.68rem;color:var(--muted);margin-top:.4rem;letter-spacing:.04em;opacity:.7;}
|
||
.boss-flag{display:none;text-align:center;color:var(--ash-red-bright);font-style:italic;letter-spacing:.08em;margin-top:.3rem;}
|
||
.boss-flag.on{display:block;}
|
||
.peer-row{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;padding:.45rem .75rem;background:var(--bg-elevated);border:1px solid var(--rule);margin-bottom:.35rem;}
|
||
.peer-row .pn{color:var(--parchment);}
|
||
.peer-row .pv{font-family:'Cinzel', Georgia, serif;color:var(--gold);font-variant-numeric:tabular-nums;}
|
||
.peer-row.off .pn,.peer-row.off .pv{color:var(--muted);}
|
||
.peer-row .ps{font-size:.72rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
|
||
</style>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
||
<script>
|
||
(function(){
|
||
var params = new URLSearchParams(location.search);
|
||
var mode = (params.get('view') || 'control').toLowerCase();
|
||
if (mode !== 'overlay') mode = 'control';
|
||
document.documentElement.classList.add('mode-' + mode);
|
||
window.__mode = mode;
|
||
})();
|
||
</script>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="overlay" hidden>
|
||
<div class="plate solo" id="ov-plate">
|
||
<div class="players" id="ov-players"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="control" hidden>
|
||
<div class="card">
|
||
<p class="title"><span data-i18n="panel.title">Elden Ring</span><small data-i18n="panel.subtitle">Death Counter</small></p>
|
||
<div class="hero">
|
||
<span class="num" id="c-total">–</span>
|
||
<div class="eyebrow" data-i18n="panel.total">Total deaths</div>
|
||
</div>
|
||
<div class="boss-flag" id="c-boss" data-i18n="panel.boss">⚔ In a boss fight</div>
|
||
<div class="stats">
|
||
<div class="stat"><div class="stat-num" id="c-raw">–</div><div class="stat-label" data-i18n="panel.raw">Read from process</div></div>
|
||
<div class="stat"><div class="stat-num" id="c-slot">–</div><div class="stat-label" data-i18n="panel.slot">Save slot</div></div>
|
||
</div>
|
||
<div class="field" id="c-peers-box" hidden>
|
||
<label data-i18n="panel.partners">Partners</label>
|
||
<div id="c-peers"></div>
|
||
</div>
|
||
<div class="obs-box">
|
||
<div class="field">
|
||
<label data-i18n="panel.obsUrl">URL for OBS (Browser Source)</label>
|
||
<div class="obs-url-row">
|
||
<input type="text" id="obs-url" readonly>
|
||
<button class="copy-btn" id="copy-btn" data-i18n="panel.copy">Copy</button>
|
||
</div>
|
||
</div>
|
||
<p class="hint" data-i18n="panel.obsHint"></p>
|
||
</div>
|
||
<p class="status" id="status"><span class="dot"></span><span id="status-text" data-i18n="status.connecting">Connecting…</span></p>
|
||
<p class="build" id="c-build"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function(){
|
||
var mode = window.__mode || 'control';
|
||
|
||
// Interface text. The real dictionary comes from /strings.json (see
|
||
// i18n.go and locales/*.json). This one here is the lifeboat: if that
|
||
// request fails, you see English and never a raw key.
|
||
var FALLBACK = {
|
||
"overlay.deaths": "deaths",
|
||
"overlay.boss": "boss",
|
||
"overlay.offline": "no connection",
|
||
"overlay.waiting": "waiting for the game…",
|
||
"overlay.you": "You",
|
||
"overlay.partner": "Partner",
|
||
"panel.title": "Elden Ring",
|
||
"panel.subtitle": "Death Counter · live reading",
|
||
"panel.total": "Total deaths",
|
||
"panel.raw": "Read from process",
|
||
"panel.slot": "Save slot",
|
||
"panel.boss": "⚔ In a boss fight",
|
||
"panel.partners": "Partners",
|
||
"panel.obsUrl": "URL for OBS (Browser Source)",
|
||
"panel.copy": "Copy",
|
||
"panel.copied": "Copied",
|
||
"panel.copyManual": "Select and copy",
|
||
"panel.obsHint": "Paste this URL into a Browser source in OBS. Transparent background, updates on its own as long as this console window stays open.",
|
||
"panel.build": "Build",
|
||
"status.connecting": "Connecting…",
|
||
"status.waitingGame": "Waiting for you to open Elden Ring…",
|
||
"status.menu": "Elden Ring open — main menu or loading screen",
|
||
"status.live": "Live — reading Elden Ring",
|
||
"status.noServer": "No connection to the local program",
|
||
"peer.playing": "playing",
|
||
"peer.menu": "in the menu",
|
||
"peer.offline": "no connection"
|
||
};
|
||
var STR = {};
|
||
function t(k){ return STR[k] || FALLBACK[k] || k; }
|
||
|
||
function applyStrings(){
|
||
var nodes = document.querySelectorAll('[data-i18n]');
|
||
for (var i = 0; i < nodes.length; i++){
|
||
nodes[i].textContent = t(nodes[i].getAttribute('data-i18n'));
|
||
}
|
||
document.documentElement.lang = STR['_lang'] || 'en';
|
||
}
|
||
document.getElementById(mode === 'overlay' ? 'overlay' : 'control').hidden = false;
|
||
|
||
function fmtElapsed(ms){
|
||
if (ms == null) return '—';
|
||
var s = Math.floor(ms/1000);
|
||
var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||
function pad(n){return (n<10?'0':'')+n;}
|
||
return h>0 ? (h+':'+pad(m)+':'+pad(ss)) : (pad(m)+':'+pad(ss));
|
||
}
|
||
|
||
var prevTotal = null;
|
||
function pulse(el){ el.classList.add('pulse'); setTimeout(function(){el.classList.remove('pulse');},260); }
|
||
|
||
if (mode === 'control'){
|
||
document.getElementById('obs-url').value = location.origin + '/?view=overlay';
|
||
document.getElementById('copy-btn').addEventListener('click', function(){
|
||
var btn=this, url=document.getElementById('obs-url');
|
||
function done(ok){ btn.textContent = ok?t('panel.copied'):t('panel.copyManual'); setTimeout(function(){btn.textContent=t('panel.copy');},1400); }
|
||
if (navigator.clipboard && navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(url.value).then(function(){done(true);}, function(){url.select();done(false);});
|
||
} else { url.select(); done(false); }
|
||
});
|
||
}
|
||
|
||
function setStatus(text, cls){
|
||
var el = document.getElementById('status');
|
||
if (!el) return;
|
||
document.getElementById('status-text').textContent = text;
|
||
el.className = 'status' + (cls ? ' ' + cls : '');
|
||
}
|
||
|
||
// Column cache: rebuilding the DOM on every tick would make the
|
||
// overlay flicker and lose the pulse animation.
|
||
//
|
||
// Indexed by POSITION, never by name. Indexing by name was a real bug:
|
||
// switching characters changed the key, spawning a new column and
|
||
// leaving the old one stuck in the DOM, so the same player showed up
|
||
// twice. The name is content, not identity.
|
||
var slots = [];
|
||
var prevByName = {};
|
||
|
||
// ensureSlots leaves exactly n columns, creating or removing whatever's
|
||
// needed (with its divider).
|
||
function ensureSlots(list, n){
|
||
while (slots.length > n){
|
||
var old = slots.pop();
|
||
if (old.div) list.removeChild(old.div);
|
||
list.removeChild(old.col);
|
||
}
|
||
while (slots.length < n){
|
||
var i = slots.length;
|
||
var div = null;
|
||
if (i > 0){
|
||
div = document.createElement('div');
|
||
div.className = 'pdiv';
|
||
list.appendChild(div);
|
||
}
|
||
var col = document.createElement('div'); col.className = 'pcol';
|
||
var nm = document.createElement('div'); nm.className = 'pname';
|
||
var vl = document.createElement('span'); vl.className = 'pnum';
|
||
var nt = document.createElement('div'); nt.className = 'pnote';
|
||
col.appendChild(nm); col.appendChild(vl); col.appendChild(nt);
|
||
list.appendChild(col);
|
||
slots.push({col:col, div:div, name:nm, num:vl, note:nt});
|
||
}
|
||
}
|
||
|
||
function playersOf(d){
|
||
if (d.players && d.players.length) return d.players;
|
||
// Respuesta de la version de un solo jugador.
|
||
return [{name:'Deaths', deaths:d.deaths, bossFight:d.bossFight,
|
||
playerLoaded:d.playerLoaded, connected:d.connected, self:true}];
|
||
}
|
||
|
||
function renderOverlay(d){
|
||
var players = playersOf(d);
|
||
var list = document.getElementById('ov-players');
|
||
document.getElementById('ov-plate').className = 'plate' + (players.length < 2 ? ' solo' : '');
|
||
ensureSlots(list, players.length);
|
||
|
||
for (var i = 0; i < players.length; i++){
|
||
var p = players[i];
|
||
var slot = slots[i];
|
||
// No character name yet: label based on the mode.
|
||
slot.name.textContent = p.name ||
|
||
(players.length < 2 ? t('overlay.deaths') : (p.self ? t('overlay.you') : t('overlay.partner')));
|
||
slot.num.textContent = p.connected ? p.deaths : '–';
|
||
slot.col.className = 'pcol' + (p.connected ? '' : ' off');
|
||
|
||
slot.note.textContent = '';
|
||
if (!p.connected){
|
||
slot.note.textContent = p.self ? t('overlay.waiting') : t('overlay.offline');
|
||
} else {
|
||
slot.note.appendChild(document.createTextNode(t('overlay.deaths')));
|
||
if (p.bossFight){
|
||
var sep = document.createElement('span');
|
||
sep.className = 'sep'; sep.textContent = '·';
|
||
var st = document.createElement('span');
|
||
st.className = 'st'; st.textContent = '⚔ ' + t('overlay.boss');
|
||
slot.note.appendChild(sep); slot.note.appendChild(st);
|
||
}
|
||
}
|
||
var prev = prevByName[p.name];
|
||
if (p.connected && prev !== undefined && p.deaths > prev) pulse(slot.num);
|
||
prevByName[p.name] = p.connected ? p.deaths : undefined;
|
||
}
|
||
}
|
||
|
||
function render(d){
|
||
var total = d.deaths;
|
||
var display = d.connected ? total : '–';
|
||
if (mode === 'overlay'){
|
||
renderOverlay(d);
|
||
} else {
|
||
var cTotal = document.getElementById('c-total');
|
||
cTotal.textContent = display;
|
||
document.getElementById('c-raw').textContent = d.connected ? d.rawDeaths : '–';
|
||
document.getElementById('c-slot').textContent = (d.slot === undefined || d.slot < 0) ? '–' : d.slot;
|
||
document.getElementById('c-boss').classList.toggle('on', !!d.bossFight);
|
||
document.getElementById('c-build').textContent = d.build ? (t('panel.build') + ': ' + d.build) : '';
|
||
if (d.connected && prevTotal !== null && total > prevTotal) pulse(cTotal);
|
||
if (!d.connected) setStatus(t('status.waitingGame'), '');
|
||
else if (d.playerLoaded === false) setStatus(t('status.menu'), '');
|
||
else setStatus(t('status.live'), 'live');
|
||
|
||
var peers = (d.players || []).filter(function(p){ return !p.self; });
|
||
document.getElementById('c-peers-box').hidden = peers.length === 0;
|
||
var box = document.getElementById('c-peers');
|
||
box.textContent = '';
|
||
peers.forEach(function(p){
|
||
var row = document.createElement('div');
|
||
row.className = 'peer-row' + (p.connected ? '' : ' off');
|
||
var n = document.createElement('span'); n.className = 'pn'; n.textContent = p.name;
|
||
var s = document.createElement('span'); s.className = 'ps';
|
||
s.textContent = p.connected ? (p.playerLoaded ? t('peer.playing') : t('peer.menu')) : t('peer.offline');
|
||
var v = document.createElement('span'); v.className = 'pv';
|
||
v.textContent = p.connected ? p.deaths : '–';
|
||
var left = document.createElement('span');
|
||
left.appendChild(n); left.appendChild(document.createTextNode(' ')); left.appendChild(s);
|
||
row.appendChild(left); row.appendChild(v);
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
prevTotal = d.connected ? total : null;
|
||
}
|
||
|
||
function poll(){
|
||
fetch('/deaths', {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
|
||
render(d);
|
||
}).catch(function(){
|
||
setStatus(t('status.noServer'), 'err');
|
||
}).finally(function(){
|
||
setTimeout(poll, 1000);
|
||
});
|
||
}
|
||
// ?lang= in the URL overrides the config's language, handy for having
|
||
// the overlay in one language and the panel in another without
|
||
// touching any files.
|
||
var langParam = new URLSearchParams(location.search).get('lang');
|
||
fetch('/strings.json' + (langParam ? '?lang=' + encodeURIComponent(langParam) : ''), {cache:'no-store'})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){ if (d && typeof d === 'object') STR = d; })
|
||
.catch(function(){ /* stick with FALLBACK */ })
|
||
.finally(function(){ applyStrings(); poll(); });
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|