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>
This commit is contained in:
1 parent
3b606e36fb
commit
bc2c8f6cbe
24 files changed
+3221
-8
No files matched your search
@@ -0,0 +1,194 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user