5年前ぐらい前につくった紙吹雪をJavaScriptからTypeScriptに移植してみました。
Canvasに紙吹雪をレンダリングする基本方針はそのままに、ちょっとだけ挙動を変更しています。
具体的には、TypeScriptバージョンは紙吹雪が舞い散り終わった後、ループさせずにCanvasをremoveChildするようにしました。
TypeScriptバージョン
See the Pen Confetti by kimmy (@kimmy) on CodePen.
※ ループしないので、舞い散り終わっている可能性が高いです。
※ 舞い散る様子は https://codepen.io/kimmy/pen/abKMvxR/ をご確認ください
ソースコード
class Progress { timestamp: number | null; duration: number; progress: number; delta: number; isLoop: boolean; constructor({ duration, isLoop }: { duration?: number; isLoop?: boolean } = {}) { this.timestamp = null; this.duration = duration || Progress.CONST.DURATION; this.progress = 0; this.delta = 0; this.isLoop = !!isLoop; this.reset(); } static get CONST() { return { DURATION: 8000 }; } reset() { this.timestamp = null; } start(now: number) { this.timestamp = now; } tick(now: number) { if (this.timestamp) { this.delta = now - this.timestamp; this.progress = Math.min(this.delta / this.duration, 1); if (this.progress >= 1 && this.isLoop) { this.start(now); } return this.progress; } else { return 0; } } } type Position = { initX: number; initY: number; }; class Sprite { canvas: HTMLCanvasElement; position: Position; rotation: number; speed: number; constructor({ canvas, position, rotation, speed, }:{ canvas: HTMLCanvasElement; position: Position; rotation: number; speed: number; }) { this.canvas = canvas; this.position = position; this.rotation = rotation; this.speed = speed; } } export class Confetti { parent: HTMLElement | HTMLBodyElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; width: number; height: number; length: number; yRange: number; progress: Progress; rotationRange: number; speedRange: number; sprites: Sprite[]; constructor({ elm, width, height, length, yRange, duration, isLoop, rotationRange, speedRange }: { elm?: HTMLElement | HTMLBodyElement; width?: number; height?: number; length?: number; yRange?: number; duration?: number; rotationRange?: number; speedRange?: number; } = {}) { this.parent = elm || document.body; this.canvas = document.createElement('canvas'); this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D; this.width = width || this.parent.offsetWidth; this.height = height || this.parent.offsetHeight; this.length = length || Confetti.CONST.PAPER_LENGTH; this.yRange = yRange || this.height * 2; this.progress = new Progress({ duration, isLoop: false }); this.rotationRange = typeof rotationRange === 'number' ? rotationRange : Confetti.CONST.ROTATION_RANGE; this.speedRange = typeof speedRange === 'number' ? speedRange : Confetti.CONST.SPEED_RANGE; this.sprites = []; this.canvas.style.cssText = [ 'display: block', 'position: absolute', 'top: 0', 'left: 0', 'pointer-events: none' ].join(';'); this.render = this.render.bind(this); this.build(); this.parent.appendChild(this.canvas); this.progress.start(performance.now()); requestAnimationFrame(this.render); } static get CONST() { return { SPRITE_WIDTH: 9, SPRITE_HEIGHT: 16, PAPER_LENGTH: 100, DURATION: 8000, ROTATION_RATE: 50, ROTATION_RANGE: 10, SPEED_RANGE: 10, COLORS: [ '#EF5350', '#EC407A', '#AB47BC', '#7E57C2', '#5C6BC0', '#42A5F5', '#29B6F6', '#26C6DA', '#26A69A', '#66BB6A', '#9CCC65', '#D4E157', '#FFEE58', '#FFCA28', '#FFA726', '#FF7043', '#8D6E63', '#BDBDBD', '#78909C' ] }; } build() { for (let i = 0; i < this.length; ++i) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; canvas.width = Confetti.CONST.SPRITE_WIDTH; canvas.height = Confetti.CONST.SPRITE_HEIGHT; ctx.save(); ctx.fillStyle = Confetti.CONST.COLORS[(Math.random() * Confetti.CONST.COLORS.length) | 0]; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.restore(); this.sprites.push(new Sprite({ canvas, position: { initX: Math.random() * this.width, initY: -canvas.height - Math.random() * this.yRange }, rotation: (this.rotationRange / 2) - Math.random() * this.rotationRange, speed: (this.speedRange / 2) + Math.random() * (this.speedRange / 2) })); } } render(now: number) { const progress = this.progress.tick(now); this.canvas.width = this.width; this.canvas.height = this.height; for (let i = 0; i < this.length; ++i) { this.ctx.save(); this.ctx.translate( this.sprites[i].position.initX + this.sprites[i].rotation * Confetti.CONST.ROTATION_RATE * progress, this.sprites[i].position.initY + progress * (this.height + this.yRange) ); this.ctx.rotate(this.sprites[i].rotation); this.ctx.drawImage( this.sprites[i].canvas, -Confetti.CONST.SPRITE_WIDTH * Math.abs(Math.sin(progress * Math.PI * 2 * this.sprites[i].speed)) / 2, -Confetti.CONST.SPRITE_HEIGHT / 2, Confetti.CONST.SPRITE_WIDTH * Math.abs(Math.sin(progress * Math.PI * 2 * this.sprites[i].speed)), Confetti.CONST.SPRITE_HEIGHT ); this.ctx.restore(); } if (progress < 1) { requestAnimationFrame(this.render); } else { this.parent.removeChild(this.canvas); } } }
使い方
new Confetti();
色々パラメータを渡せるようにはなっていますが、とりあえず、newするだけでも、
- bodyにcanvasを生成
- 8秒かけて100枚紙吹雪を舞い散らせる
- 舞い散らせた後にcanvasを削除
という動作をするようにしています。