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.
This commit is contained in:
commit
e1f7e6f529
18 files changed
+3690
No files matched your search
+377
@@ -0,0 +1,377 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user