CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 44s
CI / Playwright E2E tests (push) Successful in 2m4s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s
279 lines
16 KiB
TypeScript
279 lines
16 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 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<Record<WheelTheme, {
|
|
prefix: string, badges: string[], colors: string[], emoji: string[],
|
|
speakers: { name: string, emoji: string, color: string, quote: string }[],
|
|
banner: string,
|
|
}>> = {
|
|
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 = <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;
|
|
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: <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]);
|
|
|
|
// 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(
|
|
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
|
|
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
|
|
{emojiConfetti && big && <div className={`${emojiConfetti.prefix}-banner`}>{emojiConfetti.banner}</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>
|
|
)}
|
|
{emojiSpeaker && (
|
|
<div className={`confetti-uma-voice confetti-${theme}-voice`} style={{ ['--mlp-speaker' as any]: emojiSpeaker.color }}>
|
|
<span className="confetti-mlp-avatar">{emojiSpeaker.emoji}</span>
|
|
<div className="confetti-mlp-bubble">
|
|
<p>„{emojiSpeaker.quote}"</p>
|
|
<small>— {emojiSpeaker.name}</small>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|