diff --git a/src/effects/hearts/index.ts b/src/effects/hearts/index.ts new file mode 100644 index 0000000000..1fb743f99c --- /dev/null +++ b/src/effects/hearts/index.ts @@ -0,0 +1,191 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C. + Copyright 2022 Arseny Uskov + + 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 HeartOptions = { + /** + * The maximum number of hearts to render at a given time + */ + maxCount: number; + /** + * The amount of gravity to apply to the hearts + */ + gravity: number; + /** + * The maximum amount of drift (horizontal sway) to apply to the hearts. Each heart varies. + */ + maxDrift: number; + /** + * The maximum amount of tilt to apply to the heart. Each heart varies. + */ + maxRot: number; +}; + +type Heart = { + x: number; + y: number; + xCol: number; + scale: number; + maximumDrift: number; + maximumRot: number; + gravity: number; + color: string; +}; + +export const DefaultOptions: HeartOptions = { + maxCount: 120, + gravity: 3.2, + maxDrift: 5, + maxRot: 5, +}; + +const KEY_FRAME_INTERVAL = 15; // 15ms, roughly + +export default class Hearts implements ICanvasEffect { + private readonly options: HeartOptions; + + constructor(options: { [key: string]: any }) { + this.options = { ...DefaultOptions, ...options }; + } + + private context: CanvasRenderingContext2D | null = null; + private particles: Array = []; + private lastAnimationTime: number; + + private colours = [ + 'rgba(194,210,224,1)', + 'rgba(235,214,219,1)', + 'rgba(255,211,45,1)', + 'rgba(255,190,174,1)', + 'rgba(255,173,226,1)', + 'rgba(242,114,171,1)', + 'rgba(228,55,116,1)', + 'rgba(255,86,130,1)', + 'rgba(244,36,57,1)', + 'rgba(247,126,157,1)', + 'rgba(243,142,140,1)', + 'rgba(252,116,183,1)']; + + 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 Heart, 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: Heart, width: number, height: number): Heart => { + particle.color = this.colours[(Math.random() * this.colours.length) | 0]; + particle.x = Math.random() * width; + particle.y = Math.random() * height + height; + particle.xCol = particle.x; + particle.scale = (Math.random() * 0.07) + 0.04; + particle.maximumDrift = (Math.random() * this.options.maxDrift) + 3.5; + particle.maximumRot = (Math.random() * this.options.maxRot) + 3.5; + particle.gravity = this.options.gravity + (Math.random() * 4.8); + 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.animateAndRenderHearts(); + } + requestAnimationFrame(this.renderLoop); + } + }; + + private animateAndRenderHearts() { + if (!this.context || !this.context.canvas) { + return; + } + 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 = 6 * particle.maximumDrift * Math.sin(0.7 * (PI2 / peakDistance) * particle.y); + particle.x += particle.xCol; // bring the particle to the right place + + const posScale = 1 / particle.scale; + const x = particle.x * posScale; + const y = particle.y * posScale; + + this.context.save(); + this.context.scale(particle.scale, particle.scale); + this.context.beginPath(); + + // Rotate the heart about its centre. + // The tilt of the heart is modelled similarly to its horizontal drift, + // using a sine function. + this.context.translate(248 + x, 215 + y); + this.context.rotate((1 / 10) * particle.maximumRot * Math.sin((PI2 / peakDistance) * particle.y * 0.8)); + this.context.translate(-248 - x, -215 - y); + + // Use bezier curves to draw a heart using pre-calculated coordinates. + this.context.moveTo(140 + x, 20 + y); + this.context.bezierCurveTo(73 + x, 20 + y, 20 + x, 74 + y, 20 + x, 140 + y); + this.context.bezierCurveTo(20 + x, 275 + y, 156 + x, 310 + y, 248 + x, 443 + y); + this.context.bezierCurveTo(336 + x, 311 + y, 477 + x, 270 + y, 477 + x, 140 + y); + this.context.bezierCurveTo(477 + x, 74 + y, 423 + x, 20 + y, 357 + x, 20 + y); + this.context.bezierCurveTo(309 + x, 20 + y, 267 + x, 48 + y, 248 + x, 89 + y); + this.context.bezierCurveTo(229 + x, 48 + y, 188 + x, 20 + y, 140 + x, 20 + y); + this.context.closePath(); + + this.context.fillStyle = particle.color; + this.context.fill(); + + this.context.restore(); + + // Remove any dead hearts after a 100px wide margin. + if (particle.y < -100) { + const idx = this.particles.indexOf(particle); + this.particles.splice(idx, 1); + } + } + } +} diff --git a/src/effects/index.ts b/src/effects/index.ts index fe14fe28f8..3468d4c5b6 100644 --- a/src/effects/index.ts +++ b/src/effects/index.ts @@ -21,6 +21,7 @@ import { FireworksOptions } from "./fireworks"; import { RainfallOptions } from "./rainfall"; import { SnowfallOptions } from "./snowfall"; import { SpaceInvadersOptions } from "./spaceinvaders"; +import { HeartOptions } from "./hearts"; /** * This configuration defines room effects that can be triggered by custom message types and emojis @@ -85,5 +86,15 @@ export const CHAT_EFFECTS: Array> = [ gravity: 0.01, }, } as Effect, + { + emojis: ["💝"], + msgType: "io.element.effect.hearts", + command: "hearts", + description: () => _td("Sends the given message with hearts"), + fallbackMessage: () => _t("sends hearts") + " 💝", + options: { + maxCount: 120, + gravity: 3.2, + }, + } as Effect, ]; - diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 445c90d6c0..32e7b1affe 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -988,6 +988,8 @@ "sends snowfall": "sends snowfall", "Sends the given message with a space themed effect": "Sends the given message with a space themed effect", "sends space invaders": "sends space invaders", + "Sends the given message with hearts": "Sends the given message with hearts", + "sends hearts": "sends hearts", "Server error": "Server error", "Command error": "Command error", "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",