Add: snowfall (#474)

* [Feat] Add Plugin: Snowfall

* Add a plugin called Snowfall, based on the BetterDiscord plugin by the same name. Lets snow fall from the top of your discord client.

* [QOL] snowfall: Credit BetterDiscord Author

* Here Comes Santa Claus

* [Fix] snowfall: Fix Several Bugs

* Switch from HTML entity on the content option to the Unicode character.
* Fixed type switching bug that causes memory leaks.
* Fixed image loading bug.
* Fixed content array handing for image sources.

* [Feat/QOL] snowfall: Configurability Overhaul

* Fixed switching between types.
* Created settings option for user to select type.
* Created description to warn of possible performance issues.
* Created settings options for users to set speed, amount, and size.
* Created custom listener to check for settings changes.
* Added hardcoded SVGs for Snowflakes when using the image option (including a custom-made discord one as an easter-egg).

* Fix condition for deviation check in random function

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Z ❤'s Life 2025-11-05 14:44:54 -05:00 • committed by GitHub
parent 871de99ad7
commit f3ac9ff64a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 496 additions and 0 deletions

View file

@ -0,0 +1,491 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { createRoot, React } from "@webpack/common";
import type { Root } from "react-dom/client";
const SnowfallCSS = `
#snowfield {
pointer-events: none;
user-select: none;
z-index: 100000;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.snowflake {
position: absolute;
color: #fff;
line-height: 1;
-webkit-font-smoothing: antialiased;
}
.snowflake-solid {
border-radius: 50%;
background: #ffffff;
}
.snowflake-image {
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.5));
}
`;
// SVG snowflake images as data URIs
const SNOWFLAKE_SVGS = [
// 6-pointed snowflake
"data:image/svg+xml," + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path fill="white" d="M50,10 L55,45 L50,50 L45,45 Z M50,90 L55,55 L50,50 L45,55 Z M10,50 L45,55 L50,50 L45,45 Z M90,50 L55,55 L50,50 L55,45 Z M25,25 L45,45 L50,40 L40,30 Z M75,75 L55,55 L50,60 L60,70 Z M75,25 L55,45 L60,50 L70,40 Z M25,75 L45,55 L40,50 L30,60 Z"/>
<circle cx="50" cy="50" r="8" fill="white"/>
</svg>
`),
// 8-pointed star snowflake
"data:image/svg+xml," + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g fill="white">
<rect x="47" y="5" width="6" height="90" rx="2"/>
<rect x="5" y="47" width="90" height="6" rx="2"/>
<rect x="47" y="5" width="6" height="90" rx="2" transform="rotate(45 50 50)"/>
<rect x="47" y="5" width="6" height="90" rx="2" transform="rotate(-45 50 50)"/>
<circle cx="50" cy="50" r="10" fill="white"/>
</g>
</svg>
`),
// Discord snowflake
"data:image/svg+xml," + encodeURIComponent(`
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="discord" viewBox="0 0 24 24">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
</symbol>
</defs>
<!-- snowflake arms -->
<line x1="100" y1="100" x2="150" y2="100" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="125" y2="143.3" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="75" y2="143.3" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="50" y2="100" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="75" y2="56.7" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="125" y2="56.7" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<!-- circle mask -->
<mask id="logoMask">
<rect width="200" height="200" fill="white"/>
<circle cx="150" cy="100" r="18" fill="black"/> <!-- right -->
<circle cx="125" cy="143.3" r="18" fill="black"/> <!-- top-right -->
<circle cx="75" cy="143.3" r="18" fill="black"/> <!-- top-left -->
<circle cx="50" cy="100" r="18" fill="black"/> <!-- left -->
<circle cx="75" cy="56.7" r="18" fill="black"/> <!-- bottom-left -->
<circle cx="125" cy="56.7" r="18" fill="black"/> <!-- bottom-right -->
</mask>
<!-- circle behind all elements -->
<circle cx="100" cy="100" r="50" stroke="#FFF" stroke-width="1.5" fill="none" stroke-linecap="round" mask="url(#logoMask)" />
<use href="#discord" fill="#FFF" x="135" y="85" width="30" height="30" transform="rotate(90, 150, 100)"/>
<use href="#discord" fill="#FFF" x="110" y="128.3" width="30" height="30" transform="rotate(150, 125, 143.3)"/>
<use href="#discord" fill="#FFF" x="60" y="128.3" width="30" height="30" transform="rotate(210, 75, 143.3)"/>
<use href="#discord" fill="#FFF" x="35" y="85" width="30" height="30" transform="rotate(270, 50, 100)"/>
<use href="#discord" fill="#FFF" x="60" y="41.7" width="30" height="30" transform="rotate(330, 75, 56.7)"/>
<use href="#discord" fill="#FFF" x="110" y="41.7" width="30" height="30" transform="rotate(30, 125, 56.7)"/>
</svg>
`),
];
const settings = definePluginSettings({
typeOfSnow: {
description: "Change the type of snow displayed (Affects performance).",
type: OptionType.SELECT,
options: [
{ label: "Solid (Highest Performance)", value: "solid" },
{ label: "Text (Medium Performance)", value: "text", default: true },
{ label: "Image (Lowest Performance)", value: "image" }
],
},
maxSize: {
description: "Maximum snowflake size",
type: OptionType.SLIDER,
default: 30,
markers: [10, 20, 30, 40, 50]
},
speed: {
description: "Snowfall speed (higher = faster fall)",
type: OptionType.SLIDER,
default: 50,
markers: [50, 100, 200, 300, 400, 500]
},
flakesPerSecond: {
description: "Snowflakes per second (higher = denser snowfall)",
type: OptionType.SLIDER,
default: 5,
markers: [1, 5, 10, 20, 40, 60],
min: 1,
max: 60,
}
});
class CopleSnow {
private static winWidth = window.innerWidth;
private static winHeight = window.innerHeight;
static readonly defaultOptions = {
minSize: 10,
maxSize: 30,
type: "text" as "text" | "solid" | "image",
content: "❄" as string | string[],
fadeOut: true,
autoplay: true,
interval: 200
};
private static cssPrefix(propertyName: string): string | null {
const capitalize = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
const tempDiv = document.createElement("div");
const { style } = tempDiv;
const prefixes = ["Webkit", "Moz", "ms", "O"];
if (propertyName in style) return propertyName;
for (const prefix of prefixes) {
const name = prefix + capitalize;
if (name in style) return name;
}
return null;
}
private static readonly cssPrefixedNames = {
transform: this.cssPrefix("transform"),
transition: this.cssPrefix("transition")
};
private static readonly transitionEndEvent =
{ WebkitTransition: "webkitTransitionEnd", OTransition: "oTransitionEnd", Moztransition: "transitionend", transition: "transitionend" }[
this.cssPrefixedNames.transition ?? "transition"
] ?? "transitionend";
private static random(min: number, max: number, deviation?: number): number {
if (!deviation) {
deviation *= max;
max += deviation;
min = max - deviation;
} else {
min = min || 0;
}
return Math.floor(Math.random() * (max - min + 1) + min);
}
private static setStyle(element: HTMLElement, rules: Record<string, string | number>) {
for (const [name, value] of Object.entries(rules)) {
const cssName = CopleSnow.cssPrefixedNames[name as keyof typeof CopleSnow.cssPrefixedNames] || name;
(element.style as any)[cssName] = value;
}
}
private options = { ...CopleSnow.defaultOptions };
private queue: HTMLElement[] = [];
private $snowfield: HTMLDivElement;
private timer: number | null = null;
public playing = false;
constructor(newOptions: Partial<typeof CopleSnow.defaultOptions> = {}) {
Object.assign(this.options, newOptions);
this.$snowfield = document.createElement("div");
this.$snowfield.id = "snowfield";
document.body.appendChild(this.$snowfield);
const updateSize = () => {
CopleSnow.winHeight = window.innerHeight;
CopleSnow.winWidth = window.innerWidth;
};
window.addEventListener("resize", updateSize);
(this as any)._resizeHandler = updateSize;
this.$snowfield.addEventListener(CopleSnow.transitionEndEvent, e => {
const snowflake = e.target as HTMLElement;
if (snowflake.classList.contains("snowflake")) {
this.$snowfield.removeChild(snowflake);
this.queue.push(snowflake);
}
});
const handleVisibilityChange = () => {
if (document.hidden) this.stop();
else this.play();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
(this as any)._visibilityHandler = handleVisibilityChange;
if (this.options.autoplay) this.play();
}
private createSnowflake(): HTMLElement {
const { type, content } = this.options as { type: "text" | "image" | "solid"; content: string | string[]; };
const cntLength = Array.isArray(content) ? content.length : 1;
let snowflake: HTMLElement;
if (type === "image") {
snowflake = document.createElement("img");
const src = typeof content === "string"
? content
: content[Math.floor(Math.random() * cntLength)];
(snowflake as HTMLImageElement).src = src;
snowflake.setAttribute("draggable", "false");
} else {
snowflake = document.createElement("div");
if (type === "text") {
const textContent = typeof content === "string"
? content
: content[Math.floor(Math.random() * cntLength)];
snowflake.textContent = textContent;
}
// if type is solid we don't need to set content
}
snowflake.className = `snowflake snowflake-${type}`;
(snowflake as any).dataset.type = type;
return snowflake;
}
private animateSnowflake() {
const { winWidth, winHeight } = CopleSnow;
const size = CopleSnow.random(this.options.minSize, this.options.maxSize);
const top = -2 * size;
const left = CopleSnow.random(0, winWidth - size);
const opacity = CopleSnow.random(5, 10) / 10;
const angle = CopleSnow.random(0, winHeight * 0.8, 1);
const translateX = CopleSnow.random(-100, 100);
const translateY = winHeight + size * 2;
const baseSpeed = settings.store.speed;
const duration = (winHeight * 10) / (baseSpeed / 50);
let snowflake: HTMLElement;
if (this.queue.length > 0) {
const reused = this.queue.shift()!;
if ((reused.dataset.type as string) !== this.options.type) {
snowflake = this.createSnowflake();
} else {
snowflake = reused;
if (this.options.type === "text") {
const { content } = this.options;
if (Array.isArray(content)) {
snowflake.textContent = content[Math.floor(Math.random() * content.length)];
}
} else if (this.options.type === "image") {
const { content } = this.options;
if (Array.isArray(content)) {
(snowflake as HTMLImageElement).src = content[Math.floor(Math.random() * content.length)];
}
}
}
} else {
snowflake = this.createSnowflake();
}
const styleRules: Record<string, string | number> = {
top: `${top}px`,
left: `${left}px`,
opacity: opacity,
transform: "none",
transition: `${duration}ms linear`
};
switch (this.options.type) {
case "solid":
styleRules.width = styleRules.height = `${size}px`;
break;
case "text":
styleRules.fontSize = `${size}px`;
break;
case "image":
styleRules.width = `${size}px`;
styleRules.height = "auto";
break;
}
CopleSnow.setStyle(snowflake, styleRules);
this.$snowfield.appendChild(snowflake);
setTimeout(() => {
CopleSnow.setStyle(snowflake, {
transform: `translate(${translateX}px, ${translateY}px) rotate(${angle}deg)`,
opacity: this.options.fadeOut ? 0 : opacity
});
}, 100);
}
play() {
if (this.playing) return;
this.timer = window.setInterval(() => this.animateSnowflake(), this.options.interval);
this.playing = true;
}
stop() {
if (!this.playing) return;
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
this.playing = false;
}
destroy() {
this.stop();
if (this.$snowfield.parentNode) {
this.$snowfield.remove();
}
window.removeEventListener("resize", (this as any)._resizeHandler);
document.removeEventListener("visibilitychange", (this as any)._visibilityHandler);
}
updateOptions(newOptions: Partial<typeof CopleSnow.defaultOptions>) {
Object.assign(this.options, newOptions);
}
}
const SnowfallManager: React.FC = () => {
const snowRef = React.useRef<CopleSnow | null>(null);
const styleRef = React.useRef<HTMLStyleElement | null>(null);
React.useEffect(() => {
// Inject CSS
const styleEl = document.createElement("style");
styleEl.id = "snowfall-styles";
styleEl.textContent = SnowfallCSS;
document.head.appendChild(styleEl);
styleRef.current = styleEl;
// Get initial snow type from settings
const snowType = settings.store.typeOfSnow as "text" | "solid" | "image";
const snowOptions: Partial<typeof CopleSnow.defaultOptions> = {
autoplay: false,
type: snowType,
maxSize: settings.store.maxSize,
interval: 1000 / settings.store.flakesPerSecond
};
snowOptions.interval = settings.store.speed;
// Set content based on type
if (snowType === "image") {
snowOptions.content = SNOWFLAKE_SVGS;
}
// Create snow instance with settings
const snow = new CopleSnow(snowOptions);
snowRef.current = snow;
const blurHandler = () => snow.stop();
const focusHandler = () => {
if (document.hasFocus()) snow.play();
};
window.addEventListener("blur", blurHandler);
window.addEventListener("focus", focusHandler);
if (document.hasFocus()) {
snow.play();
}
// Custom listener for settings changes (we don't have a built-in one)
let lastSettings = {
type: settings.store.typeOfSnow as "text" | "solid" | "image",
maxSize: settings.store.maxSize,
speed: settings.store.speed,
flakesPerSecond: settings.store.flakesPerSecond
};
const settingsInterval = setInterval(() => {
const newSettings = {
type: settings.store.typeOfSnow as "text" | "solid" | "image",
maxSize: settings.store.maxSize,
speed: settings.store.speed,
flakesPerSecond: settings.store.flakesPerSecond
};
if (Object.keys(newSettings).some(k => newSettings[k as keyof typeof newSettings] !== lastSettings[k as keyof typeof lastSettings])) {
lastSettings = newSettings;
const updateOptions: Partial<typeof CopleSnow.defaultOptions> = {
type: newSettings.type,
maxSize: newSettings.maxSize,
interval: 1000 / newSettings.flakesPerSecond
};
if (newSettings.type === "image") updateOptions.content = SNOWFLAKE_SVGS;
else if (newSettings.type === "text") updateOptions.content = "❄";
snow.updateOptions(updateOptions);
}
}, 5000);
return () => {
snow.destroy();
snowRef.current = null;
window.removeEventListener("blur", blurHandler);
window.removeEventListener("focus", focusHandler);
clearInterval(settingsInterval);
if (styleEl.parentNode) {
styleEl.remove();
}
styleRef.current = null;
const snowfield = document.getElementById("snowfield");
if (snowfield) snowfield.remove();
};
}, []);
return null;
};
let snowRoot: Root | null = null;
let container: HTMLDivElement | null = null;
export default definePlugin({
name: "Snowfall",
description: "Let it snow on Discord! Ported from the BetterDiscord plugin by square.",
authors: [EquicordDevs.ZcraftElite, EquicordDevs.square],
settingsAboutComponent: () => (
<>
<Heading>Information</Heading>
<Paragraph>
This plugin adds a christmas-y snowfall effect on top of Discord's interface.
You can change the type of snow in the settings below.
<br /><br />
NOTE: While on most computers this plugin will not impact performance any more than your average Equicord extension,
it may cause some lag on lower end systems.
</Paragraph>
</>
),
settings,
start() {
container = document.createElement("div");
container.id = "snowfall-plugin-container";
document.body.appendChild(container);
snowRoot = createRoot(container);
snowRoot.render(React.createElement(SnowfallManager));
},
stop() {
if (snowRoot) {
snowRoot.unmount();
snowRoot = null;
}
if (container) {
if (container.parentNode) container.remove();
container = null;
}
}
});

View file

@ -143,6 +143,7 @@ export function initWs(isManual = false) {
case "disable": {
const m = d.data;
const settings = Settings.plugins[m.pluginName];
if (!settings) throw new Error("Plugin not found: " + m.pluginName);
if (m.enabled !== settings.enabled)
toggleEnabled(m.pluginName, reply);
break;

View file

@ -1186,6 +1186,10 @@ export const EquicordDevs = Object.freeze({
name: "Prism",
id: 390884143749136386n
},
square: {
name: "square",
id: 219363409097916416n
}
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly