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 MLP_EMOJI = ['🦄', '🌈', '✨', '⭐', '🧁', '🍎', '🦋', '💎', '🎈', '🎉', '🍰', '💜']; // Hlášky a narážky z My Little Pony: Friendship is Magic const MLP_BADGES = ['Friendship is Magic!', '20% cooler!', 'Sonic Rainboom!', 'Yay!', 'Party!', 'Yeehaw!', 'Everypony!', 'Awesome!', 'Darling!', 'Pinkie Promise']; const MLP_BADGE_COLORS = ['#8e4fd1', '#f06aa6', '#1e98d3', '#f37033', '#e3b829', '#5b4fc9']; /** Postavy Mane 6 (+ Spike) s „hláškou" po finálním výsledku — místo obrázků cutie mark emoji. */ const MLP_SPEAKERS = [ { name: 'Twilight Sparkle', emoji: '⭐', color: '#8e4fd1', quote: 'Podle mého seznamu je to ta nejlogičtější volba!' }, { name: 'Rainbow Dash', emoji: '🌈', color: '#1e98d3', quote: 'Tohle je o 20 % úžasnější než ostatní!' }, { name: 'Pinkie Pie', emoji: '🎈', color: '#f06aa6', quote: 'Tohle volá po PÁRTY! S dortem! A s cupcaky!' }, { name: 'Applejack', emoji: '🍎', color: '#f37033', quote: 'Poctivé jídlo pro poctivou práci. Jíhá!' }, { name: 'Fluttershy', emoji: '🦋', color: '#e3b829', quote: 'Jé… teda, jestli to nevadí… yay.' }, { name: 'Rarity', emoji: '💎', color: '#5b4fc9', quote: 'Naprosto božská volba, drahoušku!' }, { name: 'Spike', emoji: '🐉', color: '#62bc4d', quote: 'Zapsáno do svitku a odesláno princezně!' }, ]; const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸']; // Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...) const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena']; const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24']; /** Ai Enma a její služebníci s „hláškou" po finálním výsledku — místo obrázků emoji. */ const HG_SPEAKERS = [ { name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tenhle oběd tě bude provázet až na věčnost.' }, { name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechny podniky. Tenhle jediný obstál.' }, { name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' }, { name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' }, { name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' }, { name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, oběd je na stole… jako každý den už čtyři sta let.' }, ]; const KON_EMOJI = ['🎸', '🥁', '🎹', '🎵', '🎶', '🍰', '☕', '🍓', '🐢', '🎀', '🍪', '🫖']; // Hlášky z K-ON! (Houkago Tea Time, Fuwa Fuwa Time, Gitah, Ton-chan, Mugi a její čaj...) const KON_BADGES = ['放課後ティータイム', 'Fuwa Fuwa Time ♪', 'Tea time!', 'Gitah!', 'Ton-chan ♡', 'けいおん!', 'Nejdřív čaj, pak zkouška', "Don't say lazy", 'Azunyan~', 'Ui!']; const KON_BADGE_COLORS = ['#d43f62', '#3d6fc4', '#e8a10f', '#8a6bc7', '#e46f9a', '#27315f']; /** Členky Houkago Tea Time (+ Ui, Sawa-chan a Ton-chan) s „hláškou" po finálním výsledku. */ const KON_SPEAKERS = [ { name: 'Yui Hirasawa', emoji: '🎸', color: '#d43f62', quote: 'Gitah říká, že tohle bude mňamka! Fuwa fuwa~' }, { name: 'Mio Akiyama', emoji: '🎼', color: '#3d6fc4', quote: 'Dobře… ale hned po obědě se jde zkoušet! Opravdu!' }, { name: 'Ritsu Tainaka', emoji: '🥁', color: '#e8a10f', quote: 'Prezidentka klubu rozhodla! Raz, dva, raz dva tři čtyři!' }, { name: 'Tsumugi Kotobuki', emoji: '🫖', color: '#c9a227', quote: 'Čaj a dortík k tomu přinesu já. Těším se!' }, { name: 'Azusa Nakano', emoji: '🐱', color: '#8a6bc7', quote: 'Zase jídlo místo zkoušky? …No dobře, ale jen chvilku!' }, { name: 'Ui Hirasawa', emoji: '🍳', color: '#e46f9a', quote: 'Sestřičko, nezapomeň si vzít ubrousek!' }, { name: 'Sawako Yamanaka', emoji: '👗', color: '#27315f', quote: 'Oběd? Výborně. Mezitím vám ušiju nové kostýmy!' }, { name: 'Ton-chan', emoji: '🐢', color: '#3c9a6b', quote: '(spokojeně mává ploutvičkou)' }, ]; const BOCCHI_EMOJI = ['🎸', '🥁', '🎤', '📦', '🥭', '🟨', '🟦', '✨', '🌿', '🫠', '⭐', '🔊']; // Hlášky z Bocchi the Rock! (Kessoku Band, Kita-aura, krabice od manga, guitarhero, live house STARRY...) const BOCCHI_BADGES = ['結束バンド', 'Kessoku Band!', 'Kitaaan ✨', 'guitarhero', 'A-ano…', 'Live at STARRY', 'Bocchi-chan!', 'ぼっち・ざ・ろっく!', 'Mango box', 'Rock!']; const BOCCHI_BADGE_COLORS = ['#e0457b', '#e8b400', '#3a63c9', '#d93a30', '#1d1a22']; /** Kessoku Band a jejich okolí s „hláškou" po finálním výsledku. */ const BOCCHI_SPEAKERS = [ { name: 'Hitori Gotoh', emoji: '🎸', color: '#e0457b', quote: 'A-ano… t-tohle by šlo… teda, pokud to nikomu nevadí…' }, { name: 'Nijika Ijichi', emoji: '🥁', color: '#e8b400', quote: 'Tak jo, kapelo, jdeme jíst! Bocchi, vylez z té krabice!' }, { name: 'Ryo Yamada', emoji: '🌿', color: '#3a63c9', quote: 'Dobrá volba. Mimochodem… nepůjčíš mi na oběd?' }, { name: 'Ikuyo Kita', emoji: '✨', color: '#d93a30', quote: 'Kitaaan! Tohle se mi děsně líbí, vyfotím to na Instagram!' }, { name: 'Seika Ijichi', emoji: '🎛️', color: '#7a4fc0', quote: 'Najezte se pořádně. Večer v STARRY neodfláknete zkoušku.' }, { name: 'Futari Gotoh', emoji: '🐕', color: '#f08fb4', quote: 'Ségra zase obědvá ve skříni! Jimihen, pojď se podívat!' }, ]; /** Styly, jejichž konfety tvoří emoji a samolepky s hláškami (a po finále promluví postava s emoji). */ const EMOJI_CONFETTI: Partial> = { mlp: { prefix: 'confetti-mlp', badges: MLP_BADGES, colors: MLP_BADGE_COLORS, emoji: MLP_EMOJI, speakers: MLP_SPEAKERS, banner: '🌈 Sonic Rainboom! 🌈' }, hellgirl: { prefix: 'confetti-hg', badges: HG_BADGES, colors: HG_BADGE_COLORS, emoji: HG_EMOJI, speakers: HG_SPEAKERS, banner: '🏮 地獄流し 🏮' }, kon: { prefix: 'confetti-kon', badges: KON_BADGES, colors: KON_BADGE_COLORS, emoji: KON_EMOJI, speakers: KON_SPEAKERS, banner: '🎸 放課後ティータイム 🍰' }, bocchi: { prefix: 'confetti-bocchi', badges: BOCCHI_BADGES, colors: BOCCHI_BADGE_COLORS, emoji: BOCCHI_EMOJI, speakers: BOCCHI_SPEAKERS, banner: '🎸 結束バンド 🎸' }, }; const DURATION_MS = 6400; const rand = (min: number, max: number) => min + Math.random() * (max - min); const pick = (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; const emojiConfetti = EMOJI_CONFETTI[theme]; if (emojiConfetti) { const { prefix, badges, colors, emoji } = emojiConfetti; return Array.from({ length: count }, (_, id) => { const base: React.CSSProperties = { ...launchStyle(pick(LAUNCHES)), ['--r1' as any]: `${rand(-25, 25)}deg`, ['--r2' as any]: `${rand(-40, 40)}deg`, }; if (Math.random() < 0.35) { return { id, className: `${prefix} ${prefix}-badge`, content: pick(badges), style: { ...base, background: pick(colors) } }; } return { id, className: `${prefix} confetti-uma-emoji`, style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(emoji) }; }); } 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: }; } 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) { const [characters, setCharacters] = useState([]); 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]); // Ostatní tematické styly: hlášku pronese náhodná postava (Mane 6, Ai Enma, Houkago Tea Time, Kessoku Band...) const emojiConfetti = EMOJI_CONFETTI[theme]; const emojiSpeaker = useMemo( () => big && emojiConfetti ? pick(emojiConfetti.speakers) : undefined, // eslint-disable-next-line react-hooks/exhaustive-deps [burstKey], ); if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null; return createPortal(
{theme === 'umamusume' && big &&
🏆 1着 🏆
} {emojiConfetti && big &&
{emojiConfetti.banner}
} {pieces.map(p => {p.content})} {speaker && (

„{speaker.quote}"

— {speaker.name}
)} {emojiSpeaker && (
{emojiSpeaker.emoji}

„{emojiSpeaker.quote}"

— {emojiSpeaker.name}
)}
, document.body, ); }