init: Elden Ring death counter with multi-client server

Deno HTTP + WebSocket server serving three pages:
- / desktop with YOU DIED overlay and keyboard controls
- /mobile touch-optimized control page
- /obs transparent browser source for OBS

Count persisted to counter.json, synced in real time across all
connected clients. Compiles to a self-contained Windows .exe via
deno compile.
This commit is contained in:
2026-03-18 02:37:01 +01:00
commit 45f94b1bdf
6 changed files with 962 additions and 0 deletions

473
desktop.html Normal file
View File

@@ -0,0 +1,473 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Elden Deathcounter (Römisch)</title>
<style>
:root{
--bg0:#06060a;
--bg1:#0b0b12;
--panel: rgba(12, 12, 18, .72);
--border: rgba(212, 175, 55, .30); /* Gold */
--border2: rgba(212, 175, 55, .15);
--text: rgba(245, 238, 210, .92);
--muted: rgba(245, 238, 210, .62);
--gold: rgba(212, 175, 55, .95);
--goldGlow: rgba(212, 175, 55, .38);
--shadow: 0 26px 80px rgba(0,0,0,.55);
--radius: 18px;
}
*{ box-sizing:border-box; }
body{
margin:0;
min-height:100vh;
display:grid;
place-items:center;
color:var(--text);
/* "altehrwürdig": Serif + Smallcaps-Look über CSS */
font-family: ui-serif, Georgia, "Times New Roman", Times, serif;
background:
radial-gradient(1200px 650px at 50% 18%, rgba(212,175,55,.10), transparent 55%),
radial-gradient(900px 500px at 20% 70%, rgba(180,120,40,.08), transparent 60%),
linear-gradient(180deg, var(--bg1), var(--bg0));
overflow:hidden;
}
/* dezente Asche-Körnigkeit */
body::before{
content:"";
position:fixed;
inset:0;
pointer-events:none;
background-image: radial-gradient(rgba(255,255,255,.06) 1px, transparent 1px);
background-size: 3px 3px;
opacity:.08;
mix-blend-mode: overlay;
filter: blur(.2px);
}
.frame{
width:min(780px, 92vw);
padding:26px;
border-radius: var(--radius);
background: var(--panel);
border:1px solid var(--border2);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
position:relative;
z-index:1;
}
.frame::after{
content:"";
position:absolute;
inset:-2px;
border-radius: calc(var(--radius) + 2px);
pointer-events:none;
background: radial-gradient(620px 300px at 50% 0%,
rgba(212,175,55,.18),
transparent 60%);
opacity:.95;
}
header{
display:flex;
justify-content:space-between;
align-items:baseline;
gap:14px;
position:relative;
z-index:2;
margin-bottom:16px;
}
h1{
margin:0;
font-size: 18px;
letter-spacing: 1.2px;
font-weight: 800;
color: rgba(245,238,210,.88);
text-transform: uppercase;
font-variant: small-caps;
}
.subtitle{
margin:0;
font-size: 12px;
color: var(--muted);
text-align:right;
line-height:1.25;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
.bigBox{
position:relative;
z-index:2;
border:1px solid var(--border);
border-radius: 20px;
padding: 26px 18px;
display:grid;
place-items:center;
min-height: 220px;
background:
radial-gradient(740px 300px at 50% 30%, rgba(212,175,55,.12), transparent 70%),
linear-gradient(180deg, rgba(255,255,255,.04), rgba(0,0,0,.12));
box-shadow: inset 0 0 0 1px rgba(0,0,0,.35);
overflow:hidden;
}
.roman{
font-size: clamp(48px, 7.4vw, 92px);
font-weight: 900;
letter-spacing: 4px;
color: rgba(245,238,210,.94);
text-shadow:
0 0 16px rgba(212,175,55,.12),
0 0 34px rgba(212,175,55,.08);
user-select:text;
text-align:center;
padding: 0 10px;
font-variant: small-caps;
}
.arabic{
margin-top:10px;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 13px;
color: var(--muted);
letter-spacing:.2px;
}
.controls{
position:relative;
z-index:2;
display:flex;
gap:12px;
justify-content:center;
align-items:center;
margin-top:18px;
flex-wrap:wrap;
}
button{
appearance:none;
border:1px solid var(--border);
color: var(--text);
background: linear-gradient(180deg, rgba(212,175,55,.16), rgba(0,0,0,.18));
padding: 12px 16px;
border-radius: 14px;
font-size: 15px;
font-weight: 800;
letter-spacing:.6px;
cursor:pointer;
box-shadow: 0 10px 22px rgba(0,0,0,.35);
transition: transform .08s ease, filter .12s ease, border-color .12s ease;
min-width: 110px;
font-variant: small-caps;
font-family: ui-serif, Georgia, "Times New Roman", Times, serif;
}
button:hover{
border-color: rgba(212,175,55,.60);
filter: brightness(1.08);
}
button:active{ transform: translateY(1px) scale(0.99); }
.btnGhost{
border-color: rgba(245,238,210,.18);
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(0,0,0,.18));
min-width: 130px;
}
.mini{
margin-top:14px;
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
flex-wrap:wrap;
position:relative;
z-index:2;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 12px;
color: var(--muted);
}
.pill{
border:1px solid rgba(245,238,210,.14);
border-radius:999px;
padding:6px 10px;
background: rgba(255,255,255,.03);
}
kbd{
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 6px;
border: 1px solid rgba(245,238,210,.18);
background: rgba(0,0,0,.25);
color: rgba(245,238,210,.9);
}
/* =========================
"YOU DIED" Overlay + Gold-Flame Glow
========================= */
.overlay{
position:fixed;
inset:0;
display:grid;
place-items:center;
pointer-events:none;
opacity:0;
z-index:50;
}
.overlay::before{
/* dunkle Vignette */
content:"";
position:absolute;
inset:0;
background:
radial-gradient(900px 420px at 50% 50%, rgba(0,0,0,.35), rgba(0,0,0,.78));
opacity:0;
}
.youDiedBox{
position:relative;
width:min(980px, 92vw);
padding: 34px 22px 28px;
border-top: 1px solid rgba(245,238,210,.12);
border-bottom: 1px solid rgba(245,238,210,.12);
text-align:center;
transform: translateY(10px) scale(.98);
opacity:0;
}
.youDiedMain{
margin:0;
font-size: clamp(34px, 5.2vw, 66px);
letter-spacing: 3px;
text-transform: uppercase;
font-weight: 900;
color: rgba(170, 30, 25, .92); /* dunkles Rot wie "YOU DIED" */
text-shadow:
0 0 18px rgba(0,0,0,.65),
0 0 40px rgba(170,30,25,.25);
font-variant: small-caps;
}
.youDiedSub{
margin: 10px 0 0;
font-size: clamp(16px, 2.2vw, 22px);
letter-spacing: 1.6px;
color: rgba(245,238,210,.82);
text-shadow: 0 0 18px rgba(0,0,0,.65);
font-variant: small-caps;
}
.goldFlame{
/* goldene "Flammen"-Aura */
position:absolute;
inset:-60px -40px -60px -40px;
background:
radial-gradient(340px 160px at 30% 55%, rgba(212,175,55,.22), transparent 70%),
radial-gradient(360px 170px at 70% 55%, rgba(212,175,55,.20), transparent 72%),
radial-gradient(260px 130px at 50% 45%, rgba(212,175,55,.16), transparent 72%);
filter: blur(10px);
opacity:0;
mix-blend-mode: screen;
pointer-events:none;
}
/* Animation-Trigger */
.overlay.show{
animation: overlayFade 900ms ease forwards;
}
.overlay.show::before{
animation: vignetteIn 900ms ease forwards;
}
.overlay.show .youDiedBox{
animation: boxIn 900ms ease forwards;
}
.overlay.show .goldFlame{
animation: flamePulse 900ms ease forwards;
}
@keyframes overlayFade{
0%{ opacity:0; }
12%{ opacity:1; }
78%{ opacity:1; }
100%{ opacity:0; }
}
@keyframes vignetteIn{
0%{ opacity:0; }
15%{ opacity:1; }
80%{ opacity:1; }
100%{ opacity:0; }
}
@keyframes boxIn{
0%{ opacity:0; transform: translateY(14px) scale(.985); }
18%{ opacity:1; transform: translateY(0) scale(1); }
75%{ opacity:1; transform: translateY(0) scale(1); }
100%{ opacity:0; transform: translateY(-6px) scale(1.01); }
}
@keyframes flamePulse{
0%{ opacity:0; transform: scale(.98); }
18%{ opacity:.95; transform: scale(1); }
40%{ opacity:.55; transform: scale(1.02); }
75%{ opacity:.75; transform: scale(1.01); }
100%{ opacity:0; transform: scale(1.03); }
}
/* Respektiert "Reduce Motion" */
@media (prefers-reduced-motion: reduce){
.overlay.show, .overlay.show::before, .overlay.show .youDiedBox, .overlay.show .goldFlame{
animation: none !important;
}
}
</style>
</head>
<body>
<main class="frame">
<header>
<h1>Deathcounter</h1>
<p class="subtitle">
Persistenz: <b>Server</b> (synchronisiert über WebSocket)<br>
<span style="color: rgba(212,175,55,.9)">Tasten:</span> <kbd>+</kbd>/<kbd>-</kbd> oder ↑↓ • <kbd>R</kbd> Reset
</p>
</header>
<section class="bigBox" aria-live="polite">
<div class="roman" id="roman"></div>
<div class="arabic" id="arabic">Tode: 0</div>
</section>
<div class="controls">
<button id="minus">-1</button>
<button id="plus">+1</button>
<button class="btnGhost" id="reset" title="Setzt auf 0 zurück">Reset</button>
</div>
<div class="mini">
<span class="pill" id="status">Verbinde…</span>
<span class="pill">Römisch: 13999 (0 wird als — angezeigt)</span>
</div>
</main>
<!-- Overlay -->
<div class="overlay" id="overlay" aria-hidden="true">
<div class="youDiedBox">
<div class="goldFlame"></div>
<p class="youDiedMain">YOU DIED</p>
<p class="youDiedSub">Ein weiterer Nadelstich!</p>
</div>
</div>
<script>
function toRoman(n) {
const map = [
{ val: 1000, sym: "M" },
{ val: 900, sym: "CM" },
{ val: 500, sym: "D" },
{ val: 400, sym: "CD" },
{ val: 100, sym: "C" },
{ val: 90, sym: "XC" },
{ val: 50, sym: "L" },
{ val: 40, sym: "XL" },
{ val: 10, sym: "X" },
{ val: 9, sym: "IX" },
{ val: 5, sym: "V" },
{ val: 4, sym: "IV" },
{ val: 1, sym: "I" }
];
let res = "";
for (const { val, sym } of map) {
while (n >= val) { res += sym; n -= val; }
}
return res;
}
const romanEl = document.getElementById("roman");
const arabicEl = document.getElementById("arabic");
const statusEl = document.getElementById("status");
const overlay = document.getElementById("overlay");
let currentCount = 0;
function clamp(n, min, max){ return Math.max(min, Math.min(max, n)); }
function showOverlay(){
overlay.classList.remove("show");
void overlay.offsetWidth;
overlay.classList.add("show");
}
function render(count) {
const prev = currentCount;
currentCount = clamp(count, 0, 3999);
romanEl.textContent = currentCount === 0 ? "—" : toRoman(currentCount);
arabicEl.textContent = `Tode: ${currentCount}`;
if (currentCount > prev) showOverlay();
}
// WebSocket setup with auto-reconnect
let ws;
function connect() {
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onopen = () => {
statusEl.textContent = "Verbunden ✓";
statusEl.style.borderColor = "rgba(212,175,55,.35)";
setTimeout(() => (statusEl.style.borderColor = "rgba(245,238,210,.14)"), 1500);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (typeof msg.count === "number") render(msg.count);
};
ws.onclose = () => {
statusEl.textContent = "Getrennt — verbinde neu…";
statusEl.style.borderColor = "rgba(170,30,25,.5)";
setTimeout(connect, 2000);
};
ws.onerror = () => ws.close();
}
function send(action) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action }));
}
}
document.getElementById("plus").addEventListener("click", () => send("increment"));
document.getElementById("minus").addEventListener("click", () => send("decrement"));
document.getElementById("reset").addEventListener("click", () => send("reset"));
window.addEventListener("keydown", (e) => {
const k = e.key;
if (k === "+" || k === "=" || k === "ArrowUp") send("increment");
else if (k === "-" || k === "_" || k === "ArrowDown") send("decrement");
else if (k.toLowerCase() === "r") send("reset");
});
connect();
</script>
</body>
</html>