feat: add confetti

This commit is contained in:
2026-01-31 20:36:27 +01:00
parent 9071adff23
commit f6591b9081
3 changed files with 146 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
const props = defineProps<{
screen: "top" | "bottom";
}>();
const confetti = useConfetti();
const { onRender } = useScreen();
onRender((ctx) => {
let offset: number;
if (props.screen === "top") {
confetti.update();
offset = 0;
} else {
offset = LOGICAL_HEIGHT;
}
for (const p of confetti.particles) {
const localY = p.y - offset;
if (localY < -10 || localY > LOGICAL_HEIGHT + 10) continue;
ctx.save();
ctx.translate(p.x, localY);
ctx.rotate(p.rotation);
ctx.fillStyle = p.color;
ctx.fillRect(-p.width / 2, -p.height / 2, p.width, p.height);
ctx.restore();
}
}, 10000);
defineOptions({ render: () => null });
</script>

View File

@@ -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 });

View File

@@ -103,6 +103,7 @@ useKeyUp((key) => {
<AchievementsTopScreen v-else-if="app.screen === 'achievements'" /> <AchievementsTopScreen v-else-if="app.screen === 'achievements'" />
<AchievementsNotification /> <AchievementsNotification />
<CommonConfetti screen="top" />
</Screen> </Screen>
</div> </div>
<div> <div>
@@ -114,6 +115,8 @@ useKeyUp((key) => {
<SettingsBottomScreen v-else-if="app.screen === 'settings'" /> <SettingsBottomScreen v-else-if="app.screen === 'settings'" />
<GalleryBottomScreen v-else-if="app.screen === 'gallery'" /> <GalleryBottomScreen v-else-if="app.screen === 'gallery'" />
<AchievementsBottomScreen v-else-if="app.screen === 'achievements'" /> <AchievementsBottomScreen v-else-if="app.screen === 'achievements'" />
<CommonConfetti screen="bottom" />
</Screen> </Screen>
</div> </div>
</div> </div>