diff --git a/src/channels/dvd/gdq.svg b/src/channels/dvd/gdq.svg new file mode 100644 index 0000000..db10ebe --- /dev/null +++ b/src/channels/dvd/gdq.svg @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/src/channels/dvd/index.tsx b/src/channels/dvd/index.tsx new file mode 100644 index 0000000..b5c287b --- /dev/null +++ b/src/channels/dvd/index.tsx @@ -0,0 +1,206 @@ +/* eslint-disable react-refresh/only-export-components */ +import type { FormattedDonation, Total } from '@gdq/types/tracker'; +import { ChannelProps, registerChannel } from '../channels'; + +import { useListenFor, useReplicant } from 'use-nodecg'; +import styled from '@emotion/styled'; +import TweenNumber from '@gdq/lib/components/TweenNumber'; +import { usePIXICanvas } from '@gdq/lib/hooks/usePIXICanvas'; +import { useEffect, useRef } from 'react'; +import { CRTFilter } from '@pixi/filter-crt'; +import * as PIXI from 'pixi.js'; +import logoFile from './gdq.svg'; + +registerChannel('DVD', 42, DVDChannel, { + position: 'bottomLeft', + site: 'Twitch', + handle: 'squiddotmid', +}); + +type DVDLogo = { + sprite: PIXI.Sprite; + xVel: number; + yVel: number; + rainbow: boolean; + hue: number; +}; + +function DVDChannel(props: ChannelProps) { + const [total] = useReplicant('total', null); + const logos = useRef([]); + const logoContainer = useRef(null); + const containerRef = useRef(null); + const crtFilter = useRef(null); + const backdrop = useRef(null); + + //const confettiRef = useRef(); + + const logoSpeed = 2; + + const windowWidth = 1092; + const windowHeight = 332; + + function spawnLogo() { + if (!logoContainer.current) return; + + const newLogo = PIXI.Sprite.from(logoFile); + + // place logo at a random position within the middle of the window + newLogo.x = (Math.random() * windowWidth) / 2 + windowWidth / 4; + newLogo.y = (Math.random() * windowHeight) / 2 + windowHeight / 4; + + const logoData = { + sprite: newLogo, + xVel: logoSpeed * (Math.random() < 0.5 ? -1 : 1), + yVel: logoSpeed * (Math.random() < 0.5 ? -1 : 1), + rainbow: false, + hue: 0, + }; + + newLogo.tint = randomColor(); + logos.current.push(logoData); + + logoContainer.current.addChild(newLogo); + } + + useListenFor('donation', (donation: FormattedDonation) => { + /** + * Respond to a donation. + */ + spawnLogo(); + }); + + const [app, canvasRef] = usePIXICanvas({ width: windowWidth, height: windowHeight }, () => { + if (!logoContainer.current) return null; + + logos.current.forEach((logo) => { + logo.sprite.x += logo.xVel; + logo.sprite.y += logo.yVel; + let bounces = 0; + + if (logo.sprite.x <= 0 || logo.sprite.x >= windowWidth - logo.sprite.width) { + logo.xVel *= -1; + bounces += 1; + } + if (logo.sprite.y <= 0 || logo.sprite.y >= windowHeight - logo.sprite.height) { + logo.yVel *= -1; + bounces += 1; + } + + if (bounces == 1) { + logo.sprite.tint = randomColor(); + } + if (bounces == 2) { + logo.rainbow = true; + } + + if (logo.rainbow) { + logo.hue = (logo.hue + 2) % 360; + const color = HSVtoRGB(logo.hue / 360, 1, 1); + logo.sprite.tint = color; + } + }); + }); + + useEffect(() => { + if (!app.current || crtFilter.current) return; + + crtFilter.current = new CRTFilter({ + vignetting: 0.2, + time: 0, + lineWidth: 1, + lineContrast: 0.3, + noise: 0.1, + }); + + app.current.stage.filters = [crtFilter.current]; + + backdrop.current = new PIXI.Graphics(); + + backdrop.current.beginFill(0x222222); + backdrop.current.drawRect(0, 0, windowWidth, windowHeight); + app.current.stage.addChild(backdrop.current); + + logoContainer.current = new PIXI.Container(); + + app.current.stage.addChild(logoContainer.current); + + spawnLogo(); + }, [app, crtFilter]); + + function randomColor() { + return HSVtoRGB(Math.random(), 0.8, 0.8); + } + + // Adapted from https://axonflux.com/handy-rgb-to-hsl-and-rgb-to-hsv-color-model-c + function HSVtoRGB(h: number, s: number, v: number) { + let r = 0; + let g = 0; + let b = 0; + + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + switch (i % 6) { + case 0: + (r = v), (g = t), (b = p); + break; + case 1: + (r = q), (g = v), (b = p); + break; + case 2: + (r = p), (g = v), (b = t); + break; + case 3: + (r = p), (g = q), (b = v); + break; + case 4: + (r = t), (g = p), (b = v); + break; + case 5: + (r = v), (g = p), (b = q); + break; + } + return (Math.round(r * 255) << 16) + (Math.round(g * 255) << 8) + Math.round(b * 255); + } + + return ( + + + + $ + + + ); +} + +const Container = styled.div` + position: absolute; + width: 100%; + height: 100%; + padding: 0; + margin: 0; +`; + +const TotalEl = styled.div` + font-family: gdqpixel; + font-size: 46px; + color: white; + + position: absolute; + + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + -webkit-text-stroke-width: 2px; + -webkit-text-stroke-color: black; +`; + +const Canvas = styled.canvas` + position: absolute; + width: 100% !important; + height: 100% !important; +`; diff --git a/src/channels/index.ts b/src/channels/index.ts index 19cb3bb..79107ca 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -17,5 +17,6 @@ import './ff-shop'; import './papers-please'; import './minesweeper'; import './qwop'; +import './dvd'; export * from './channels';