Files
ForbiddenStarsApp/frontend/src/pages/CreateMatchPage.tsx
T
NotBigGhostandClaude Fable 5 93d81010f7 UI: выбор фракции — выпадашка в стиле выбора игрока, без занятых фракций
PlayerSelect обобщён в PickerSelect (рендер опций задаёт родитель) — им
оформлены оба поля создания партии. Фракции, выбранные в других строках,
не предлагаются (сервер при рандоме и так их исключал); набор фракций
по-прежнему приходит из available_factions группы с учётом дополнений.
Выбранное значение ищется в полном списке, а не в отфильтрованном:
после «Рандом всем» с повторами фракция строки может быть занята соседней.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 03:23:14 +03:00

259 lines
9.8 KiB
TypeScript

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 {
user_id: number | "";
faction_id: number | "";
was_random: boolean;
}
function emptyDraft(): Draft {
return { 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<Draft[]>([emptyDraft(), emptyDraft()]);
const [error, setError] = useState<string | null>(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 (!groupId) return <div className="muted">Нет активной группы.</div>;
const takenFactionIds = (exceptIdx: number) =>
rows
.filter((_, i) => i !== exceptIdx)
.map((r) => r.faction_id)
.filter((x): x is number => typeof x === "number");
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
const takenUserIds = (exceptIdx: number) =>
rows
.filter((_, i) => i !== exceptIdx)
.map((r) => r.user_id)
.filter((x): x is number => typeof x === "number");
const update = (idx: number, patch: Partial<Draft>) =>
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
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 pool = factions ?? [];
if (pool.length === 0 || spin[idx] != null) return;
try {
const faction = await randomize.mutateAsync({
group_id: groupId,
exclude_faction_ids: takenFactionIds(idx),
});
await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () =>
update(idx, { faction_id: faction.id, was_random: true }),
);
} catch (e) {
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
setSpin((s) => ({ ...s, [idx]: null }));
toast.show(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((_, idx) => {
const f = pool[idx % pool.length];
return animateReel(idx, names, f.name_ru, () =>
update(idx, { 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,
})),
});
toast.show("Партия начата");
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) => (
<>
<Avatar url={m.avatar_url} nickname={m.nickname} size={size} />
<span className="nick">{m.nickname}</span>
</>
);
return (
<div>
<div className="card">
<h3>Этап 1 — старт партии</h3>
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
<button className="btn" onClick={randomizeAll} disabled={spinning}>
<Dices size={18} /> Рандом всем
</button>
</div>
{rows.map((row, idx) => (
<div className="participant-row" key={idx}>
<div className="row-between">
<b>Игрок {idx + 1}</b>
{rows.length > 2 && (
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}>
<Trash2 size={16} />
</button>
)}
</div>
<PickerSelect
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))}
placeholder="— игрок —"
renderOption={(m) => playerLabel(m, 28)}
renderValue={(m) => playerLabel(m, 24)}
onPick={(m) => update(idx, { user_id: m.id })}
/>
<div className="row">
{spin[idx] != null ? (
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
) : (
<PickerSelect
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))}
placeholder="— фракция —"
renderOption={(f) => f.name_ru}
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })}
/>
)}
<button
className="btn"
onClick={() => randomizeRow(idx)}
disabled={spin[idx] != null}
title="Случайная фракция"
>
<Dices size={18} />
</button>
</div>
</div>
))}
{rows.length < Math.min(MAX_MATCH_PLAYERS, memberOptions.length) && (
<button className="btn btn-ghost" onClick={addRow}>
+ Добавить игрока
</button>
)}
{error && <p className="error-text">{error}</p>}
<button
className="btn btn-primary btn-block"
style={{ marginTop: 12 }}
onClick={submit}
disabled={startMatch.isPending}
>
Начать партию
</button>
</div>
);
}