Files
deathwatch/overlay.html
T
emmatherock e1f7e6f529 Initial commit: DeathWatch, an Elden Ring death counter for OBS
Reads the game's process memory read-only (AOB signature scanning,
re-resolved every tick — never a cached pointer) and serves a live
death counter as a browser-source overlay plus a status panel.

Supports co-op: each player runs the program locally, one acts as
hub. The peer link is authenticated (HMAC challenge/response, no
token on the wire, constant-time compare) but not yet encrypted —
see CLAUDE.md's TODO section for the planned TLS+pinning split.

Zero external dependencies — including a hand-rolled WebSocket
implementation — so the whole thing ships as one .exe.
2026-09-17 21:10:22 -03:00

378 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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);
}
/* Con un solo jugador la placa se angosta; el canvas de OBS no cambia,
simplemente ocupa menos. Asi las dimensiones se configuran una vez. */
.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);}
/* Una sola linea bajo el numero: dice "muertes" y, cuando hay algo que
contar (jefe, sin conexion), lo agrega ahi mismo. Asi la palabra
siempre esta y el alto del overlay no cambia nunca. */
.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';
// Textos de la interfaz. El diccionario real viene de /strings.json
// (ver i18n.go y locales/*.json). Este de aca es el bote salvavidas:
// si esa peticion falla, se ve ingles y nunca una clave cruda.
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 : '');
}
// Cache de las columnas: reconstruir el DOM en cada tick haria
// parpadear el overlay y perderia la animacion del pulso.
//
// Se indexan por POSICION, nunca por nombre. Indexarlas por nombre fue
// un bug real: al cambiar de personaje cambiaba la clave, se creaba una
// columna nueva y la vieja quedaba colgada en el DOM, asi que un mismo
// jugador aparecia dos veces. El nombre es contenido, no identidad.
var slots = [];
var prevByName = {};
// ensureSlots deja exactamente n columnas, creando o sacando las que
// hagan falta (con su divisor).
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];
// Sin nombre de personaje todavía: etiqueta según el modo.
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= en la URL pisa el idioma del config, comodo para tener el
// overlay en un idioma y el panel en otro sin tocar archivos.
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(){ /* nos quedamos con FALLBACK */ })
.finally(function(){ applyStrings(); poll(); });
})();
</script>
</body>
</html>