Skip to content

Commit d8d2493

Browse files
committed
НОВАЯ МИКУ РУЛЕТКА, ПОКА НЕ ДО КОНЦА
1 parent 9d27444 commit d8d2493

File tree

15 files changed

+667
-123
lines changed

15 files changed

+667
-123
lines changed

src/components/OBS_Components/MikuMonday/MikuMonday.tsx

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "react-roulette-pro/dist/index.css";
22

3-
import { useCallback, useEffect, useRef, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44

55
import IntroStage from "./components/stages/IntroStage";
66
import ResultStage from "./components/stages/ResultStage";
@@ -22,6 +22,49 @@ function MikuMondayContent() {
2222

2323
const rouletteGroups = useRouletteGroups(currentAlert);
2424

25+
// Получаем выигрышный трек из рулетки
26+
const winnerTrack = useMemo(() => {
27+
if (!currentAlert) return undefined;
28+
29+
const winnerGroup = rouletteGroups.find(group => group.hasWinner);
30+
if (!winnerGroup) {
31+
console.log(
32+
"[MikuMonday] Нет группы с победителем, используем исходный трек"
33+
);
34+
return currentAlert.selectedTrack;
35+
}
36+
37+
const winnerPrize = winnerGroup.prizes[winnerGroup.prizeIndex];
38+
if (!winnerPrize) {
39+
console.warn("[MikuMonday] Не найден приз для победителя");
40+
return currentAlert.selectedTrack;
41+
}
42+
43+
// Ищем полный объект трека в availableTracks
44+
const fullTrack = currentAlert.availableTracks.find(
45+
track => track.id === winnerPrize.id
46+
);
47+
48+
if (!fullTrack) {
49+
console.warn("[MikuMonday] Не найден полный объект трека", {
50+
prizeId: winnerPrize.id,
51+
prizeText: winnerPrize.text,
52+
});
53+
return currentAlert.selectedTrack;
54+
}
55+
56+
console.log("[MikuMonday] Выигрышный трек определен", {
57+
originalTrackId: currentAlert.selectedTrack.id,
58+
originalTrackNumber: currentAlert.selectedTrack.number,
59+
winnerTrackId: fullTrack.id,
60+
winnerTrackNumber: fullTrack.number,
61+
winnerArtist: fullTrack.artist,
62+
winnerTitle: fullTrack.title,
63+
});
64+
65+
return fullTrack;
66+
}, [currentAlert, rouletteGroups]);
67+
2568
const [stage, setStage] = useState<StageKey>("waiting");
2669
const waitingTimeoutRef = useRef<number | null>(null);
2770
const stageSyncTimeoutRef = useRef<number | null>(null);
@@ -72,30 +115,51 @@ function MikuMondayContent() {
72115
currentAlert?.skipAvailableTracksUpdate === true;
73116

74117
const handleIntroComplete = useCallback(() => {
118+
console.log("[MikuMonday] handleIntroComplete вызван", {
119+
rouletteGroupsLength: rouletteGroups.length,
120+
currentAlertId: currentAlert?.id,
121+
trackNumber: currentAlert?.selectedTrack.number,
122+
});
75123
if (rouletteGroups.length > 0) {
124+
console.log("[MikuMonday] Переходим на рулетку");
76125
setStage("roulette");
77126
return;
78127
}
128+
console.log("[MikuMonday] Нет рулеток, переходим на результат");
79129
setStage("result");
80-
}, [rouletteGroups.length]);
130+
}, [rouletteGroups.length, currentAlert]);
81131

82132
const handleRouletteComplete = useCallback(() => {
83133
setStage("result");
84134
}, []);
85135

86136
const handleResultComplete = useCallback(() => {
137+
console.log("[MikuMonday] handleResultComplete вызван", {
138+
currentAlertId: currentAlert?.id,
139+
queueId: currentAlert?.queueId,
140+
displayName: currentAlert?.twitchUser.displayName,
141+
trackNumber: currentAlert?.selectedTrack.number,
142+
});
87143
setStage("waiting");
88144
if (waitingTimeoutRef.current !== null) {
89145
window.clearTimeout(waitingTimeoutRef.current);
90146
}
91147

92148
waitingTimeoutRef.current = window.setTimeout(() => {
149+
console.log("[MikuMonday] Вызываем dequeueCurrent после паузы", {
150+
pauseMs: QUEUE_PAUSE_MS,
151+
});
93152
waitingTimeoutRef.current = null;
94153
dequeueCurrent();
95154
const nextAlert = useMikuMondayStore.getState().currentAlert;
155+
console.log("[MikuMonday] Текущий статус после деквеу", {
156+
nextAlertId: nextAlert?.id,
157+
nextAlertDisplayName: nextAlert?.twitchUser.displayName,
158+
nextAlertTrackNumber: nextAlert?.selectedTrack.number,
159+
});
96160
setStage(nextAlert ? "intro" : "waiting");
97161
}, QUEUE_PAUSE_MS);
98-
}, [dequeueCurrent]);
162+
}, [dequeueCurrent, currentAlert]);
99163

