From 9987407187e26f8cb1c6c101594d8549d2b07f23 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 25 Oct 2026 23:59:41 +0300 Subject: [PATCH] add matchmaking progress screen --- frontend/src/App.jsx | 38 ++++++- frontend/src/api.js | 4 + frontend/src/components/GameScreen.jsx | 55 +++++++++ frontend/src/components/SearchScreen.jsx | 83 ++++++++++++++ frontend/src/game.js | 125 +++++++++++++++++++++ frontend/src/matchmaking.js | 136 +++++++++++++++++++++++ frontend/src/styles.css | 80 +++++++++++++ 7 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/GameScreen.jsx create mode 100644 frontend/src/components/SearchScreen.jsx create mode 100644 frontend/src/game.js create mode 100644 frontend/src/matchmaking.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 13d7c3b..f26c6b3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,9 +1,17 @@ +import { useCallback, useState } from 'react' import CodeInput from './components/CodeInput' import ProfileBar from './components/ProfileBar' +import SearchScreen from './components/SearchScreen' +import GameScreen from './components/GameScreen' +import { useAuth } from './auth' export default function App() { + const { token } = useAuth() + const [screen, setScreen] = useState('menu') // 'menu' | 'search' | 'game' + const [gameSession, setGameSession] = useState(null) + const handleRandomBattle = () => { - // TODO: матчмейкинг случайного боя + setScreen('search') } const handleInvite = () => { @@ -15,6 +23,34 @@ export default function App() { console.log('join code:', code) } + const handleMatched = useCallback((match) => { + setGameSession({ + gameServerUrl: match.gameServerUrl, + gameToken: match.gameToken, + userToken: token, + }) + setScreen('game') + }, [token]) + + const handleLeaveGame = useCallback(() => { + setGameSession(null) + setScreen('menu') + }, []) + + if (screen === 'search' && token) { + return ( + setScreen('menu')} + /> + ) + } + + if (screen === 'game' && gameSession) { + return + } + return (
diff --git a/frontend/src/api.js b/frontend/src/api.js index 36c71a1..a5a6786 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -19,6 +19,10 @@ async function request(path, { method = 'GET', body, token, formData } = {}) { return data } +export function wsUrl(path = '') { + return `${API_URL.replace(/^http/, 'ws')}${path}` +} + export class ApiError extends Error { constructor(message, status) { super(message) diff --git a/frontend/src/components/GameScreen.jsx b/frontend/src/components/GameScreen.jsx new file mode 100644 index 0000000..94bfcf7 --- /dev/null +++ b/frontend/src/components/GameScreen.jsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react' +import { createGameConnection } from '../game' + +export default function GameScreen({ session, onLeave }) { + const [status, setStatus] = useState('connecting') // 'connecting' | 'ready' | 'error' + const [error, setError] = useState(null) + const attemptRef = useRef(0) + + useEffect(() => { + const attempt = ++attemptRef.current + let cancelled = false + const connection = createGameConnection(session) + + connection.promise + .then(() => { + if (!cancelled && attempt === attemptRef.current) setStatus('ready') + }) + .catch((err) => { + if (cancelled || attempt !== attemptRef.current) return + setError(err.message || 'Не удалось подключиться к игре') + setStatus('error') + }) + + return () => { + cancelled = true + connection.cancel() + } + }, [session]) + + return ( +
+
+ {status === 'connecting' && ( + <> +
Подключение к игре...
+ + + )} + {status === 'ready' &&
Расстановка кораблей
} + {status === 'error' && ( + <> +
{error}
+ + + )} +
+
+ ) +} diff --git a/frontend/src/components/SearchScreen.jsx b/frontend/src/components/SearchScreen.jsx new file mode 100644 index 0000000..9a35586 --- /dev/null +++ b/frontend/src/components/SearchScreen.jsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from 'react' +import { createMatchmaking, MatchmakingError } from '../matchmaking' + +const STAGE_LABELS = { + connecting: 'Подключение...', + searching: 'Подбор соперника...', +} + +const ERROR_LABELS = { + timeout: 'Соперник не найден. Попробуйте ещё раз.', + connection: 'Сетевая ошибка. Проверьте подключение и попробуйте снова.', + rejected: 'Не удалось начать поиск. Попробуйте позже.', +} + +export default function SearchScreen({ token, onMatched, onCancel }) { + const [stage, setStage] = useState('connecting') // 'connecting' | 'searching' + const [error, setError] = useState(null) + const [retryToken, setRetryToken] = useState(0) + const attemptRef = useRef(0) + + useEffect(() => { + const attempt = ++attemptRef.current + let cancelled = false + let matchmaking = null + + setStage('connecting') + setError(null) + + matchmaking = createMatchmaking(token, (status) => { + if (!cancelled && attempt === attemptRef.current) setStage(status) + }) + + matchmaking.promise.then(onMatched).catch((err) => { + if (cancelled || attempt !== attemptRef.current) return + if (err instanceof MatchmakingError) { + setError(ERROR_LABELS[err.code] || ERROR_LABELS.connection) + } else { + setError(ERROR_LABELS.connection) + } + }) + + return () => { + cancelled = true + matchmaking.cancel() + } + }, [token, onMatched, retryToken]) + + const handleCancel = () => { + onCancel() + } + + return ( +
+
+ {error ? ( + <> +
{error}
+
+ + +
+ + ) : ( + <> +
{STAGE_LABELS[stage]}
+ + + + )} +
+
+ ) +} diff --git a/frontend/src/game.js b/frontend/src/game.js new file mode 100644 index 0000000..6aaa35f --- /dev/null +++ b/frontend/src/game.js @@ -0,0 +1,125 @@ +export class GameConnectionError extends Error { + constructor(code, message) { + super(message) + this.code = code // 'connection' | 'join_timeout' | 'closed' + } +} + +// Опкоды протокола game-server +const OP_JOIN = 1 +const OP_END = 2 +const OP_STATE = 3 +const OP_ACK = 4 + +const CLOSE_REASONS = { + 4000: 'Соединение заменено новым', + 4001: 'Ожидалось сообщение JOIN', + 4002: 'Некорректное сообщение', + 4003: 'Недействительный игровой токен', + 4004: 'Вы не участник этой партии', +} + +/** + * Подключается к game-server и отправляет JOIN. Резолвится, когда партия + * началась (получен STATE с кодом 1 — расстановка кораблей). Возвращает + * объект с promise и функцией отмены `cancel()`. + * + * @param {{ gameServerUrl: string, gameToken: string, userToken: string }} session + */ +export function createGameConnection({ gameServerUrl, gameToken, userToken }) { + let ws = null + let settled = false + let lastSeq = 0 + + const promise = new Promise((resolve, reject) => { + const fail = (err) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + + const succeed = (connection) => { + if (settled) return + settled = true + resolve(connection) + // Сокет НЕ закрываем — соединение переиспользуется для игры + ws.onerror = null + ws.onclose = (event) => { + if (connection.onClose) connection.onClose(event.code) + } + } + + ws = new WebSocket(gameServerUrl) + + ws.onopen = () => { + ws.send(JSON.stringify([OP_JOIN, gameToken, userToken])) + } + + ws.onmessage = (event) => { + let msg + try { + msg = JSON.parse(event.data) + } catch { + return + } + if (!Array.isArray(msg) || typeof msg[0] !== 'number') return + + const [opcode, seq] = msg + if (typeof seq === 'number' && seq > lastSeq) { + lastSeq = seq + ack(seq) + } + + if (opcode === OP_STATE) { + const state = msg[2] + if (state === 1) { + succeed({ socket: ws, send: sendMessage }) + } + } else if (opcode === OP_END) { + const code = msg[2] + const reason = msg[3] || 'Партия завершена' + fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason)) + } + } + + ws.onerror = () => { + if (!settled) fail(new GameConnectionError('connection', 'Сетевая ошибка')) + } + + ws.onclose = (event) => { + if (!settled) { + const message = + CLOSE_REASONS[event.code] || + (event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано') + fail(new GameConnectionError('closed', message)) + } + } + }) + + function sendMessage(payload) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(payload)) + } + } + + function ack(seq) { + sendMessage([OP_ACK, seq]) + } + + function cleanup() { + if (ws) { + ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null + ws.close() + ws = null + } + } + + function cancel() { + if (settled) return + settled = true + cleanup() + } + + return { promise, cancel } +} diff --git a/frontend/src/matchmaking.js b/frontend/src/matchmaking.js new file mode 100644 index 0000000..3cedfb5 --- /dev/null +++ b/frontend/src/matchmaking.js @@ -0,0 +1,136 @@ +import { wsUrl } from './api' + +const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' }) + +export class MatchmakingError extends Error { + constructor(code, message) { + super(message) + this.code = code // 'connection' | 'timeout' | 'rejected' + } +} + +/** + * Подключается к main-server по WebSocket (Action Cable) и инициирует + * поиск соперника. Возвращает объект с promise (резолвится при событии + * `matched`) и функцией отмены `cancel()`. + * + * @param {string} token JWT пользователя + * @param {(status: 'connecting' | 'searching') => void} onStatus + */ +export function createMatchmaking(token, onStatus) { + let ws = null + let settled = false + + const send = (payload) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(payload)) + } + } + + const promise = new Promise((resolve, reject) => { + const fail = (err) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + + const succeed = (match) => { + if (settled) return + settled = true + cleanup() + resolve(match) + } + + onStatus('connecting') + + ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`)) + let subscribed = false + + ws.onopen = () => { + send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER }) + } + + ws.onmessage = (event) => { + let msg + try { + msg = JSON.parse(event.data) + } catch { + return + } + + if (msg.type === 'confirm_subscription') { + subscribed = true + onStatus('searching') + send({ + command: 'message', + identifier: CHANNEL_IDENTIFIER, + data: JSON.stringify({ action: 'start' }), + }) + return + } + + if (msg.type === 'reject_subscription') { + fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника')) + return + } + + if (msg.type === 'ping' || msg.type === 'welcome') return + + const message = msg.message + if (!message || typeof message !== 'object') return + + if (message.type === 'matched') { + succeed({ + matchId: message.match_id, + gameToken: message.game_token, + gameServerUrl: message.game_server, + players: message.players, + }) + } else if (message.type === 'timeout') { + fail(new MatchmakingError('timeout', 'Соперник не найден')) + } + } + + ws.onerror = () => { + if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка')) + } + + ws.onclose = (event) => { + if (!settled) { + const err = new MatchmakingError('connection', 'Соединение прервано') + err.closeCode = event.code + fail(err) + } + } + + // Подписка не подтверждена — защита от «зависшего» соединения + setTimeout(() => { + if (!settled && !subscribed) { + fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу')) + } + }, 10000) + }) + + function cleanup() { + if (ws) { + ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null + ws.close() + ws = null + } + } + + /** Явная отмена поиска: команда `stop` + закрытие сокета. */ + function cancel() { + if (settled) return + settled = true + send({ + command: 'message', + identifier: CHANNEL_IDENTIFIER, + data: JSON.stringify({ action: 'stop' }), + }) + cleanup() + } + + return { promise, cancel } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6b71638..8cb7844 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -204,7 +204,87 @@ input { cursor: default; } +/* Экран поиска соперника */ +.search-screen { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 28px; + padding: 40px 20px 80px; +} + +.search-label { + font-family: 'Comfortaa'; + font-size: clamp(24px, 4vw, 36px); + font-weight: 700; + color: var(--text); + letter-spacing: 0.5px; +} + +.search-error { + font-family: 'Comfortaa'; + font-size: clamp(20px, 3.5vw, 28px); + font-weight: 700; + color: var(--danger); + text-align: center; + max-width: min(520px, 90vw); +} + +.search-actions { + display: flex; + flex-direction: column; + gap: 14px; + width: min(320px, 90vw); +} + +.btn-secondary { + color: var(--primary); + background: #fff; + border: 2px solid var(--primary-light); + box-shadow: 0 4px 0 rgba(29, 107, 216, 0.15); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--primary-soft); +} + +/* Три прыгающие точки */ +.bouncing-dots { + display: flex; + gap: 14px; +} + +.bouncing-dots span { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(180deg, var(--primary), var(--primary-dark)); + animation: dot-bounce 1s infinite ease-in-out; +} + +.bouncing-dots span:nth-child(2) { + animation-delay: 0.15s; +} + +.bouncing-dots span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes dot-bounce { + 0%, 80%, 100% { + transform: translateY(0); + opacity: 0.5; + } + 40% { + transform: translateY(-16px); + opacity: 1; + } +} + /* Профиль внизу */ + .profile-bar { display: flex; justify-content: center;