UI: рандом фракции — «Рандом всем» тоже анимируется; плавное замедление барабана и пауза 0.5с перед выбором

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:50:31 +03:00
co-authored by Claude Opus 4.8
parent 0745d07887
commit 75feb9d15a
+60 -17
View File
@@ -1,5 +1,5 @@
import { Dices, Trash2 } from "lucide-react"; import { Dices, Trash2 } from "lucide-react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
@@ -36,6 +36,15 @@ export function CreateMatchPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название. // Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название.
const [spin, setSpin] = useState<Record<number, string | null>>({}); const [spin, setSpin] = useState<Record<number, string | null>>({});
const reelTimers = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
// Снимаем таймеры барабана при размонтировании.
useEffect(
() => () => {
Object.values(reelTimers.current).forEach((t) => clearTimeout(t));
},
[],
);
if (lm || lf) return <Spinner />; if (lm || lf) return <Spinner />;
if (!groupId) return <div className="muted">Нет активной группы.</div>; if (!groupId) return <div className="muted">Нет активной группы.</div>;
@@ -52,39 +61,72 @@ export function CreateMatchPage() {
const addRow = () => setRows((rs) => [...rs, emptyDraft()]); const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
const animateReel = (
idx: number,
names: string[],
finalName: string,
onReveal: () => void,
): Promise<void> =>
new Promise((resolve) => {
let delay = 50;
const tick = () => {
setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] }));
delay *= 1.18;
if (delay < 300) {
reelTimers.current[idx] = setTimeout(tick, delay);
} else {
reelTimers.current[idx] = setTimeout(() => {
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной
reelTimers.current[idx] = setTimeout(() => {
onReveal();
setSpin((s) => ({ ...s, [idx]: null }));
resolve();
}, 500); // пауза перед «выбором»
}, delay);
}
};
tick();
});
const randomizeRow = async (idx: number) => { const randomizeRow = async (idx: number) => {
const pool = factions ?? []; const pool = factions ?? [];
if (pool.length === 0) return; if (pool.length === 0 || spin[idx] != null) return;
// Запускаем «барабан»: быстро мелькаем названиями фракций.
const ticker = setInterval(() => {
const name = pool[Math.floor(Math.random() * pool.length)].name_ru;
setSpin((s) => ({ ...s, [idx]: name }));
}, 60);
try { try {
const faction = await randomize.mutateAsync({ const faction = await randomize.mutateAsync({
group_id: groupId, group_id: groupId,
exclude_faction_ids: takenFactionIds(idx), exclude_faction_ids: takenFactionIds(idx),
}); });
await new Promise((r) => setTimeout(r, 700)); // даём барабану покрутиться await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () =>
clearInterval(ticker); update(idx, { faction_id: faction.id, was_random: true }),
setSpin((s) => ({ ...s, [idx]: null })); );
update(idx, { faction_id: faction.id, was_random: true });
} catch (e) { } catch (e) {
clearInterval(ticker); if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
setSpin((s) => ({ ...s, [idx]: null })); setSpin((s) => ({ ...s, [idx]: null }));
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
} }
}; };
const randomizeAll = () => { const randomizeAll = async () => {
const pool = [...(factions ?? [])]; const all = factions ?? [];
if (all.length === 0) return;
const pool = [...all];
for (let i = pool.length - 1; i > 0; i--) { for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]]; [pool[i], pool[j]] = [pool[j], pool[i]];
} }
if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы."); if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы.");
setRows((rs) => const names = all.map((f) => f.name_ru);
rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })), // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
await Promise.all(
rows.map((_, idx) => {
const f = pool[idx % pool.length];
return animateReel(idx, names, f.name_ru, () =>
update(idx, { faction_id: f.id, was_random: true }),
);
}),
); );
}; };
@@ -116,13 +158,14 @@ export function CreateMatchPage() {
}; };
const memberOptions = (members ?? []) as MemberRead[]; const memberOptions = (members ?? []) as MemberRead[];
const spinning = Object.values(spin).some((v) => v != null);
return ( return (
<div> <div>
<div className="card"> <div className="card">
<h3>Этап 1 — старт партии</h3> <h3>Этап 1 — старт партии</h3>
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p> <p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
<button className="btn" onClick={randomizeAll}> <button className="btn" onClick={randomizeAll} disabled={spinning}>
<Dices size={18} /> Рандом всем <Dices size={18} /> Рандом всем
</button> </button>
</div> </div>