CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 36s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 4s
179 lines
7.2 KiB
TypeScript
179 lines
7.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { StyleTheme } from "../context/settings";
|
|
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
|
|
import "./WheelConfetti.scss";
|
|
|
|
type WheelTheme = StyleTheme;
|
|
|
|
type Props = {
|
|
theme: WheelTheme,
|
|
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
|
|
burstKey: number,
|
|
/** Velká salva (finální výsledek) vs. menší (mezivýsledek). */
|
|
big?: boolean,
|
|
};
|
|
|
|
type UmaImage = { src: string, round: boolean };
|
|
|
|
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
|
|
const UMA_EMOJI = ['🐴', '🏇', '🥕', '🏆', '🌸', '✨', '⭐', '🍙', '🎀', '🥇'];
|
|
// Hlášky z fandomu Umamusume (Umapyoi Densetsu, Teio step, hachimī, Golshi, Oguri „okawari"...)
|
|
const UMA_BADGES = ['1着!', 'Umazing!', 'Umapyoi!', 'Teio step!', 'Hachimī~', 'Golshi approves', 'Okawari!', 'Pakapaka~', 'ウマい!'];
|
|
const UMA_BADGE_COLORS = ['#ff6fa3', '#2e9a3a', '#43b8f0', '#f2a91a'];
|
|
const DURATION_MS = 6400;
|
|
|
|
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
|
const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)];
|
|
|
|
type Piece = { id: number, style: React.CSSProperties, content: React.ReactNode, className: string };
|
|
|
|
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
|
|
|
|
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
|
|
|
|
/**
|
|
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
|
|
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
|
|
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
|
|
*/
|
|
function launchStyle(launch: Launch): React.CSSProperties {
|
|
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
|
|
switch (launch) {
|
|
case 'cannon-left':
|
|
case 'cannon-right': {
|
|
const dir = launch === 'cannon-left' ? 1 : -1;
|
|
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
|
|
top = rand(88, 100);
|
|
x1 = dir * rand(12, 62);
|
|
y1 = -rand(45, 88);
|
|
x2 = x1 + dir * rand(6, 22);
|
|
y2 = rand(12, 25);
|
|
delay = rand(0, 450);
|
|
break;
|
|
}
|
|
case 'burst': {
|
|
const angle = rand(0, Math.PI * 2);
|
|
const radius = rand(18, 48);
|
|
left = 50;
|
|
top = 42;
|
|
x1 = Math.cos(angle) * radius;
|
|
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
|
|
x2 = x1 * 1.35;
|
|
y2 = rand(70, 85);
|
|
delay = rand(0, 250);
|
|
break;
|
|
}
|
|
default: {
|
|
left = rand(0, 100);
|
|
top = -12;
|
|
x1 = rand(-8, 8);
|
|
y1 = rand(35, 55);
|
|
x2 = x1 + rand(-10, 10);
|
|
y2 = rand(115, 125);
|
|
delay = rand(400, 1800);
|
|
}
|
|
}
|
|
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
|
|
return {
|
|
left: `${left}vw`,
|
|
top: `${top}vh`,
|
|
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
|
|
animationDelay: `${delay}ms`,
|
|
animationDuration: `${rand(2600, 3900)}ms`,
|
|
['--x1' as any]: `${x1}vw`,
|
|
['--y1' as any]: `${y1}vh`,
|
|
['--x2' as any]: `${x2}vw`,
|
|
['--y2' as any]: `${y2}vh`,
|
|
['--r1' as any]: `${spin * 0.4}deg`,
|
|
['--r2' as any]: `${spin}deg`,
|
|
};
|
|
}
|
|
|
|
function buildPieces(theme: WheelTheme, big: boolean, images: UmaImage[]): Piece[] {
|
|
if (theme === 'simple') {
|
|
const count = big ? 170 : 80;
|
|
return Array.from({ length: count }, (_, id) => ({
|
|
id,
|
|
className: 'confetti-paper',
|
|
content: null,
|
|
style: {
|
|
...launchStyle(pick(LAUNCHES)),
|
|
width: `${rand(6, 11)}px`,
|
|
height: `${rand(10, 18)}px`,
|
|
background: pick(SIMPLE_COLORS),
|
|
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
|
|
},
|
|
}));
|
|
}
|
|
const count = big ? 50 : 24;
|
|
return Array.from({ length: count }, (_, id) => {
|
|
const roll = Math.random();
|
|
// Samolepky se tolik netočí, ať jsou čitelné
|
|
const base: React.CSSProperties = {
|
|
...launchStyle(pick(LAUNCHES)),
|
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
|
};
|
|
if (images.length && roll < 0.45) {
|
|
const image = pick(images);
|
|
return { id, className: `confetti-uma confetti-uma-img${image.round ? ' round' : ''}`, style: base,
|
|
content: <img src={image.src} alt="" loading="eager" /> };
|
|
}
|
|
if (roll < 0.7) {
|
|
return { id, className: 'confetti-uma confetti-uma-badge', content: pick(UMA_BADGES),
|
|
style: { ...base, background: pick(UMA_BADGE_COLORS) } };
|
|
}
|
|
return { id, className: 'confetti-uma confetti-uma-emoji', style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(UMA_EMOJI) };
|
|
});
|
|
}
|
|
|
|
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. */
|
|
export default function WheelConfetti({ theme, burstKey, big = false }: Readonly<Props>) {
|
|
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
|
|
const [active, setActive] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (theme === 'umamusume') loadUmaCharacters().then(setCharacters);
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
if (!burstKey) return;
|
|
setActive(true);
|
|
const t = setTimeout(() => setActive(false), DURATION_MS + 900);
|
|
return () => clearTimeout(t);
|
|
}, [burstKey]);
|
|
|
|
const pieces = useMemo(
|
|
() => buildPieces(theme, big, characters.flatMap(c => c.images.map(src => ({ src, round: !!c.round })))),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[burstKey],
|
|
);
|
|
|
|
// Při finálním výsledku „promluví" náhodná postava svou hláškou
|
|
const speaker = useMemo(() => {
|
|
const withQuote = characters.filter(c => c.quote);
|
|
return theme === 'umamusume' && big && withQuote.length ? pick(withQuote) : undefined;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [burstKey]);
|
|
|
|
if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
|
|
|
|
return createPortal(
|
|
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
|
|
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
|
|
{pieces.map(p => <span key={p.id} className={p.className} style={p.style}>{p.content}</span>)}
|
|
{speaker && (
|
|
<div className="confetti-uma-voice">
|
|
<img src={pick(speaker.images)} alt="" className={speaker.round ? 'round' : undefined} />
|
|
<div className="confetti-uma-bubble">
|
|
<p>„{speaker.quote}"</p>
|
|
<small>— {speaker.name}</small>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|