Liegestuetze: Zaehler aus Kanalpunkten, Overlay und Korrektur von Hand
Neue Aktion `liegestuetze` fuer Belohnungen: jede Einloesung zaehlt um `anzahl` hoch (Vorgabe 5). Der Stand liegt in der Datenbank und uebersteht einen Neustart. Browser-Source /liegestuetze zeigt ihn, die Uebersichtsseite hat Knoepfe zum Herunterzaehlen und Setzen. Nebenbei: POST /tts landete bei der Overlay-Seite statt bei der Freigabe. Seiten werden jetzt nur noch bei GET ausgeliefert. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5622499877
commit
eb9eab949e
11 files changed
+469
-9
No files matched your search
@@ -37,6 +37,7 @@ import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||
} from './rewards.ts';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
import { createLiegestuetze } from './liegestuetze.ts';
|
||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||
import { createSpikeDetector } from '../../ingest/src/spike.ts';
|
||||
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
|
||||
@@ -95,6 +96,10 @@ export async function runHub(): Promise<void> {
|
||||
// ---- Kanalpunkte
|
||||
const warteschlange = createWarteschlange();
|
||||
const drossel = createDrossel();
|
||||
const liegestuetze = createLiegestuetze(db);
|
||||
// Gleich zu Beginn in den Bus, sonst zeigt eine frisch verbundene
|
||||
// Browser-Source 0, bis die naechste Einloesung kommt.
|
||||
bus.publish({ kind: 'liegestuetze', offen: liegestuetze.stand() });
|
||||
|
||||
/**
|
||||
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
|
||||
@@ -189,6 +194,11 @@ export async function runHub(): Promise<void> {
|
||||
});
|
||||
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
|
||||
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
|
||||
if (ent.aktion === 'liegestuetze') {
|
||||
const dazu = ent.plan.anzahl ?? 5;
|
||||
const offen = liegestuetze.aendern(dazu);
|
||||
bus.publish({ kind: 'liegestuetze', offen, dazu, name: e.userName });
|
||||
}
|
||||
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
||||
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
|
||||
break;
|
||||
@@ -321,6 +331,7 @@ export async function runHub(): Promise<void> {
|
||||
goLive: announcer.lastResult() ?? 'noch nichts',
|
||||
overlayClients: bus.clients(),
|
||||
ttsWartend: warteschlange.liste().length,
|
||||
liegestuetzeOffen: liegestuetze.stand(),
|
||||
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
|
||||
spikeArmed: spikes.isArmed(),
|
||||
note: state.chatAnonymous
|
||||
@@ -360,6 +371,21 @@ export async function runHub(): Promise<void> {
|
||||
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
||||
},
|
||||
},
|
||||
liegestuetze: {
|
||||
stand: () => liegestuetze.stand(),
|
||||
aendern: (n) => {
|
||||
const offen = liegestuetze.aendern(n);
|
||||
bus.publish({ kind: 'liegestuetze', offen });
|
||||
log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ${offen} offen`);
|
||||
return offen;
|
||||
},
|
||||
setzen: (n) => {
|
||||
const offen = liegestuetze.setzen(n);
|
||||
bus.publish({ kind: 'liegestuetze', offen });
|
||||
log.info(`Liegestuetze von Hand gesetzt -> ${offen} offen`);
|
||||
return offen;
|
||||
},
|
||||
},
|
||||
});
|
||||
void server;
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben. Er
|
||||
// darf weder beim Neustart vergessen noch ins Negative laufen.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { migrate } from '#db/index.ts';
|
||||
import { createLiegestuetze, HOECHSTENS } from './liegestuetze.ts';
|
||||
import { entscheiden, type RewardsConfig } from './rewards.ts';
|
||||
import { zahlAus } from '../../overlay-server/src/server.ts';
|
||||
|
||||
function datei(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-lz-'));
|
||||
return path.join(dir, 't.db');
|
||||
}
|
||||
|
||||
function oeffnen(file: string): DatabaseSync {
|
||||
const db = new DatabaseSync(file);
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
test('Liegestuetze: zaehlt hoch und herunter, nie unter 0', () => {
|
||||
const lz = createLiegestuetze(oeffnen(datei()));
|
||||
assert.equal(lz.stand(), 0);
|
||||
assert.equal(lz.aendern(10), 10);
|
||||
assert.equal(lz.aendern(10), 20);
|
||||
assert.equal(lz.aendern(-5), 15);
|
||||
assert.equal(lz.aendern(-100), 0);
|
||||
assert.equal(lz.setzen(42), 42);
|
||||
assert.equal(lz.setzen(-3), 0);
|
||||
assert.equal(lz.setzen(10_000_000), HOECHSTENS);
|
||||
});
|
||||
|
||||
test('Liegestuetze: der Stand ueberlebt einen Neustart', () => {
|
||||
const file = datei();
|
||||
const erst = oeffnen(file);
|
||||
createLiegestuetze(erst).aendern(30);
|
||||
erst.close();
|
||||
|
||||
assert.equal(createLiegestuetze(oeffnen(file)).stand(), 30);
|
||||
});
|
||||
|
||||
test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => {
|
||||
const cfg: RewardsConfig = {
|
||||
enabled: true,
|
||||
belohnungen: [{ titel: '5 Liegestütze', aktion: 'liegestuetze', anzahl: 5 }],
|
||||
tts: { freigabe: true, maxZeichen: 200, blockliste: [], proPersonProStunde: 3 },
|
||||
};
|
||||
const ent = entscheiden({
|
||||
id: 'r1', rewardId: 'x', rewardTitle: '5 liegestütze', userId: 'u1', userName: 'Kevin',
|
||||
text: '', kosten: 2000, ts: '2026-10-04T20:00:00Z',
|
||||
}, cfg);
|
||||
assert.equal(ent.art, 'ausfuehren');
|
||||
assert.ok(ent.art === 'ausfuehren' && ent.aktion === 'liegestuetze' && ent.plan.anzahl === 5);
|
||||
});
|
||||
|
||||
test('zahlAus: nur ganze Zahlen, alles andere ist null', () => {
|
||||
assert.equal(zahlAus('10'), 10);
|
||||
assert.equal(zahlAus(' -5 '), -5);
|
||||
assert.equal(zahlAus('+3'), 3);
|
||||
for (const x of ['', 'abc', '1.5', '1e3', '9999999', null, undefined]) {
|
||||
assert.equal(zahlAus(x), null, String(x));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Offene Liegestuetze aus Kanalpunkten.
|
||||
//
|
||||
// Eine Einloesung zaehlt hoch, Abdu zaehlt auf der Uebersichtsseite wieder
|
||||
// herunter, wenn er sie gemacht hat - oder korrigiert, wenn etwas nicht
|
||||
// stimmt. Der Stand liegt in der Datenbank: was die Zuschauer bezahlt haben,
|
||||
// darf ein Neustart nicht loeschen.
|
||||
|
||||
import type { Db } from '#db/index.ts';
|
||||
|
||||
/** Mehr glaubt niemand, und ein Tippfehler mit sechs Nullen soll nicht im Bild stehen. */
|
||||
export const HOECHSTENS = 99_999;
|
||||
|
||||
export type Liegestuetze = {
|
||||
stand: () => number;
|
||||
/** Zaehlt um `n` hoch oder (negativ) herunter. Nie unter 0. Gibt den neuen Stand zurueck. */
|
||||
aendern: (n: number) => number;
|
||||
/** Setzt den Stand fest. Gibt den neuen Stand zurueck. */
|
||||
setzen: (n: number) => number;
|
||||
};
|
||||
|
||||
export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze {
|
||||
const lesen = db.prepare('SELECT wert FROM zaehler WHERE name = ?');
|
||||
const schreiben = db.prepare(`
|
||||
INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert
|
||||
`);
|
||||
|
||||
const stand = (): number => (lesen.get(name) as { wert: number } | undefined)?.wert ?? 0;
|
||||
const setzen = (n: number): number => {
|
||||
const wert = Math.min(HOECHSTENS, Math.max(0, Math.trunc(n)));
|
||||
schreiben.run(name, wert, new Date().toISOString());
|
||||
return wert;
|
||||
};
|
||||
|
||||
return { stand, setzen, aendern: (n) => setzen(stand() + n) };
|
||||
}
|
||||
@@ -31,7 +31,8 @@ export type Einloesung = {
|
||||
ts: string;
|
||||
};
|
||||
|
||||
export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
|
||||
export type AktionsArt =
|
||||
| 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
|
||||
|
||||
export type BelohnungsPlan = {
|
||||
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
|
||||
@@ -43,6 +44,8 @@ export type BelohnungsPlan = {
|
||||
freigabe?: boolean;
|
||||
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
|
||||
maxZeichen?: number;
|
||||
/** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */
|
||||
anzahl?: number;
|
||||
};
|
||||
|
||||
export type RewardsConfig = {
|
||||
|
||||
@@ -108,3 +108,11 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
|
||||
assert.equal(n, 1);
|
||||
assert.equal(bus.clients(), 0);
|
||||
});
|
||||
|
||||
test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => {
|
||||
const bus = createBus();
|
||||
assert.equal(schnappschuss(bus).liegestuetze, 0);
|
||||
bus.publish({ kind: 'liegestuetze', offen: 20, dazu: 10, name: 'Kevin' });
|
||||
bus.publish({ kind: 'liegestuetze', offen: 15 });
|
||||
assert.equal(schnappschuss(bus).liegestuetze, 15);
|
||||
});
|
||||
@@ -65,6 +65,17 @@ export type StreamEvent = {
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Neuer Stand der offenen Liegestuetze. `dazu` und `name` nur bei einer
|
||||
* Einloesung - eine Korrektur von Hand soll nicht aussehen wie eine.
|
||||
*/
|
||||
export type LiegestuetzeEvent = {
|
||||
kind: 'liegestuetze';
|
||||
offen: number;
|
||||
dazu?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type OverlayEvent =
|
||||
| ChatEvent
|
||||
| AlertEvent
|
||||
@@ -88,10 +99,15 @@ export type OverlayEvent =
|
||||
}
|
||||
/** Alles sofort still. Fuer den Panikschalter. */
|
||||
| { kind: 'still' }
|
||||
| LiegestuetzeEvent
|
||||
| { kind: 'scene'; scene: string }
|
||||
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||
| { kind: 'beat'; t: number }
|
||||
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
|
||||
| {
|
||||
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
|
||||
/** Offene Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
|
||||
liegestuetze: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
|
||||
@@ -118,6 +134,7 @@ export function createBus(): Bus {
|
||||
let stream: StreamEvent = {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
let liegestuetze = 0;
|
||||
|
||||
return {
|
||||
publish(ev) {
|
||||
@@ -131,6 +148,8 @@ export function createBus(): Bus {
|
||||
if (alerts.length > KEEP_ALERTS) alerts.shift();
|
||||
} else if (ev.kind === 'stream') {
|
||||
stream = ev;
|
||||
} else if (ev.kind === 'liegestuetze') {
|
||||
liegestuetze = ev.offen;
|
||||
} else if (ev.kind === 'clear') {
|
||||
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
|
||||
// naechste Neustart der Browser-Source es zurueck ins Bild.
|
||||
@@ -153,7 +172,7 @@ export function createBus(): Bus {
|
||||
return () => { listeners.delete(fn); };
|
||||
},
|
||||
|
||||
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }),
|
||||
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }),
|
||||
clients: () => listeners.size,
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export const PAGES = [
|
||||
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
|
||||
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
|
||||
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
|
||||
{ path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200,
|
||||
title: 'Liegestütze', hint: 'Offene Liegestütze aus Kanalpunkten. Zählt bei Einlösung hoch, korrigieren unten auf dieser Seite.' },
|
||||
] as const;
|
||||
|
||||
export type OverlayServer = {
|
||||
@@ -96,6 +98,12 @@ export type OverlayOptions = {
|
||||
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
|
||||
audio: (id: string) => Buffer | undefined;
|
||||
};
|
||||
/** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */
|
||||
liegestuetze?: {
|
||||
stand: () => number;
|
||||
aendern: (n: number) => number;
|
||||
setzen: (n: number) => number;
|
||||
};
|
||||
};
|
||||
|
||||
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
@@ -167,7 +175,11 @@ async function handle(
|
||||
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||
|
||||
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
||||
// Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
|
||||
// Uebersicht (POST, mit Anmeldung). Ohne die Einschraenkung bekamen die
|
||||
// statt einer Antwort die Overlay-Seite - oder ohne Schluessel ein 403.
|
||||
const page = req.method === 'GET' || req.method === 'HEAD'
|
||||
? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
|
||||
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
|
||||
if (page || p === '/events' || audioId) {
|
||||
if (!zugang.darfOverlay(req, url)) {
|
||||
@@ -276,6 +288,27 @@ async function handle(
|
||||
JSON.stringify({ wartend: opts.tts.wartend() }));
|
||||
}
|
||||
|
||||
// ---- Liegestuetze von Hand korrigieren
|
||||
if (p === '/liegestuetze' && req.method === 'POST' && opts.liegestuetze) {
|
||||
const form = await readForm(req);
|
||||
const was = form.get('was') ?? '';
|
||||
const n = zahlAus(form.get('wert'));
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, offen: opts.liegestuetze!.stand() }));
|
||||
|
||||
if (n === null) return antwort('Keine ganze Zahl.', 400);
|
||||
if (was === 'aendern') {
|
||||
const offen = opts.liegestuetze.aendern(n);
|
||||
return antwort(`${n > 0 ? '+' : ''}${n}, jetzt ${offen} offen.`);
|
||||
}
|
||||
if (was === 'setzen') {
|
||||
if (n < 0) return antwort('Weniger als 0 geht nicht.', 400);
|
||||
return antwort(`Auf ${opts.liegestuetze.setzen(n)} gesetzt.`);
|
||||
}
|
||||
return antwort('Unbekannt.', 400);
|
||||
}
|
||||
|
||||
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerOverlayKey();
|
||||
return redirect(res, '/');
|
||||
@@ -351,6 +384,13 @@ function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Eingabe von der Seite in eine ganze Zahl. Alles andere ist `null`. */
|
||||
export function zahlAus(roh: string | null | undefined): number | null {
|
||||
if (roh === null || roh === undefined) return null;
|
||||
const s = roh.trim();
|
||||
return /^[+-]?\d{1,6}$/.test(s) ? Number(s) : null;
|
||||
}
|
||||
|
||||
function redirect(res: http.ServerResponse, to: string): void {
|
||||
res.writeHead(303, { location: to });
|
||||
res.end();
|
||||
@@ -427,6 +467,11 @@ const SEITEN_CSS = `
|
||||
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
|
||||
.wartend:first-child { border-top: 0; }
|
||||
.wartend .hint { max-width: 60ch; word-break: break-word; }
|
||||
.lz-stand { font: 700 3rem/1 "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums; margin: .4rem 0 1rem; }
|
||||
.lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin-left: .5rem; }
|
||||
button.lz { margin-left: 0; min-width: 3.2rem; }
|
||||
input.lz { width: 6.5rem; padding: .2rem .6rem; font-size: .85rem; }
|
||||
button.tts { margin-left: .4rem; }
|
||||
`;
|
||||
|
||||
@@ -466,6 +511,25 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
|
||||
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
|
||||
</div>
|
||||
<p id="tts-meldung" class="meldung"> </p>` : '';
|
||||
const lzFeld = opts.liegestuetze ? `
|
||||
<h2>Liegestütze</h2>
|
||||
<p class="lead">Jede Einlösung zählt hoch. Hier herunterzählen, wenn sie gemacht
|
||||
sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.</p>
|
||||
<div class="lz-stand"><span id="lz-offen">${opts.liegestuetze.stand()}</span><small>offen</small></div>
|
||||
<div class="gruppe">
|
||||
<button class="lz" data-um="-10">−10</button>
|
||||
<button class="lz" data-um="-5">−5</button>
|
||||
<button class="lz" data-um="-1">−1</button>
|
||||
<button class="lz" data-um="1">+1</button>
|
||||
<button class="lz" data-um="5">+5</button>
|
||||
<button class="lz" data-um="10">+10</button>
|
||||
</div>
|
||||
<form class="gruppe" id="lz-form">
|
||||
<input class="lz" id="lz-wert" type="number" min="0" step="1" inputmode="numeric" placeholder="Anzahl">
|
||||
<button type="submit" class="lz">setzen</button>
|
||||
<button type="button" class="lz" id="lz-null">auf 0</button>
|
||||
</form>
|
||||
<p id="lz-meldung" class="meldung"> </p>` : '';
|
||||
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
|
||||
const rows = PAGES.map((p) => {
|
||||
const id = `u${p.path.slice(1)}`;
|
||||
@@ -524,6 +588,8 @@ ${testfeld}
|
||||
|
||||
${ttsFeld}
|
||||
|
||||
${lzFeld}
|
||||
|
||||
<h2>Schlüssel</h2>
|
||||
<form method="post" action="/schluessel/neu">
|
||||
<button type="submit">Schlüssel neu würfeln</button>
|
||||
@@ -565,11 +631,48 @@ ${ttsFeld}
|
||||
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
|
||||
: 'verbunden - kein Stream';
|
||||
setzeLive(ev.stream.live);
|
||||
zeigeLiegestuetze(ev.liegestuetze);
|
||||
} else if (ev.kind === 'stream') {
|
||||
setzeLive(ev.live);
|
||||
} else if (ev.kind === 'liegestuetze') {
|
||||
zeigeLiegestuetze(ev.offen);
|
||||
}
|
||||
};
|
||||
|
||||
// Der Stand kommt über den Ereignisstrom, damit eine Einlösung auch hier
|
||||
// sofort sichtbar ist und zwei offene Fenster nicht auseinanderlaufen.
|
||||
function zeigeLiegestuetze(n) {
|
||||
const el = document.getElementById('lz-offen');
|
||||
if (el && typeof n === 'number') el.textContent = n.toLocaleString('de-DE');
|
||||
}
|
||||
const lzMeldung = document.getElementById('lz-meldung');
|
||||
async function lzRuf(was, wert) {
|
||||
const res = await fetch('/liegestuetze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ was, wert: String(wert) }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
|
||||
zeigeLiegestuetze(body.offen);
|
||||
lzMeldung.textContent = body.text || '';
|
||||
lzMeldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
|
||||
setTimeout(() => { lzMeldung.innerHTML = ' '; lzMeldung.className = 'meldung'; }, 4000);
|
||||
}
|
||||
for (const b of document.querySelectorAll('button.lz[data-um]')) {
|
||||
b.addEventListener('click', () => lzRuf('aendern', b.dataset.um));
|
||||
}
|
||||
const lzForm = document.getElementById('lz-form');
|
||||
if (lzForm) {
|
||||
lzForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const feld = document.getElementById('lz-wert');
|
||||
if (feld.value === '') return;
|
||||
lzRuf('setzen', feld.value);
|
||||
feld.value = '';
|
||||
});
|
||||
document.getElementById('lz-null').addEventListener('click', () => lzRuf('setzen', 0));
|
||||
}
|
||||
|
||||
let sagAb;
|
||||
async function loese(art) {
|
||||
const res = await fetch('/test', {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Liegestütze</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Offene Liegestütze aus Kanalpunkten.
|
||||
*
|
||||
* Eine Einlösung zählt hoch und zeigt kurz, wer es war. Wenn Abdu auf der
|
||||
* Übersichtsseite herunterzählt, blinkt die Zahl grün - ohne Namen, damit
|
||||
* eine Korrektur nicht wie eine Einlösung aussieht.
|
||||
*
|
||||
* In OBS: Browser-Quelle 420x200. Einstellungen in der Adresse:
|
||||
* ?verstecken=1 bei 0 ausblenden statt "erledigt" zeigen
|
||||
* ?titel=... andere Beschriftung als "Liegestütze" */
|
||||
#karte {
|
||||
position: absolute;
|
||||
left: 12px; bottom: 12px;
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 16px 26px 16px 18px;
|
||||
overflow: hidden;
|
||||
transition: opacity .5s, transform .5s, box-shadow .4s, border-color .4s;
|
||||
}
|
||||
#karte.weg { opacity: 0; transform: translateY(14px); }
|
||||
|
||||
.symbol {
|
||||
width: 58px; height: 58px; flex: none;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--lila), var(--pink));
|
||||
box-shadow: 0 6px 18px rgba(169, 112, 255, .45);
|
||||
}
|
||||
.symbol svg { width: 36px; height: 36px; }
|
||||
|
||||
.text { min-width: 0; }
|
||||
#titel {
|
||||
font: 600 13px/1 var(--font-zahl);
|
||||
letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
#zahl {
|
||||
display: block;
|
||||
font: 800 54px/1 var(--font);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -.02em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
#zahl.erledigt { font-size: 30px; color: var(--gruen); padding: 8px 0 6px; }
|
||||
|
||||
/* Einlösung: Karte glüht gold, Zahl springt. */
|
||||
#karte.mehr { border-color: var(--gold); box-shadow: var(--schatten), 0 0 28px rgba(255, 197, 61, .55); }
|
||||
#zahl.springen { animation: springen .6s cubic-bezier(.3, 1.6, .5, 1); }
|
||||
@keyframes springen {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.22); color: var(--gold); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Korrektur nach unten: kurz grün, das heißt "gemacht". */
|
||||
#zahl.weniger { animation: weniger .9s ease-out; }
|
||||
@keyframes weniger {
|
||||
0% { color: var(--gruen); transform: translateY(-4px); }
|
||||
100% { color: inherit; transform: none; }
|
||||
}
|
||||
|
||||
/* "+5 von Name", steigt über der Karte auf. */
|
||||
#plus {
|
||||
position: absolute;
|
||||
left: 12px; bottom: 112px;
|
||||
max-width: 396px;
|
||||
padding: 8px 14px;
|
||||
font: 700 20px/1.1 var(--font);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
}
|
||||
#plus b { color: var(--gold); margin-right: 6px; }
|
||||
#plus.zeigen { animation: aufsteigen 3.6s ease-out both; }
|
||||
@keyframes aufsteigen {
|
||||
0% { opacity: 0; transform: translateY(16px); }
|
||||
12% { opacity: 1; transform: none; }
|
||||
80% { opacity: 1; transform: none; }
|
||||
100% { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="plus" class="glas"></div>
|
||||
<div id="karte" class="glas">
|
||||
<div class="symbol" aria-hidden="true">
|
||||
<!-- Figur im Liegestütz: Kopf, Rücken, Arme, Boden. -->
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="#fff" stroke-width="3.4"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="17" r="4" fill="#fff" stroke="none"/>
|
||||
<path d="M15 21 L41 29"/>
|
||||
<path d="M17 22 L15 33"/>
|
||||
<path d="M41 29 L42 33"/>
|
||||
<path d="M6 37 H44" stroke-width="2.4" opacity=".7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text">
|
||||
<div id="titel" class="kontur">Liegestütze</div>
|
||||
<span id="zahl" class="kontur">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var karte = document.getElementById('karte');
|
||||
var zahl = document.getElementById('zahl');
|
||||
var plus = document.getElementById('plus');
|
||||
var verstecken = Overlay.param('verstecken', false);
|
||||
document.getElementById('titel').textContent = Overlay.param('titel', 'Liegestütze');
|
||||
|
||||
var angezeigt = null; // was gerade im Bild steht
|
||||
var lauf = null; // laufendes Hochzählen
|
||||
var glimmen = null;
|
||||
|
||||
function schreibe(n) {
|
||||
if (n === 0 && !verstecken) {
|
||||
zahl.textContent = 'erledigt';
|
||||
zahl.classList.add('erledigt');
|
||||
} else {
|
||||
zahl.textContent = Overlay.zahl(n);
|
||||
zahl.classList.remove('erledigt');
|
||||
}
|
||||
karte.classList.toggle('weg', verstecken && n === 0);
|
||||
}
|
||||
|
||||
function nochmal(el, klasse) {
|
||||
el.classList.remove(klasse);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(klasse);
|
||||
}
|
||||
|
||||
/** Zählt sichtbar von der alten zur neuen Zahl hoch. Bei großen Sprüngen
|
||||
* nicht jede einzelne Zahl, sonst dauert ein Setzen auf 500 ewig. */
|
||||
function hochzaehlen(von, bis) {
|
||||
clearInterval(lauf);
|
||||
var schritte = Math.min(bis - von, 20);
|
||||
var i = 0;
|
||||
lauf = setInterval(function () {
|
||||
i++;
|
||||
schreibe(i >= schritte ? bis : Math.round(von + (bis - von) * i / schritte));
|
||||
if (i >= schritte) clearInterval(lauf);
|
||||
}, 45);
|
||||
}
|
||||
|
||||
function stand(n, einloesung) {
|
||||
if (typeof n !== 'number') return;
|
||||
var alt = angezeigt;
|
||||
angezeigt = n;
|
||||
|
||||
// Erster Stand nach dem Verbinden: einfach hinschreiben, ohne Effekt.
|
||||
if (alt === null || alt === n) { clearInterval(lauf); schreibe(n); return; }
|
||||
|
||||
if (n > alt) {
|
||||
hochzaehlen(alt, n);
|
||||
nochmal(zahl, 'springen');
|
||||
karte.classList.add('mehr');
|
||||
clearTimeout(glimmen);
|
||||
glimmen = setTimeout(function () { karte.classList.remove('mehr'); }, 2500);
|
||||
} else {
|
||||
clearInterval(lauf);
|
||||
schreibe(n);
|
||||
nochmal(zahl, 'weniger');
|
||||
}
|
||||
|
||||
if (einloesung && einloesung.name) {
|
||||
plus.innerHTML = '<b>+' + Overlay.esc(einloesung.dazu) + '</b>' +
|
||||
'von ' + Overlay.esc(einloesung.name);
|
||||
nochmal(plus, 'zeigen');
|
||||
}
|
||||
}
|
||||
|
||||
Overlay.on('state', function (ev) { stand(ev.liegestuetze); });
|
||||
Overlay.on('liegestuetze', function (ev) {
|
||||
stand(ev.offen, ev.dazu ? { dazu: ev.dazu, name: ev.name } : null);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user