From a0d925b879e67b8f4683b75eb73efc4b7b961d0e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sun, 20 Dec 2020 23:29:03 -0700 Subject: [PATCH] Add a snowfall chat effect (with /snowfall command) --- src/effects/index.ts | 28 ++++++- src/effects/snowfall/index.ts | 148 ++++++++++++++++++++++++++++++++++ src/i18n/strings/en_EN.json | 2 + 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/effects/snowfall/index.ts diff --git a/src/effects/index.ts b/src/effects/index.ts index 27f1902d7c..a22948ebcf 100644 --- a/src/effects/index.ts +++ b/src/effects/index.ts @@ -75,6 +75,20 @@ type FireworksOptions = { */ gravity: number; } +type SnowfallOptions = { + /** + * The maximum number of snowflakes to render at a given time + */ + maxCount: number; + /** + * The amount of gravity to apply to the snowflakes + */ + gravity: number; + /** + * The amount of drift (horizontal sway) to apply to the snowflakes. Each snowflake varies. + */ + maxDrift: number; +} /** * This configuration defines room effects that can be triggered by custom message types and emojis @@ -99,12 +113,24 @@ export const CHAT_EFFECTS: Array> = [ msgType: 'nic.custom.fireworks', command: 'fireworks', description: () => _td("Sends the given message with fireworks"), - fallbackMessage: () => _t("sends fireworks") + "🎆", + fallbackMessage: () => _t("sends fireworks") + " 🎆", options: { maxCount: 500, gravity: 0.05, }, } as Effect, + { + emojis: ['❄', '🌨'], + msgType: 'io.element.effect.snowfall', + command: 'snowfall', + description: () => _td("Sends the given message with snowfall"), + fallbackMessage: () => _t("sends snowfall") + " ❄", + options: { + maxCount: 200, + gravity: 0.05, + maxDrift: 5, + }, + } as Effect, ]; diff --git a/src/effects/snowfall/index.ts b/src/effects/snowfall/index.ts new file mode 100644 index 0000000000..2affb50405 --- /dev/null +++ b/src/effects/snowfall/index.ts @@ -0,0 +1,148 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import ICanvasEffect from '../ICanvasEffect'; +import { arrayFastClone } from "../../utils/arrays"; + +export type SnowfallOptions = { + /** + * The maximum number of snowflakes to render at a given time + */ + maxCount: number; + /** + * The amount of gravity to apply to the snowflakes + */ + gravity: number; + /** + * The amount of drift (horizontal sway) to apply to the snowflakes. Each snowflake varies. + */ + maxDrift: number; +} + +type Snowflake = { + x: number; + y: number; + xCol: number; + diameter: number; + maximumDrift: number; + gravity: number; +} + +export const DefaultOptions: SnowfallOptions = { + maxCount: 200, + gravity: 0.05, + maxDrift: 5, +}; + +const KEY_FRAME_INTERVAL = 15; // 15ms, roughly + +export default class Snowfall implements ICanvasEffect { + private readonly options: SnowfallOptions; + + constructor(options: { [key: string]: any }) { + this.options = {...DefaultOptions, ...options}; + } + + private context: CanvasRenderingContext2D | null = null; + private particles: Array = []; + private lastAnimationTime: number; + + public isRunning: boolean; + + public start = async (canvas: HTMLCanvasElement, timeout = 3000) => { + if (!canvas) { + return; + } + this.context = canvas.getContext('2d'); + this.particles = []; + const count = this.options.maxCount; + while (this.particles.length < count) { + this.particles.push(this.resetParticle({} as Snowflake, canvas.width, canvas.height)); + } + this.isRunning = true; + requestAnimationFrame(this.renderLoop); + if (timeout) { + window.setTimeout(this.stop, timeout); + } + } + + public stop = async () => { + this.isRunning = false; + } + + private resetParticle = (particle: Snowflake, width: number, height: number): Snowflake => { + particle.x = Math.random() * width; + particle.y = Math.random() * -height; + particle.xCol = particle.x; + particle.diameter = (Math.random() * 7) + 4; + particle.maximumDrift = (Math.random() * this.options.maxDrift) + 3.5; + particle.gravity = this.options.gravity + (Math.random() * 6) + 4; + return particle; + } + + private renderLoop = (): void => { + if (!this.context || !this.context.canvas) { + return; + } + if (this.particles.length === 0) { + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + } else { + const timeDelta = Date.now() - this.lastAnimationTime; + if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) { + // Clear the screen first + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + + this.lastAnimationTime = Date.now(); + this.animateAndRenderSnowflakes(); + } + requestAnimationFrame(this.renderLoop); + } + }; + + private animateAndRenderSnowflakes() { + if (!this.context || !this.context.canvas) { + return; + } + const height = this.context.canvas.height; + for (const particle of arrayFastClone(this.particles)) { + particle.y += particle.gravity; + + // We treat the drift as a sine function to have a more fluid-like movement instead + // of a pong-like movement off walls of the X column. This means that for + // $x=A\sin(\frac{2\pi}{P}y)$ we use the `maximumDrift` as the amplitude (A) and a + // large multiplier to create a very long waveform through P. + const peakDistance = 75 * particle.maximumDrift; + const PI2 = Math.PI * 2; + particle.x = particle.maximumDrift * Math.sin((PI2 / peakDistance) * particle.y); + particle.x += particle.xCol; // bring the particle to the right place + + const radius = particle.diameter / 2; + this.context.save(); + this.context.beginPath(); + this.context.ellipse(particle.x, particle.y, radius, radius, 0, 0, 360); + this.context.fillStyle = '#eaeaea'; // grey so it shows up on the light theme + this.context.fill(); + this.context.closePath(); + this.context.restore(); + + // Remove any dead snowflakes + const maxBounds = radius * 4; // make sure it's *really* off screen + if (particle.y > (height + maxBounds)) { + const idx = this.particles.indexOf(particle); + this.particles.splice(idx, 1); + } + } + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b96d767ccb..556d217837 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -849,6 +849,8 @@ "sends confetti": "sends confetti", "Sends the given message with fireworks": "Sends the given message with fireworks", "sends fireworks": "sends fireworks", + "Sends the given message with snowfall": "Sends the given message with snowfall", + "sends snowfall": "sends snowfall", "You held the call Resume": "You held the call Resume", "%(peerName)s held the call": "%(peerName)s held the call", "Video Call": "Video Call",