feat: add confetti
This commit is contained in:
34
app/components/Common/Confetti.vue
Normal file
34
app/components/Common/Confetti.vue
Normal 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>
|
||||||
109
app/composables/useConfetti.ts
Normal file
109
app/composables/useConfetti.ts
Normal 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 });
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user