diff --git a/src/channels/index.ts b/src/channels/index.ts index 449c66e..c344f28 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -17,6 +17,7 @@ import './ff-shop'; import './papers-please'; import './minesweeper'; import './qwop'; +import './laser-sunset'; import './here-comes-niko'; export * from './channels'; diff --git a/src/channels/laser-sunset/config.ts b/src/channels/laser-sunset/config.ts new file mode 100644 index 0000000..49c6e43 --- /dev/null +++ b/src/channels/laser-sunset/config.ts @@ -0,0 +1,55 @@ +/** + * @file Configuration values. Many of the settings are now balanced to look good enough and will + * break if put to extremes. Handle with care. + */ + +const CONFIG = { + Donations: { + despawnMs: 4000, + countMax: 6, + xMarginPercent: 15, + yMinPercent: 12, + yMaxPercent: 25, + reflectionOpacity: 0.3, + fadeAt: 0.2, + hoverTimeMs: 1500, + hoverOffset: 1, + colors: ['mediumspringgreen', 'deepskyblue', 'cyan'], + }, + Subscriptions: { + despawnMs: 8000, + fontSizeMin: 25, + fontSizeMax: 60, + lockTimeMs: 600, + colors: ['Lavender', 'LightSkyBlue', 'Aquamarine', 'AliceBlue', 'HoneyDew'], + }, + Stars: { + countX: 10, + countY: 7, + brightnessMin: 120, + brightnessMax: 240, + opacityMin: 0.3, + opacityMax: 1.0, + twinkleMs: 600, + }, + Lasers: { + bgXstart: 67, + bgXmin: -53, + scrollSpeed: 0.5, + }, + Cloud: { + despawnMs: 5000, + xMargin: 5, + sizeMinPercent: 2, + sizeMaxPercent: 12, + sizeDonationMin: 5, + sizeDonationMax: 1000, + perspectiveGrowth: 2.4, + colors: ['magenta', 'orchid', 'blueviolet', 'mediumpurple'], + }, + Timers: { + fpsInterval: 1000 / 60, + }, +}; + +export default CONFIG; diff --git a/src/channels/laser-sunset/functions.ts b/src/channels/laser-sunset/functions.ts new file mode 100644 index 0000000..6088ef0 --- /dev/null +++ b/src/channels/laser-sunset/functions.ts @@ -0,0 +1,222 @@ +import type { FormattedDonation, TwitchSubscription } from '@gdq/types/tracker'; +import { StarVisual, Overcast, DonationPopup, SubscriptionVisual } from './types'; +import CONFIG from './config'; + +export const formatCurrency = (val: number) => { + return val.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }); +}; + +export const easeIn = (number: number, total: number, scaleMin = 1.0, scaleMax = 1.0) => { + return scaleMin + (number / total) * (scaleMax - scaleMin); +}; + +export const easeOut = (number: number, total: number, scaleMin = 1.0, scaleMax = 1.0) => { + return scaleMin + (scaleMax - scaleMin) * easeIn(number, total, 0.0, 1.0); +}; + +export const randomRange = (min: number, max: number) => { + return min + Math.random() * (max - min); +}; + +export const randomStarOpacity = () => { + return randomRange(CONFIG.Stars.opacityMin, CONFIG.Stars.opacityMax); +}; + +export const spawnStar = (number: number, xMin: number, xMax: number, yMin: number, yMax: number): StarVisual => { + const clr = randomRange(CONFIG.Stars.brightnessMin, CONFIG.Stars.brightnessMax); + const opacity = randomStarOpacity(); + + return { + left: randomRange(xMin, xMax), + top: randomRange(yMin, yMax), + text: number % 2 == 0 ? '+' : '*', + color: 'RGB(' + clr + ',' + clr + ',' + clr + ')', + opacity: opacity, + }; +}; + +export const starStyle = (star: StarVisual) => { + return { + left: star.left + '%', + top: star.top + '%', + color: star.color, + opacity: star.opacity, + }; +}; + +export const spawnDonation = (baseProps: FormattedDonation, count: number): DonationPopup => { + const range = 100 - CONFIG.Donations.xMarginPercent; + const left = + CONFIG.Donations.xMarginPercent + ((count % CONFIG.Donations.countMax) / CONFIG.Donations.countMax) * range; + + return { + ...baseProps, + renderedAmount: formatCurrency(baseProps.rawAmount), + left: left, + top: randomRange(CONFIG.Donations.yMinPercent, CONFIG.Donations.yMaxPercent), + color: CONFIG.Donations.colors[count % CONFIG.Donations.colors.length], + received: new Date(), + }; +}; + +export const spawnCloud = (donationAmount: number, count: number): Overcast => { + const clamped = Math.min(Math.max(donationAmount, CONFIG.Cloud.sizeDonationMin), CONFIG.Cloud.sizeDonationMax); + const amountRatio = (clamped - CONFIG.Cloud.sizeDonationMin) / CONFIG.Cloud.sizeDonationMax; + const sizeMin = CONFIG.Cloud.sizeMinPercent; + const sizeMax = CONFIG.Cloud.sizeMaxPercent; + const sideLength = sizeMin + amountRatio * (sizeMax - sizeMin); + + return { + left: CONFIG.Cloud.xMargin + Math.random() * (100 - CONFIG.Cloud.xMargin * 2), + width: sideLength, + height: sideLength * 1.5, + backgroundColor: CONFIG.Cloud.colors[count % CONFIG.Cloud.colors.length], + received: new Date(), + }; +}; + +export const cloudScreenspaceProps = (cloud: Overcast) => { + const now = new Date(); + const timeVisible = Math.min(now.getTime() - cloud.received.getTime(), CONFIG.Cloud.despawnMs); + const ageRatio = timeVisible / CONFIG.Cloud.despawnMs; + const visibilityWindow = 100 + CONFIG.Cloud.sizeMaxPercent; + const age = ageRatio * visibilityWindow; + + return { + left: cloud.left, + bottom: age, + width: cloud.width + cloud.width * ageRatio * CONFIG.Cloud.perspectiveGrowth, + height: cloud.height, + backgroundColor: cloud.backgroundColor, + now: now, + age: age, + rotate: age * 8, + }; +}; + +export const cloudStyle = (cloud: Overcast) => { + const csp = cloudScreenspaceProps(cloud); + + return { + left: csp.left + '%', + bottom: csp.bottom + '%', + width: csp.width + '%', + height: csp.height + '%', + backgroundColor: csp.backgroundColor, + rotate: 'x -' + csp.rotate + 'deg', + }; +}; + +export const donationHovering = (age: number) => { + const maxTime = CONFIG.Donations.hoverTimeMs; + const relAge = age % maxTime; + const offset = CONFIG.Donations.hoverOffset; + return Math.cos((relAge / maxTime) * Math.PI * 2) * offset; +}; + +export const cloudReflectionStyle = (cloud: Overcast) => { + const csp = cloudScreenspaceProps(cloud); + + return { + left: csp.left + '%', + top: csp.bottom + '%', + width: csp.width + '%', + height: csp.height + '%', + backgroundColor: csp.backgroundColor, + rotate: 'x ' + csp.rotate + 'deg', + }; +}; + +export const donationScreenspaceProps = (donation: DonationPopup) => { + const now = new Date(); + const age = now.getTime() - donation.received.getTime(); + const progress = Math.min(1.0, age / CONFIG.Donations.despawnMs); + const fadeAt = CONFIG.Donations.fadeAt; + const fadeOutAt = 1.0 - fadeAt; + + let opacity = 1.0; + if (progress < fadeAt) { + opacity = progress / fadeAt; + } else if (progress > fadeOutAt) { + opacity = 1 - (progress - fadeOutAt) / fadeAt; + } + + return { + x: donation.left, + y: donation.top + donationHovering(age), + now: now, + age: age, + color: donation.color, + opacity: opacity, + }; +}; + +export const donationStyle = (donation: DonationPopup) => { + const dsp = donationScreenspaceProps(donation); + return { left: dsp.x + '%', top: dsp.y + '%', color: dsp.color, opacity: dsp.opacity }; +}; + +export const donationReflectionStyle = (donation: DonationPopup) => { + const dsp = donationScreenspaceProps(donation); + const pushDown = 10; + + return { + ...donation, + left: dsp.x + '%', + top: 50 - dsp.y + pushDown + '%', + opacity: dsp.opacity * CONFIG.Donations.reflectionOpacity, + }; +}; + +export const spawnSubscription = (sub: TwitchSubscription, count: number): SubscriptionVisual => { + const angleDegs = count % 2 ? (count % 36) * 10 : ((count + 18) % 36) * 10; + const angleRads = (angleDegs / 360) * Math.PI * 2.0; + + return { + angle: angleRads, + radius: 2 + Math.random() * 5, + color: CONFIG.Subscriptions.colors[count % CONFIG.Subscriptions.colors.length], + received: new Date(), + }; +}; + +export const subscriptionScreenspaceProps = (sub: SubscriptionVisual) => { + const now = new Date(); + const age = now.getTime() - sub.received.getTime(); + const radius = sub.radius + age / 50.0; + + return { + x: 50 + Math.cos(sub.angle) * radius, + y: 45 + Math.sin(sub.angle) * radius, + now: now, + age: age, + radius: radius, + color: sub.color, + fontSize: easeIn( + age, + CONFIG.Subscriptions.despawnMs, + CONFIG.Subscriptions.fontSizeMin, + CONFIG.Subscriptions.fontSizeMax, + ), + }; +}; + +export const subscriptionStyle = (sub: SubscriptionVisual) => { + const ssp = subscriptionScreenspaceProps(sub); + return { left: ssp.x + '%', top: ssp.y + '%', fontSize: ssp.fontSize + 'px', color: ssp.color }; +}; + +export const subscriptionReflectionStyle = (sub: SubscriptionVisual) => { + const dsp = subscriptionScreenspaceProps(sub); + const age = dsp.age * 2; + const pushDown = 10 + (age / CONFIG.Donations.despawnMs) * 120; + return { + ...sub, + left: dsp.x + '%', + top: dsp.radius + pushDown + '%', + }; +}; diff --git a/src/channels/laser-sunset/index.tsx b/src/channels/laser-sunset/index.tsx new file mode 100644 index 0000000..809b852 --- /dev/null +++ b/src/channels/laser-sunset/index.tsx @@ -0,0 +1,269 @@ +/** + * @author SushiElemental + * + * @file Break channel showing an HTML/CSS 【 V A P O R W A V E 】 scenery + */ + +import type { FormattedDonation, TwitchSubscription, Total } from '@gdq/types/tracker'; +import { ChannelProps, registerChannel } from '../channels'; +import styled from '@emotion/styled'; + +import { useEffect, useState, useReducer } from 'react'; +import { useReplicant } from 'use-nodecg'; +import { useActive } from '@gdq/lib/hooks/useActive'; +import { useListenForFn } from '@gdq/lib/hooks/useListenForFn'; +import TweenNumber from '@gdq/lib/components/TweenNumber'; + +import { StarVisual, SunReflectionLine, Overcast, DonationPopup, SubscriptionVisual } from './types'; +import * as fn from './functions'; +import CONFIG from './config'; +import './style.css'; + +registerChannel('Laser Sunset', 15, LaserSunset, { + position: 'topRight', + site: 'Twitch', + handle: 'SushiElemental', +}); + +export function LaserSunset(props: ChannelProps) { + const active = useActive(); + const [running, setRunning] = useState(false); + const [total] = useReplicant('total', null); + const [stars, setStars] = useState([]); + const [countDonations, incrementDonationsCount] = useReducer((x) => x + 1, 0); + const [donations, setDonations] = useState([]); + const [countSubscriptions, incrementSubscriptionsCount] = useReducer((x) => x + 1, 0); + const [subscriptions, setSubscriptions] = useState([]); + const [lasersX, scrollLasers] = useReducer((x) => { + return x > CONFIG.Lasers.bgXmin ? x - CONFIG.Lasers.scrollSpeed : CONFIG.Lasers.bgXstart; + }, CONFIG.Lasers.bgXstart); + const [clouds, setClouds] = useState([]); + const [sunReflections, setSunReflections] = useState([ + { xPosition: 50, marginTop: 2, width: 16 }, + { xPosition: 50, marginTop: 2, width: 14 }, + { xPosition: 50, marginTop: 2, width: 12 }, + { xPosition: 50, marginTop: 2, width: 10 }, + { xPosition: 50, marginTop: 2, width: 9 }, + { xPosition: 50, marginTop: 2, width: 8 }, + { xPosition: 50, marginTop: 2, width: 6 }, + { xPosition: 50, marginTop: 2, width: 4 }, + ]); + + const lasersXstyle = `.Lasers:after { background-position-x: ${lasersX}px; }`; + + const onDonationReceived = (donation: FormattedDonation) => { + if (!active || donations.length >= CONFIG.Donations.countMax) return; + + const dono = fn.spawnDonation(donation, countDonations); + const cloud = fn.spawnCloud(donation.rawAmount, countDonations); + setDonations((donos) => [...donos, dono]); + setClouds((clouds) => [...clouds, cloud]); + setTimeout(() => removeDonation(dono.received), CONFIG.Donations.despawnMs); + setTimeout(() => removeCloud(cloud.received), CONFIG.Cloud.despawnMs); + + incrementDonationsCount(); + animateSunReflections(); + + if (donations.length >= CONFIG.Donations.countMax) { + props.lock(); + } + }; + + const onSubscriptionReceived = (subscription: TwitchSubscription) => { + if (!active) return; + + setTimeout(() => props.unlock(), CONFIG.Subscriptions.lockTimeMs); + + const sub = fn.spawnSubscription(subscription, countSubscriptions); + setSubscriptions((subs) => [...subs, sub]); + setTimeout(() => removeSubscription(sub.received), CONFIG.Subscriptions.despawnMs); + + incrementSubscriptionsCount(); + animateSunReflections(); + }; + + useListenForFn('donation', onDonationReceived); + useListenForFn('subscription', onSubscriptionReceived); + + useEffect(() => { + if (!active) return; + + const fpsTimer = setInterval(() => { + scrollLasers(); + }, CONFIG.Timers.fpsInterval); + + setRunning(true); + return () => clearInterval(fpsTimer); + }, [active]); + + useEffect(() => { + const xStep = Math.min(100, 100 / CONFIG.Stars.countX); + const yStep = Math.min(100, 100 / CONFIG.Stars.countY); + const stars = new Array(); + + for (let y = 0; y < CONFIG.Stars.countY; y++) { + for (let x = 0; x < CONFIG.Stars.countX; x++) { + const star = fn.spawnStar( + y * CONFIG.Stars.countY + x, + x * xStep, + x * xStep + xStep, + y * yStep, + y * yStep + yStep, + ); + stars.push(star); + } + } + + setStars(stars); + }, [CONFIG.Stars.countX, CONFIG.Stars.countY]); + + useEffect(() => { + if (!active) return; + + const twinkleTimer = setInterval(() => { + setStars((stars) => + stars.map((s, index) => { + return { ...s, opacity: fn.randomStarOpacity() }; + }), + ); + }, CONFIG.Stars.twinkleMs); + + return () => clearInterval(twinkleTimer); + }, [active]); + + const removeDonation = (date: Date) => { + setDonations((donos) => donos.filter((d) => d.received !== date)); + props.unlock(); + }; + + const removeCloud = (date: Date) => { + setClouds((donos) => donos.filter((d) => d.received !== date)); + }; + + const removeSubscription = (date: Date) => { + setSubscriptions((subscriptions) => subscriptions.filter((s) => s.received !== date)); + }; + + const animateSunReflections = () => { + setSunReflections((lines) => { + return lines.map((line, index) => { + line.width = 16 - index * 2 + (Math.random() * 2 - 1); + line.marginTop = Math.floor(2.5 + (Math.random() * 2 - 1)); + line.xPosition = 50 + (Math.random() * 2 - 1); + return line; + }); + }); + }; + + if (!active || !running) { + return ; + } + + return ( + + + + + {stars.map((s, index) => ( + + {s.text} + + ))} + + + + + + + + {clouds.map((c, index) => ( + + ))} + + + + + {stars.map((s, index) => ( + + {s.text} + + ))} + + + + + + {sunReflections.map((line, index) => ( + + ))} + + + {subscriptions.map((s, idx) => ( + + ♥ + + ))} + + + {clouds.map((c, index) => ( + + ))} + + + {donations.map((d, idx) => ( + + {d.renderedAmount} + + ))} + + + + {subscriptions.map((s, idx) => ( + + ♥ + + ))} + + + {donations.map((d, idx) => ( + + {d.renderedAmount} + + ))} + + + $ + + + ); +} + +export const Container = styled.div``; +export const TotalEl = styled.div``; +export const DonationsList = styled.div``; +export const Donation = styled.div``; +export const SubscriptionsList = styled.div``; +export const Subscription = styled.div``; +export const Sky = styled.div``; +export const Stars = styled.div``; +export const Star = styled.div``; +export const Clouds = styled.div``; +export const Cloud = styled.div``; +export const CloudReflections = styled.div``; +export const Sun = styled.div``; +export const Ocean = styled.div``; +export const OceanBackground = styled.div``; +export const Lasers = styled.div``; +export const LasersHorizon = styled.div``; +export const SunReflections = styled.div``; +export const SunReflection = styled.div``; diff --git a/src/channels/laser-sunset/style.css b/src/channels/laser-sunset/style.css new file mode 100644 index 0000000..780d112 --- /dev/null +++ b/src/channels/laser-sunset/style.css @@ -0,0 +1,284 @@ + +.Container { + position: absolute; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + background-color: #000; +} + +.TotalEl { + font-family: gdqpixel; + font-size: 46px; + color: #eee; + text-shadow: 1px 1px 4px indigo; + opacity: 0.9; + position: absolute; + left: 3%; + top: 50%; + margin-top: -23px; + z-index: 101; +} + +.DonationsList { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; +} + +.Donation { + position: absolute; + left: 0; + top: 0; + font-family: gdqpixel; + font-size: 28px; + text-wrap: nowrap; + color: magenta; + text-shadow: 4px 2px indigo; + transform: translate(-50%, 0%); + z-index: 69; +} + +.Donation.reflection { + opacity: 0.3; + transform: translate(-50%, 0%) rotateX(-230deg); + z-index: initial; +} + +.SubscriptionsList { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; +} + +.Subscription { + position: absolute; + left: 0; + top: 0; + font-family: gdqpixel; + font-size: 28px; + color: magenta; + text-shadow: 4px 2px indigo; + transform: translate(-50%, 0%); + z-index: 69; +} + +.Subscription.reflection { + opacity: 0.3; + transform: translate(-8px, 0) rotateX(-230deg); + z-index: initial; +} + +.Sky { + position: absolute; + width: 100%; + height: 50%; + background: linear-gradient( + to bottom, + rgba(222, 33, 111, .0) 0%, + rgba(222, 33, 111, .2) 80%, + rgba(222, 33, 111, .5) 90%, + rgba(222, 33, 111, 1) 100% + ); +} + +.Stars { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + opacity: 0.7; +} + +.Stars.reflection { + transform: rotateX(-180deg); + opacity: 0.4; +} + +.Star { + font-family: gdqpixel; + font-size: 10px; + color: #eee; + position: absolute; + left: 10%; + top: 10%; + transition: opacity .6s ease-in-out; +} + +.Clouds { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.Clouds:after { + transform: perspective(200px) rotateX(-38deg) scale(2,1) translateZ(0); + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100vh; + -webkit-background-clip: content-box; + background-clip: content-box; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + outline: 1px solid transparent; + transform-origin: bottom center; + will-change: transform; +} + +.Clouds:after { + background-position: center bottom; + background-size: 110px 110px; + background-image: + linear-gradient(to right, magenta 2px, transparent 0); +} + +.Cloud { + transform: perspective(200px) rotateX(-38deg) scale(2,1) translateZ(0); + position: absolute; + z-index: 42; +} + +.CloudReflections { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; +} + +.Cloud.reflection { + transform: perspective(200px) rotateX(38deg) scale(2,1) translateZ(0); + opacity: 0.4; +} + +.Sun { + position: absolute; + left: 50%; + top: 69%; + width: 200px; + height: 200px; + border-radius: 100px; + box-shadow: 0 0 32px 0 #f09000; + background-color: #f09000; + transform: translate(-50%, -35%); +} + +.Sun-top { + clip-path: inset(-50px -50px 166px -50px); +} + +.Sun-middle-top { + clip-path: inset(38px -50px 150px -50px); +} + +.Sun-middle { + clip-path: inset(56px -50px 126px -50px); +} + +.Sun-middle-bottom { + clip-path: inset(82px -50px 97px -50px); +} + +.Sun-bottom { + clip-path: inset(112px -50px -50px -50px); +} + +.Ocean { + position: absolute; + bottom: 0%; + width: 100%; + height: 50%; + overflow: hidden; + background: #000; +} + +.Ocean-background { + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient( + to bottom, + rgba(0, 215, 215, .7) 0%, + rgba(0, 215, 215, .1) 20%, + rgba(0, 215, 215, 0) 100% + ); +} + +.Lasers { + position: absolute; + overflow: hidden; + width: 200%; + height: 100%; + left: 50%; + transform: translate(-50%, 0%); +} + +/* Laser grid background effect, taken from https://stackoverflow.com/questions/53416334/css-80s-tron-grid */ +.Lasers:after { + transform: perspective(200px) rotateX(38deg) scale(2,1) translateZ(0); + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100vh; + -webkit-background-clip: content-box; + background-clip: content-box; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + outline: 1px solid transparent; + transform-origin: bottom center; + will-change: transform; +} + +.Lasers:after { + background-position: center bottom; + background-size: 120px 120px; + background-image: + linear-gradient(to right, cyan 10px, transparent 0), + linear-gradient(to bottom, cyan 16px, transparent 0); +} + +.Lasers-horizon { + position: absolute; + width: 100%; + top: 0; + height: 100%; + background: linear-gradient(to bottom,rgba(0,0,0,0.4) 0%,rgba(0,0,0,0.2) 50%,rgba(0,0,0,0) 100%); +} + +.SunReflections { + position: absolute; + top: 3%; + width: 100%; + left: 50%; + transform: translate(-50%, 0%); +} + +.SunReflection { + position: relative; + left: 50%; + margin: 6px 0; + width: 20%; + height: 5px; + box-shadow: 0 0 16px 1px #f09000; + background-color: #f09000ad; + transform: translate(-50%, 0%); + transition: width 1s cubic-bezier(0, .3, .5, 1), left 1s cubic-bezier(0, .3, .5, 1), margin-top 1s ease-in-out; +} + +.Lamps { + position: absolute; + right: 2%; + bottom: 10%; +} diff --git a/src/channels/laser-sunset/types.ts b/src/channels/laser-sunset/types.ts new file mode 100644 index 0000000..df463e5 --- /dev/null +++ b/src/channels/laser-sunset/types.ts @@ -0,0 +1,28 @@ +import type { FormattedDonation } from '@gdq/types/tracker'; + +export type StarVisual = { left: number; top: number; text: string; color: string; opacity: number }; + +export type SunReflectionLine = { xPosition: number; marginTop: number; width: number }; + +export type Overcast = { + left: number; + width: number; + height: number; + backgroundColor: string; + received: Date; +}; + +export type DonationPopup = FormattedDonation & { + renderedAmount: string; + left: number; + top: number; + color: string; + received: Date; +}; + +export type SubscriptionVisual = { + angle: number; + radius: number; + color: string; + received: Date; +};