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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
counter.json
|
||||
*.exe
|
||||
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
A single-file, zero-dependency Elden Ring death counter (`Elden Ring Counter.html`). Open directly in any browser — no build step, no server needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything lives in one HTML file with three sections:
|
||||
|
||||
- **CSS** (lines 8–339): Elden Ring-themed dark UI using CSS custom properties (`--gold`, `--bg0`, etc.). The "YOU DIED" overlay is CSS-animation-driven via the `.show` class.
|
||||
- **HTML** (lines 342–376): Static markup. `#roman` and `#arabic` are the display targets; `#overlay` holds the death animation.
|
||||
- **JS** (lines 378–468): Vanilla JS. `deaths` is the single state variable, persisted to `localStorage` under key `elden_deathcounter_value`. `render()` syncs state → DOM → storage. `add(delta)` is the only way to mutate count; it triggers the overlay on increment.
|
||||
|
||||
## Key constraints
|
||||
|
||||
- Roman numeral display is capped at 3999 (`clamp(0, 3999)`); 0 displays as `—`.
|
||||
- The overlay animation restarts cleanly via a forced reflow (`void overlay.offsetWidth`) before re-adding `.show`.
|
||||
- UI language is German (`lang="de"`); string literals like `"Tode:"` and `"Gespeichert lokal ✓"` are in German.
|
||||
473
desktop.html
Normal file
473
desktop.html
Normal 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: 1–3999 (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>
|
||||
261
mobile.html
Normal file
261
mobile.html
Normal file
@@ -0,0 +1,261 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<title>Elden Counter – Mobile</title>
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#06060a;
|
||||
--bg1:#0b0b12;
|
||||
--panel: rgba(12, 12, 18, .85);
|
||||
--border: rgba(212, 175, 55, .30);
|
||||
--border2: rgba(212, 175, 55, .15);
|
||||
--text: rgba(245, 238, 210, .92);
|
||||
--muted: rgba(245, 238, 210, .62);
|
||||
--gold: rgba(212, 175, 55, .95);
|
||||
--shadow: 0 26px 80px rgba(0,0,0,.55);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
*{ box-sizing:border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
html, body{
|
||||
margin:0;
|
||||
height:100%;
|
||||
}
|
||||
|
||||
body{
|
||||
min-height:100svh;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:20px;
|
||||
padding:24px 16px;
|
||||
color:var(--text);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
h1{
|
||||
margin:0;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1.4px;
|
||||
font-weight: 800;
|
||||
color: rgba(245,238,210,.70);
|
||||
text-transform: uppercase;
|
||||
font-variant: small-caps;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
.bigBox{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
width:min(360px, 90vw);
|
||||
border:1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 28px 16px 22px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
background:
|
||||
radial-gradient(500px 220px 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), var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.roman{
|
||||
font-size: clamp(52px, 14vw, 80px);
|
||||
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);
|
||||
text-align:center;
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
.arabic{
|
||||
margin-top:8px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing:.2px;
|
||||
}
|
||||
|
||||
.controls{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:14px;
|
||||
width:min(360px, 90vw);
|
||||
}
|
||||
|
||||
.row{
|
||||
display:flex;
|
||||
gap:14px;
|
||||
}
|
||||
|
||||
button{
|
||||
appearance:none;
|
||||
flex:1;
|
||||
border:1px solid var(--border);
|
||||
color: var(--text);
|
||||
background: linear-gradient(180deg, rgba(212,175,55,.16), rgba(0,0,0,.18));
|
||||
border-radius: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
letter-spacing:.4px;
|
||||
cursor:pointer;
|
||||
box-shadow: 0 10px 22px rgba(0,0,0,.35);
|
||||
transition: transform .08s ease, filter .12s ease, border-color .12s ease;
|
||||
font-variant: small-caps;
|
||||
font-family: ui-serif, Georgia, "Times New Roman", Times, serif;
|
||||
padding: 22px 10px;
|
||||
/* Prevent double-tap zoom */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
button:active{
|
||||
transform: scale(0.97);
|
||||
filter: brightness(1.12);
|
||||
border-color: rgba(212,175,55,.55);
|
||||
}
|
||||
|
||||
.btnGhost{
|
||||
border-color: rgba(245,238,210,.18);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(0,0,0,.18));
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
border:1px solid rgba(245,238,210,.14);
|
||||
border-radius:999px;
|
||||
padding:6px 14px;
|
||||
background: rgba(255,255,255,.03);
|
||||
transition: border-color .3s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Deathcounter</h1>
|
||||
|
||||
<section class="bigBox" aria-live="polite">
|
||||
<div class="roman" id="roman">—</div>
|
||||
<div class="arabic" id="arabic">Tode: 0</div>
|
||||
</section>
|
||||
|
||||
<div class="controls">
|
||||
<div class="row">
|
||||
<button id="minus">−1</button>
|
||||
<button id="plus">+1</button>
|
||||
</div>
|
||||
<button class="btnGhost" id="reset">Reset</button>
|
||||
</div>
|
||||
|
||||
<span class="status" id="status">Verbinde…</span>
|
||||
|
||||
<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");
|
||||
|
||||
let currentCount = 0;
|
||||
|
||||
function clamp(n, min, max){ return Math.max(min, Math.min(max, n)); }
|
||||
|
||||
function render(count) {
|
||||
currentCount = clamp(count, 0, 3999);
|
||||
romanEl.textContent = currentCount === 0 ? "—" : toRoman(currentCount);
|
||||
arabicEl.textContent = `Tode: ${currentCount}`;
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
121
obs.html
Normal file
121
obs.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Elden Counter – OBS</title>
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--border: rgba(212, 175, 55, .30);
|
||||
--text: rgba(245, 238, 210, .92);
|
||||
--muted: rgba(245, 238, 210, .62);
|
||||
}
|
||||
|
||||
*{ box-sizing:border-box; }
|
||||
|
||||
html, body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
background:transparent;
|
||||
}
|
||||
|
||||
body{
|
||||
display:inline-flex;
|
||||
font-family: ui-serif, Georgia, "Times New Roman", Times, serif;
|
||||
}
|
||||
|
||||
.bigBox{
|
||||
border:1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 26px 32px 22px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
/* Fixed size so layout never jumps as numbers grow */
|
||||
width: 440px;
|
||||
height: 160px;
|
||||
background:
|
||||
radial-gradient(500px 220px 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);
|
||||
}
|
||||
|
||||
.roman{
|
||||
font-size: 72px;
|
||||
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);
|
||||
text-align:center;
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
.arabic{
|
||||
margin-top:8px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing:.2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section class="bigBox" aria-live="polite">
|
||||
<div class="roman" id="roman">—</div>
|
||||
<div class="arabic" id="arabic">Tode: 0</div>
|
||||
</section>
|
||||
|
||||
<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");
|
||||
|
||||
function clamp(n, min, max){ return Math.max(min, Math.min(max, n)); }
|
||||
|
||||
function render(count) {
|
||||
const n = clamp(count, 0, 3999);
|
||||
romanEl.textContent = n === 0 ? "—" : toRoman(n);
|
||||
arabicEl.textContent = `Tode: ${n}`;
|
||||
}
|
||||
|
||||
let ws;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(`ws://${location.host}/ws`);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (typeof msg.count === "number") render(msg.count);
|
||||
};
|
||||
ws.onclose = () => setTimeout(connect, 2000);
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
84
server.ts
Normal file
84
server.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { join } from "jsr:@std/path";
|
||||
|
||||
const dir = import.meta.dirname!;
|
||||
|
||||
let deaths = 0;
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
// Load persisted count
|
||||
try {
|
||||
const data = JSON.parse(await Deno.readTextFile("counter.json"));
|
||||
deaths = Number(data.count) || 0;
|
||||
} catch {
|
||||
// No file yet — start at 0
|
||||
}
|
||||
|
||||
function broadcast(payload: string) {
|
||||
for (const ws of clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
await Deno.writeTextFile("counter.json", JSON.stringify({ count: deaths }));
|
||||
}
|
||||
|
||||
function handleSocket(ws: WebSocket) {
|
||||
ws.onopen = () => {
|
||||
clients.add(ws);
|
||||
ws.send(JSON.stringify({ count: deaths }));
|
||||
};
|
||||
|
||||
ws.onmessage = async (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
const before = deaths;
|
||||
|
||||
if (msg.action === "increment") deaths = Math.min(3999, deaths + 1);
|
||||
else if (msg.action === "decrement") deaths = Math.max(0, deaths - 1);
|
||||
else if (msg.action === "reset") deaths = 0;
|
||||
else return;
|
||||
|
||||
if (deaths !== before) await persist();
|
||||
broadcast(JSON.stringify({ count: deaths }));
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => clients.delete(ws);
|
||||
ws.onerror = () => clients.delete(ws);
|
||||
}
|
||||
|
||||
async function handler(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
const upgrade = req.headers.get("upgrade") ?? "";
|
||||
if (upgrade.toLowerCase() !== "websocket") {
|
||||
return new Response("WebSocket upgrade required", { status: 426 });
|
||||
}
|
||||
const { socket, response } = Deno.upgradeWebSocket(req);
|
||||
handleSocket(socket);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (url.pathname === "/mobile") {
|
||||
const html = await Deno.readTextFile(join(dir, "mobile.html"));
|
||||
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
|
||||
if (url.pathname === "/obs") {
|
||||
const html = await Deno.readTextFile(join(dir, "obs.html"));
|
||||
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
|
||||
// Default: desktop
|
||||
const html = await Deno.readTextFile(join(dir, "desktop.html"));
|
||||
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
|
||||
console.log("Elden Ring Counter running on http://localhost:8080");
|
||||
console.log("Mobile: http://<your-local-ip>:8080/mobile");
|
||||
console.log("Obs: http://<your-local-ip>:8080/obs");
|
||||
Deno.serve({ port: 8080 }, handler);
|
||||
Reference in New Issue
Block a user