Add heart effect (#6188)
* add heart effect * clean up syntax * convert indentation to spaces * appease the linter * hopefully resolve merge conflict * add missing semicolons * add label to toggle switch * Revert "add label to toggle switch" This reverts commit 5e83d9b9109f5f1922555484f7492596cc5966ff. * remove extra space * replace gift heart with emoji heart in timeline message * reduce number of emoji triggers * copyright should be myself * Update src/effects/index.ts Co-authored-by: ColonisationCaptain <52425971+ColonisationCaptain@users.noreply.github.com> Co-authored-by: Travis Ralston <travpc@gmail.com> Co-authored-by: Travis Ralston <travisr@matrix.org>
This commit is contained in:
parent
579a166113
commit
cdb0c2c6a1
3 changed files with 205 additions and 1 deletions
191
src/effects/hearts/index.ts
Normal file
191
src/effects/hearts/index.ts
Normal file
|
@ -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<Heart> = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Effect<{ [key: string]: any }>> = [
|
|||
gravity: 0.01,
|
||||
},
|
||||
} as Effect<SpaceInvadersOptions>,
|
||||
{
|
||||
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<HeartOptions>,
|
||||
];
|
||||
|
||||
|
|
|
@ -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.",
|
||||
|
|
Loading…
Reference in a new issue