From f6591b90816f17b97b43ef81f2c36c42d8d28c84 Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Sat, 31 Jan 2026 20:36:27 +0100 Subject: [PATCH] feat: add confetti --- app/components/Common/Confetti.vue | 34 +++++++++ app/composables/useConfetti.ts | 109 +++++++++++++++++++++++++++++ app/pages/index.vue | 3 + 3 files changed, 146 insertions(+) create mode 100644 app/components/Common/Confetti.vue create mode 100644 app/composables/useConfetti.ts diff --git a/app/components/Common/Confetti.vue b/app/components/Common/Confetti.vue new file mode 100644 index 0000000..13c9dc3 --- /dev/null +++ b/app/components/Common/Confetti.vue @@ -0,0 +1,34 @@ + diff --git a/app/composables/useConfetti.ts b/app/composables/useConfetti.ts new file mode 100644 index 0000000..368dfa9 --- /dev/null +++ b/app/composables/useConfetti.ts @@ -0,0 +1,109 @@ +const PARTICLE_COUNT = 400; +const SPAWN_DURATION = 700; +const VX_RANGE = 0.5; +const VY_MIN = 0.3; +const VY_RANGE = 0.2; +const WIDTH_MIN = 3; +const WIDTH_RANGE = 4; +const HEIGHT_MIN = 2; +const HEIGHT_RANGE = 3; +const ROTATION_SPEED_RANGE = 0.15; +const DRIFT_STRENGTH = 0.03; +const GRAVITY = 0.007; +const VX_DAMPING = 0.995; +const SWAY_AMPLITUDE = 0.3; +const SWAY_SPEED_MIN = 0.02; +const SWAY_SPEED_RANGE = 0.03; +const OFFSCREEN_MARGIN = 10; +const TOTAL_HEIGHT = 192 * 2; + +const COLORS = [ + "#fb0018", + "#0059f3", + "#f3e300", + "#00fb00", + "#fb8afb", + "#fb9200", +]; + +export type ConfettiParticle = { + x: number; + y: number; + vx: number; + vy: number; + width: number; + height: number; + color: string; + rotation: number; + rotationSpeed: number; + swayPhase: number; + swaySpeed: number; +}; + +type Spawner = { + frame: number; + accumulator: number; +}; + +const particles: ConfettiParticle[] = []; +const spawners: Spawner[] = []; + +const spawnParticle = () => { + particles.push({ + x: Math.random() * LOGICAL_WIDTH, + y: -(30 + Math.random() * 20), + vx: (Math.random() - 0.5) * VX_RANGE, + vy: VY_MIN + Math.random() * VY_RANGE, + width: WIDTH_MIN + Math.random() * WIDTH_RANGE, + height: HEIGHT_MIN + Math.random() * HEIGHT_RANGE, + color: COLORS[Math.floor(Math.random() * COLORS.length)]!, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * ROTATION_SPEED_RANGE, + swayPhase: Math.random() * Math.PI * 2, + swaySpeed: SWAY_SPEED_MIN + Math.random() * SWAY_SPEED_RANGE, + }); +}; + +const spawn = () => { + spawners.push({ frame: 0, accumulator: 0 }); +}; + +const update = () => { + for (let s = spawners.length - 1; s >= 0; s -= 1) { + const spawner = spawners[s]!; + if (spawner.frame < SPAWN_DURATION) { + const progress = spawner.frame / SPAWN_DURATION; + const inv = 1 - progress; + const rate = inv * inv; + spawner.accumulator += rate * (PARTICLE_COUNT / SPAWN_DURATION) * 3; + + const count = Math.floor(spawner.accumulator); + spawner.accumulator -= count; + + for (let i = 0; i < count; i += 1) { + spawnParticle(); + } + + spawner.frame += 1; + } else { + spawners.splice(s, 1); + } + } + + for (let i = particles.length - 1; i >= 0; i -= 1) { + const p = particles[i]!; + p.vy += GRAVITY; + p.vx += (Math.random() - 0.5) * DRIFT_STRENGTH; + p.vx *= VX_DAMPING; + p.swayPhase += p.swaySpeed; + p.x += p.vx + Math.sin(p.swayPhase) * SWAY_AMPLITUDE; + p.y += p.vy; + p.rotation += p.rotationSpeed; + + if (p.y > TOTAL_HEIGHT + OFFSCREEN_MARGIN) { + particles.splice(i, 1); + } + } +}; + +export const useConfetti = () => ({ particles, spawn, update }); diff --git a/app/pages/index.vue b/app/pages/index.vue index b9432c7..eee0cc0 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -103,6 +103,7 @@ useKeyUp((key) => { +
@@ -114,6 +115,8 @@ useKeyUp((key) => { + +