100164
if (!currentAlert || stage === "waiting") {
101165
return (
@@ -130,11 +194,24 @@ function MikuMondayContent() {
130194

131195
return (
132196
<div className={styles.layout}>
133-
<ResultStage
134-
track={currentAlert.selectedTrack}
135-
twitchUser={currentAlert.twitchUser}
136-
onComplete={handleResultComplete}
137-
/>
197+
{(() => {
198+
const trackToDisplay = winnerTrack ?? currentAlert.selectedTrack;
199+
console.log("[MikuMonday] 📺 Отправляем трек в ResultStage", {
200+
trackId: trackToDisplay.id,
201+
trackNumber: trackToDisplay.number,
202+
trackTitle: trackToDisplay.title,
203+
trackArtist: trackToDisplay.artist,
204+
isWinnerTrack: winnerTrack !== undefined,
205+
winnerGroupExists: rouletteGroups.some(g => g.hasWinner),
206+
});
207+
return (
208+
<ResultStage
209+
track={trackToDisplay}
210+
twitchUser={currentAlert.twitchUser}
211+
onComplete={handleResultComplete}
212+
/>
213+
);
214+
})()}
138215
</div>
139216
);
140217
}

src/components/OBS_Components/MikuMonday/MikuMondayController.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ export default function MikuMondayController() {
1818
const isAlertShowing = useMikuMondayStore(state => state.isAlertShowing);
1919
const { showToast } = useToastModal();
2020

21+
useEffect(() => {
22+
console.log("[MikuMondayController] currentAlert изменился", {
23+
alertId: currentAlert?.id,
24+
queueId: currentAlert?.queueId,
25+
displayName: currentAlert?.twitchUser.displayName,
26+
trackNumber: currentAlert?.selectedTrack.number,
27+
isAlertShowing,
28+
});
29+
}, [
30+
currentAlert?.id,
31+
currentAlert?.queueId,
32+
currentAlert?.selectedTrack.number,
33+
currentAlert?.twitchUser.displayName,
34+
isAlertShowing,
35+
]);
36+
2137
useEffect(() => {
2238
startHub().catch(err => {
2339
showToast(
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
.roulette {
2+
width: 100%;
3+
height: 100%;
4+
position: relative;
5+
overflow: hidden;
6+
display: flex;
7+
align-items: center;
8+
background: rgba(0, 0, 0, 0.3);
9+
border-radius: 8px;
10+
}
11+
12+
.track {
13+
display: flex;
14+
align-items: center;
15+
will-change: transform;
16+
padding: 20px 0;
17+
}
18+
19+
.prize {
20+
flex-shrink: 0;
21+
display: flex;
22+
align-items: flex-end;
23+
justify-content: center;
24+
position: relative;
25+
border: 2px solid rgba(57, 197, 187, 0.3);
26+
border-radius: 8px;
27+
overflow: hidden;
28+
transition: all 0.3s;
29+
height: 210px;
30+
31+
&:hover {
32+
border-color: rgba(57, 197, 187, 0.8);
33+
}
34+
}
35+
36+
.prizeImage {
37+
position: absolute;
38+
top: 0;
39+
left: 0;
40+
width: 100%;
41+
height: 100%;
42+
object-fit: cover;
43+
z-index: 0;
44+
}
45+
46+
.prizeText {
47+
position: relative;
48+
z-index: 1;
49+
font-size: 14px;
50+
font-weight: 600;
51+
color: #fff;
52+
text-align: center;
53+
word-break: break-word;
54+
padding: 12px;
55+
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.7) 50%, transparent 100%);
56+
width: 100%;
57+
line-height: 1.3;
58+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
59+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
import type { RoulettePrize } from "../../types";
4+
import styles from "./CustomRoulette.module.scss";
5+
6+
interface CustomRouletteProps {
7+
prizes: RoulettePrize[];
8+
prizeIndex: number;
9+
isReversed?: boolean;
10+
start: boolean;
11+
spinningTime?: number;
12+
onComplete?: () => void;
13+
}
14+
15+
const PRIZE_WIDTH = 200; // ширина одного приза
16+
const PRIZE_GAP = 10; // отступ между призами
17+
const SPIN_DURATION = 20; // секунды
18+
19+
export default function CustomRoulette({
20+
prizes,
21+
prizeIndex,
22+
isReversed = false,
23+
start,
24+
spinningTime = SPIN_DURATION,
25+
onComplete,
26+
}: CustomRouletteProps) {
27+
const containerRef = useRef<HTMLDivElement>(null);
28+
const [translateX, setTranslateX] = useState(0);
29+
const [isSpinning, setIsSpinning] = useState(false);
30+
31+
useEffect(() => {
32+
if (!start || isSpinning || prizes.length === 0) return;
33+
34+
console.log("[CustomRoulette] Начинаем вращение", {
35+
prizeIndex,
36+
prizesCount: prizes.length,
37+
isReversed,
38+
spinningTime,
39+
});
40+
41+
setIsSpinning(true);
42+
43+
// Создаем зацикленный массив призов (повторяем 5 раз для плавной прокрутки)
44+
const containerWidth = containerRef.current?.offsetWidth || 1000;
45+
const totalPrizeWidth = PRIZE_WIDTH + PRIZE_GAP;
46+
47+
// Центр контейнера
48+
const centerOffset = containerWidth / 2 - PRIZE_WIDTH / 2;
49+
50+
// Вычисляем сколько полных прокруток сделать (3-5 оборотов)
51+
const totalPrizes = prizes.length;
52+
53+
// Индекс выигрышного приза в повторяющемся массиве (берем средний повтор)
54+
const middleRepeat = 2; // из 5 повторов берем средний
55+
const targetPrizeIndex = middleRepeat * totalPrizes + prizeIndex;
56+
57+
// Финальная позиция выигрышного приза
58+
const targetPosition = targetPrizeIndex * totalPrizeWidth;
59+
60+
// Финальная позиция с центрированием
61+
const endPosition = centerOffset - targetPosition;
62+
63+
console.log("[CustomRoulette] Расчет позиций", {
64+
endPosition,
65+
targetPosition,
66+
targetPrizeIndex,
67+
centerOffset,
68+
});
69+
70+
// Запускаем анимацию
71+
setTranslateX(endPosition);
72+
73+
// Уведомляем о завершении
74+
const timer = setTimeout(() => {
75+
console.log("[CustomRoulette] Вращение завершено", {
76+
prizeIndex,
77+
finalPrize: prizes[prizeIndex],
78+
});
79+
setIsSpinning(false);
80+
onComplete?.();
81+
}, spinningTime * 1000);
82+
83+
return () => clearTimeout(timer);
84+
}, [start, prizes, prizeIndex, isReversed, spinningTime, onComplete]);
85+
86+
// Создаем повторяющийся массив призов
87+
const repeatedPrizes = Array(5).fill(prizes).flat();
88+
89+
return (
90+
<div className={styles.roulette} ref={containerRef}>
91+
<div
92+
className={styles.track}
93+
style={{
94+
transform: `translateX(${translateX}px)`,
95+
transition: isSpinning
96+
? `transform ${spinningTime}s cubic-bezier(0.17, 0.67, 0.12, 0.99)`
97+
: "none",
98+
}}
99+
>
100+
{repeatedPrizes.map((prize, index) => (
101+
<div
102+
key={`${prize.id}-${index}`}
103+
className={styles.prize}
104+
style={{
105+
width: `${PRIZE_WIDTH}px`,
106+
marginRight: `${PRIZE_GAP}px`,
107+
}}
108+
>
109+
{prize.image && (
110+
<img
111+
src={prize.image}
112+
alt={prize.text}
113+
className={styles.prizeImage}
114+
/>
115+
)}
116+
<div className={styles.prizeText}>{prize.text}</div>
117+
</div>
118+
))}
119+
</div>
120+
</div>
121+
);
122+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./CustomRoulette";
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.container {
2+
width: 100%;
3+
height: 100%;
4+
display: flex;
5+
flex-direction: column;
6+
gap: 16px;
7+
padding: 20px;
8+
position: relative;
9+
}
10+
11+
.rouletteWrapper {
12+
flex: 1;
13+
min-height: 0;
14+
position: relative;
15+
mask-image: linear-gradient(
16+
to right,
17+
rgba(0, 0, 0, 0) 0%,
18+
rgba(0, 0, 0, 1) 25%,
19+
rgba(0, 0, 0, 1) 75%,
20+
rgba(0, 0, 0, 0) 100%
21+
);
22+
-webkit-mask-image: linear-gradient(
23+
to right,
24+
rgba(0, 0, 0, 0) 0%,
25+
rgba(0, 0, 0, 1) 25%,
26+
rgba(0, 0, 0, 1) 75%,
27+
rgba(0, 0, 0, 0) 100%
28+
);
29+
30+
&.reversed {
31+
// Для reversed рулеток можно добавить особые стили
32+
}
33+
}

0 commit comments

Comments
 (0)