import { Dices, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; import { PickerSelect } from "../components/PickerSelect"; import { Spinner } from "../components/Spinner"; import { MAX_MATCH_PLAYERS } from "../domain/constants"; import { useToast } from "../context/ToastContext"; import type { MemberRead } from "../domain/types"; import { useMe } from "../hooks/auth"; import { useGroupFactions } from "../hooks/reference"; import { useGroupMembers } from "../hooks/groups"; import { useRandomizeFaction, useStartMatch } from "../hooks/matches"; interface Draft { // Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома // отложенно дописывает результат в свою строку и промахнулся бы по соседа. id: number; user_id: number | ""; faction_id: number | ""; was_random: boolean; } let draftSeq = 0; function emptyDraft(): Draft { // Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах // страницы, а сквозная нумерация избавляет от коллизий при перемонтировании. draftSeq += 1; return { id: draftSeq, user_id: "", faction_id: "", was_random: false }; } export function CreateMatchPage() { const { data: me } = useMe(); const groupId = me?.active_group_id ?? null; const { data: members, isLoading: lm } = useGroupMembers(groupId); const { data: factions, isLoading: lf } = useGroupFactions(groupId); const startMatch = useStartMatch(); const randomize = useRandomizeFaction(); const toast = useToast(); const navigate = useNavigate(); const [rows, setRows] = useState([emptyDraft(), emptyDraft()]); const [error, setError] = useState(null); // Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название. const [spin, setSpin] = useState>({}); const reelTimers = useRef>>({}); // Снимаем таймеры барабана при размонтировании. useEffect( () => () => { Object.values(reelTimers.current).forEach((t) => clearTimeout(t)); }, [], ); if (lm || lf) return ; if (!groupId) return
Нет активной группы.
; const takenFactionIds = (exceptId: number) => rows .filter((r) => r.id !== exceptId) .map((r) => r.faction_id) .filter((x): x is number => typeof x === "number"); // Игроки, уже выбранные в других строках, — в списке не предлагаются. const takenUserIds = (exceptId: number) => rows .filter((r) => r.id !== exceptId) .map((r) => r.user_id) .filter((x): x is number => typeof x === "number"); const update = (id: number, patch: Partial) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); const addRow = () => setRows((rs) => [...rs, emptyDraft()]); const removeRow = (id: number) => { const timer = reelTimers.current[id]; if (timer) clearTimeout(timer); delete reelTimers.current[id]; setSpin((sp) => { const { [id]: _dropped, ...rest } = sp; return rest; }); setRows((rs) => rs.filter((r) => r.id !== id)); }; // «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление), // затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только // после этого фракция «выбирается» (onReveal: показываем select с результатом). const animateReel = ( id: number, names: string[], finalName: string, onReveal: () => void, ): Promise => new Promise((resolve) => { let delay = 50; const tick = () => { setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] })); delay *= 1.18; if (delay < 300) { reelTimers.current[id] = setTimeout(tick, delay); } else { reelTimers.current[id] = setTimeout(() => { setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной reelTimers.current[id] = setTimeout(() => { onReveal(); setSpin((s) => ({ ...s, [id]: null })); resolve(); }, 500); // пауза перед «выбором» }, delay); } }; tick(); }); const randomizeRow = async (id: number) => { const pool = factions ?? []; if (pool.length === 0 || spin[id] != null) return; try { const faction = await randomize.mutateAsync({ group_id: groupId, exclude_faction_ids: takenFactionIds(id), }); await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () => update(id, { faction_id: faction.id, was_random: true }), ); } catch (e) { if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]); setSpin((s) => ({ ...s, [id]: null })); toast.error(e instanceof ApiError ? e.message : "Ошибка рандома"); } }; const randomizeAll = async () => { const all = factions ?? []; if (all.length === 0) return; const pool = [...all]; for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы."); const names = all.map((f) => f.name_ru); // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию. await Promise.all( rows.map((row, idx) => { const f = pool[idx % pool.length]; return animateReel(row.id, names, f.name_ru, () => update(row.id, { faction_id: f.id, was_random: true }), ); }), ); }; const submit = async () => { setError(null); const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== ""); if (filled.length < 2) { setError("Нужно минимум 2 игрока с выбранной фракцией."); return; } if (filled.length > MAX_MATCH_PLAYERS) { setError(`Максимум ${MAX_MATCH_PLAYERS} игроков в партии.`); return; } try { const match = await startMatch.mutateAsync({ group_id: groupId, participants: filled.map((r) => ({ user_id: r.user_id as number, faction_id: r.faction_id as number, was_random: r.was_random, })), }); navigate(`/match/${match.id}`, { replace: true }); } catch (e) { setError(e instanceof ApiError ? e.message : "Не удалось начать партию"); } }; const memberOptions = (members ?? []) as MemberRead[]; const spinning = Object.values(spin).some((v) => v != null); // Опции выпадашек. Игрок — аватар + ник (в триггере аватар мельче, чтобы // поле не разъезжалось по высоте с соседними). const playerOptions = memberOptions.map((m) => ({ id: m.user_id, nickname: m.nickname, avatar_url: m.avatar_url, })); const playerLabel = (m: { nickname: string; avatar_url?: string | null }, size: number) => ( <> {m.nickname} ); return (

Этап 1 — старт партии

Выберите игроков и фракции. Места и итоги укажете при завершении.

{rows.map((row, idx) => (
Игрок {idx + 1} {rows.length > 2 && ( )}
m.id === row.user_id) ?? null} options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))} placeholder="— игрок —" renderOption={(m) => playerLabel(m, 28)} renderValue={(m) => playerLabel(m, 24)} onPick={(m) => update(row.id, { user_id: m.id })} />
{spin[row.id] != null ? (
{spin[row.id]}
) : ( f.id === row.faction_id) ?? null} options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))} placeholder="— фракция —" renderOption={(f) => f.name_ru} onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })} /> )}
))} {rows.length < Math.min(MAX_MATCH_PLAYERS, memberOptions.length) && ( )} {error &&

{error}

}
); }