Files
abdulive/apps/overlay-web/public/effects.html
T
ZeasyAlexandClaude Opus 5 bc2c8f6cbe Overlay: fuenf Browser-Sources fuer OBS
Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein
Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein
Ding, das mitten im Stream kaputtgehen kann.

Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md:
Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource`
verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert
nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge:
der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein
SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar),
und ein Zustands-Schnappschuss beim Verbinden.

Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT
herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur
ueber EventSub.

GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten.

  gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif
  PRIVMSG #abdulive_ :[Idk GIF by Respective]

Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn.
Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]"
im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094
Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse
eben nicht im Text steht.

Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind:

- Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der
  Ausblend-Animation aus dem DOM verschwinden, und lief in eine
  Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag
  eingefroren.
- Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim
  Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe
  gueltig aus und wuerde den falschen Textteil ersetzen.

Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und
jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur
pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist
waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht,
sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus.

build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket
404 statt Overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:28 +02:00

195 lines
6.1 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Effekte</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Ein Canvas fuer alles. Hunderte DOM-Knoten, die sich pro Bild bewegen,
* kosten in OBS spuerbar Leistung - und Leistung, die hier draufgeht, fehlt
* beim Encoder. */
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
/* Der kurze Lichtschein bei einer Chat-Spitze. Liegt hinter den Teilchen und
* ist das, was den Effekt "teuer" wirken laesst. */
#schein {
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0;
background: radial-gradient(ellipse at 50% 60%,
rgba(169, 112, 255, .32), transparent 62%);
}
#schein.an { animation: blitz 1.6s ease-out both; }
@keyframes blitz {
0% { opacity: 0; }
12% { opacity: 1; }
100% { opacity: 0; }
}
</style>
</head>
<body>
<div id="schein"></div>
<canvas id="bild"></canvas>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var canvas = document.getElementById('bild');
var ctx = canvas.getContext('2d');
var schein = document.getElementById('schein');
var teilchen = [];
var bilder = {}; // Emote-ID -> Image
var letzteEmotes = []; // was gerade im Chat benutzt wird
var laeuft = false;
/** Obergrenze. Ein Raid mit 200 Leuten darf den Encoder nicht umbringen. */
var MAX = Overlay.param('max', 260);
function passeAn() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
passeAn();
window.addEventListener('resize', passeAn);
function bild(id) {
if (bilder[id]) return bilder[id];
var img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
encodeURIComponent(id) + '/default/dark/3.0';
bilder[id] = img;
return img;
}
/** Emote-Regen: faellt von oben, dreht sich leicht, verblasst unten. */
function regen(anzahl) {
var quellen = letzteEmotes.slice(-12);
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
var e = quellen.length ? quellen[(Math.random() * quellen.length) | 0] : null;
teilchen.push({
art: e ? 'emote' : 'punkt',
img: e ? bild(e) : null,
farbe: farbe(),
x: Math.random() * canvas.width,
y: -60 - Math.random() * canvas.height * 0.5,
vx: (Math.random() - 0.5) * 0.8,
vy: 2.2 + Math.random() * 3.4,
g: 0.02,
dreh: (Math.random() - 0.5) * 0.06,
winkel: (Math.random() - 0.5) * 0.7,
groesse: 34 + Math.random() * 30,
leben: 1,
zerfall: 0.0016 + Math.random() * 0.0016,
});
}
starte();
}
var PALETTE = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d', '#4ade80'];
function farbe() { return PALETTE[(Math.random() * PALETTE.length) | 0]; }
/** Konfetti: schiesst aus der unteren Mitte nach oben und faellt zurueck. */
function konfetti(anzahl) {
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
var winkel = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
var tempo = 9 + Math.random() * 11;
teilchen.push({
art: 'schnipsel',
farbe: farbe(),
x: canvas.width / 2 + (Math.random() - 0.5) * 340,
y: canvas.height + 14,
vx: Math.cos(winkel) * tempo,
vy: Math.sin(winkel) * tempo,
g: 0.28,
dreh: (Math.random() - 0.5) * 0.4,
winkel: Math.random() * Math.PI,
groesse: 9 + Math.random() * 9,
leben: 1,
zerfall: 0.0055 + Math.random() * 0.004,
});
}
starte();
}
function starte() {
if (laeuft) return;
laeuft = true;
requestAnimationFrame(schritt);
}
function schritt() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (var i = teilchen.length - 1; i >= 0; i--) {
var t = teilchen[i];
t.vy += t.g;
t.x += t.vx;
t.y += t.vy;
t.winkel += t.dreh;
t.leben -= t.zerfall;
if (t.leben <= 0 || t.y > canvas.height + 120) { teilchen.splice(i, 1); continue; }
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, t.leben));
ctx.translate(t.x, t.y);
ctx.rotate(t.winkel);
if (t.art === 'emote' && t.img && t.img.complete && t.img.naturalWidth) {
ctx.drawImage(t.img, -t.groesse / 2, -t.groesse / 2, t.groesse, t.groesse);
} else if (t.art === 'schnipsel') {
ctx.fillStyle = t.farbe;
// Schmales Rechteck statt Quadrat: sieht in Drehung nach Papier aus.
ctx.fillRect(-t.groesse / 2, -t.groesse / 4, t.groesse, t.groesse / 2);
} else {
ctx.fillStyle = t.farbe;
ctx.beginPath();
ctx.arc(0, 0, t.groesse / 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (teilchen.length) requestAnimationFrame(schritt);
else { laeuft = false; ctx.clearRect(0, 0, canvas.width, canvas.height); }
}
// ---- Ausloeser
Overlay.on('chat', function (ev) {
for (var i = 0; i < ev.emotes.length; i++) {
if (letzteEmotes.indexOf(ev.emotes[i].id) === -1) letzteEmotes.push(ev.emotes[i].id);
}
if (letzteEmotes.length > 24) letzteEmotes.splice(0, letzteEmotes.length - 24);
});
Overlay.on('hype', function (ev) {
schein.classList.remove('an');
void schein.offsetWidth;
schein.classList.add('an');
// Je hoeher die Punktzahl, desto mehr - aber gedeckelt, sonst steht bei
// einem echten Ausbruch das Bild voll und man sieht das Spiel nicht mehr.
regen(Math.min(110, 34 + Math.round((ev.score || 1) * 9)));
});
// Konfetti als eigenes Ereignis: welche Belohnung Konfetti ausloest,
// entscheidet der Hub - diese Seite muss davon nichts wissen.
Overlay.on('konfetti', function (ev) {
konfetti(Math.round(90 * (ev.staerke || 1)));
});
Overlay.on('alert', function (ev) {
if (ev.type === 'raid') konfetti(Math.min(180, 70 + (ev.amount || 0) * 2));
else if (ev.type === 'massgift') konfetti(Math.min(180, 60 + (ev.amount || 0) * 10));
else if (ev.type === 'sub' || ev.type === 'resub' || ev.type === 'subgift') konfetti(80);
else if (ev.type === 'cheer' && (ev.amount || 0) >= 100) konfetti(60);
});
})();
</script>
</body>
</html>