From c198815a1827b259b4024cb53a9ee78f825de322 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Mon, 2 Nov 2026 23:44:43 +0300 Subject: [PATCH] implement full game ui --- AGENTS.md | 6 +- frontend/src/components/BattleScreen.jsx | 261 +++++++++++++++++++++++ frontend/src/components/GameScreen.jsx | 94 +++++--- frontend/src/components/SeaBoard.jsx | 126 +++++++---- frontend/src/game.js | 22 ++ frontend/src/styles.css | 212 ++++++++++++++++++ 6 files changed, 648 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/BattleScreen.jsx diff --git a/AGENTS.md b/AGENTS.md index 2a6fc5d..b0f2ed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,10 @@ The repository's directory structure is simple: there are 3 directories for each ## Project roadmap - [x] Basic sign-up/sign-in function - [x] Matchmaking with delegation to the game server (using a shared JWT secret) -- [ ] Actual game logic & game UI +- [x] Actual game logic & game UI - [x] Ship placement UI and logic - - [ ] Battle UI and logic - - [ ] End of battle UI + - [x] Battle UI and logic + - [x] End of battle UI - [ ] Saving results on the main server, match history, rating calculation - [ ] Additional in-game features: chat, reactions, live connection status - [ ] Profile customization diff --git a/frontend/src/components/BattleScreen.jsx b/frontend/src/components/BattleScreen.jsx new file mode 100644 index 0000000..dcdd114 --- /dev/null +++ b/frontend/src/components/BattleScreen.jsx @@ -0,0 +1,261 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import SeaBoard from './SeaBoard' +import { + STATE_YOUR_TURN, + STATE_WAIT_OPPONENT_TURN, + FIRE_RESULT_MISS, + FIRE_RESULT_HIT, + FIRE_RESULT_SUNK, +} from '../game' + +/** + * Обрабатывает потопление корабля на поле соперника: + * находит все соединенные клетки 'damaged', помечает их как 'destroyed', + * а все окружающие клетки (включая диагональные) — как 'miss'. + */ +export function applySunkShipToBoard(prevBoard, hitX, hitY) { + const board = prevBoard.map((row) => [...row]) + board[hitY][hitX] = 'damaged' + + const shipCells = [{ x: hitX, y: hitY }] + + // Поиск поврежденных клеток корабля по горизонтали + let cx = hitX - 1 + while (cx >= 0 && board[hitY][cx] === 'damaged') { + shipCells.push({ x: cx, y: hitY }) + cx-- + } + cx = hitX + 1 + while (cx < 10 && board[hitY][cx] === 'damaged') { + shipCells.push({ x: cx, y: hitY }) + cx++ + } + + // Поиск поврежденных клеток корабля по вертикали + let cy = hitY - 1 + while (cy >= 0 && board[cy][hitX] === 'damaged') { + shipCells.push({ x: hitX, y: cy }) + cy-- + } + cy = hitY + 1 + while (cy < 10 && board[cy][hitX] === 'damaged') { + shipCells.push({ x: hitX, y: cy }) + cy++ + } + + // Помечаем все палубы потопленного корабля как 'destroyed' + for (const c of shipCells) { + board[c.y][c.x] = 'destroyed' + } + + // Окружающие клетки (включая диагональные) помечаем как 'miss' + for (const c of shipCells) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = c.x + dx + const ny = c.y + dy + if (nx >= 0 && nx < 10 && ny >= 0 && ny < 10) { + if (board[ny][nx] !== 'destroyed') { + board[ny][nx] = 'miss' + } + } + } + } + } + + return board +} + +export default function BattleScreen({ + myShips = [], + gameState, + connection, + onLeave, +}) { + // Поле игрока (собственные корабли без скруглений) + const [myBoard] = useState(() => { + const b = Array.from({ length: 10 }, () => Array(10).fill('empty')) + for (const ship of myShips) { + for (let i = 0; i < ship.size; i++) { + const cx = ship.orientation ? ship.x : ship.x + i + const cy = ship.orientation ? ship.y + i : ship.y + if (cx >= 0 && cx < 10 && cy >= 0 && cy < 10) { + b[cy][cx] = 'ship' + } + } + } + return b + }) + + // Поле соперника (туман войны: изначально все клетки пустые) + const [opponentBoard, setOpponentBoard] = useState(() => + Array.from({ length: 10 }, () => Array(10).fill('empty')) + ) + + const [aiming, setAiming] = useState(null) + const [isFiring, setIsFiring] = useState(false) + const [showSurrenderModal, setShowSurrenderModal] = useState(false) + + const pendingShotRef = useRef(null) + + const isMyTurn = gameState === STATE_YOUR_TURN + + // Обработка результатов выстрела от сервера + useEffect(() => { + if (!connection) return + + connection.onFireResult = (resultCode) => { + const shot = pendingShotRef.current + pendingShotRef.current = null + setIsFiring(false) + setAiming(null) + + if (!shot) return + + const { x, y } = shot + + if (resultCode === FIRE_RESULT_MISS) { + setOpponentBoard((prev) => { + const next = prev.map((row) => [...row]) + next[y][x] = 'miss' + return next + }) + } else if (resultCode === FIRE_RESULT_HIT) { + setOpponentBoard((prev) => { + const next = prev.map((row) => [...row]) + next[y][x] = 'damaged' + return next + }) + } else if (resultCode === FIRE_RESULT_SUNK) { + setOpponentBoard((prev) => applySunkShipToBoard(prev, x, y)) + } + } + + return () => { + connection.onFireResult = null + } + }, [connection]) + + // Наведение курсора на клетку поля соперника (прицеливание) + const handleCellHover = useCallback( + (x, y) => { + if (!isMyTurn || isFiring) return + // Не подсвечивать прицел на уже пораженных клетках + if (opponentBoard[y][x] !== 'empty') { + setAiming(null) + return + } + setAiming({ x, y }) + }, + [isMyTurn, isFiring, opponentBoard] + ) + + const handleCellLeave = useCallback(() => { + setAiming(null) + }, []) + + // Клик по клетке поля соперника (выстрел) + const handleCellClick = useCallback( + (x, y) => { + if (!isMyTurn || isFiring) return + if (opponentBoard[y][x] !== 'empty') return + + setIsFiring(true) + pendingShotRef.current = { x, y } + connection.fire(x, y) + }, + [isMyTurn, isFiring, opponentBoard, connection] + ) + + return ( +
+ {/* Верхняя панель со статусом и кнопкой сдаться в углу */} +
+
+
+ {isMyTurn ? 'Ваш ход — выберите цель' : 'Ожидайте ход соперника'} +
+
+
+ +
+
+ + {/* Основная боевая область с двумя полями и стрелкой посередине */} +
+ {/* Левая половина: собственное поле игрока */} +
+
Ваш флот
+ +
+ + {/* Между половинами: голубая стрелка, показывающая направление атаки */} +
+
+ +
+
+ {isMyTurn ? 'Атака' : 'Оборона'} +
+
+ + {/* Правая половина: поле соперника */} +
+
Флот соперника
+ +
+
+ + {/* Модальное окно заглушки "Сдаться" */} + {showSurrenderModal && ( +
+
+

Сдаться

+

+ Функция «Сдаться» в данный момент находится в разработке на сервере. +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/GameScreen.jsx b/frontend/src/components/GameScreen.jsx index c953966..2e91ce1 100644 --- a/frontend/src/components/GameScreen.jsx +++ b/frontend/src/components/GameScreen.jsx @@ -6,12 +6,17 @@ import { STATE_WAIT_OPPONENT_READY, STATE_YOUR_TURN, STATE_WAIT_OPPONENT_TURN, + END_CODE_VICTORY, + END_CODE_DEFEAT, } from '../game' import ShipPlacementScreen from './ShipPlacementScreen' +import BattleScreen from './BattleScreen' export default function GameScreen({ session, onLeave }) { - const [status, setStatus] = useState('connecting') // 'connecting' | 'placing' | 'game' | 'error' + // 'connecting' | 'placing' | 'waiting_opponent' | 'battle' | 'victory' | 'defeat' | 'error' + const [status, setStatus] = useState('connecting') const [gameState, setGameState] = useState(STATE_PLACING_SHIPS) + const [myShips, setMyShips] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const [opponentReady, setOpponentReady] = useState(false) const [error, setError] = useState(null) @@ -34,28 +39,42 @@ export default function GameScreen({ session, onLeave }) { if (newState === STATE_OPPONENT_READY) { setOpponentReady(true) + } else if (newState === STATE_WAIT_OPPONENT_READY) { + setIsSubmitting(false) + setGameState(newState) + setStatus('waiting_opponent') } else if ( - newState === STATE_WAIT_OPPONENT_READY || newState === STATE_YOUR_TURN || newState === STATE_WAIT_OPPONENT_TURN ) { setIsSubmitting(false) setGameState(newState) - setStatus('game') + setStatus('battle') } } - conn.onEnd = (_code, reason) => { + conn.onEnd = (code, reason) => { if (cancelled || attempt !== attemptRef.current) return setIsSubmitting(false) - setError(reason || 'Партия завершена') - setStatus('error') + + if (code === END_CODE_VICTORY || reason === 'victory') { + setStatus('victory') + } else if (code === END_CODE_DEFEAT || reason === 'defeat') { + setStatus('defeat') + } else { + setError(reason || 'Партия завершена') + setStatus('error') + } } conn.onClose = () => { if (cancelled || attempt !== attemptRef.current) return setIsSubmitting(false) setStatus((prev) => { + // Если игра уже закончилась победой или поражением, не перетираем статус + if (prev === 'victory' || prev === 'defeat') { + return prev + } if (prev !== 'error') { setError('Соединение с сервером прервано') return 'error' @@ -80,24 +99,11 @@ export default function GameScreen({ session, onLeave }) { const handleReady = useCallback((ships) => { if (!connectionRef.current) return + setMyShips(ships) setIsSubmitting(true) connectionRef.current.placeShips(ships) }, []) - // Текст заглушки в зависимости от состояния игры - const getGameStubText = () => { - switch (gameState) { - case STATE_WAIT_OPPONENT_READY: - return 'Соперник расставляет корабли...' - case STATE_YOUR_TURN: - return 'Ваш ход' - case STATE_WAIT_OPPONENT_TURN: - return 'Соперник ходит' - default: - return '' - } - } - return (
{status === 'connecting' && ( @@ -121,17 +127,45 @@ export default function GameScreen({ session, onLeave }) { )} - {status === 'game' && ( + {status === 'waiting_opponent' && (
-
{getGameStubText()}
- {(gameState === STATE_WAIT_OPPONENT_READY || - gameState === STATE_WAIT_OPPONENT_TURN) && ( - - )} +
Соперник расставляет корабли...
+ +
+ )} + + {status === 'battle' && ( +
+ +
+ )} + + {status === 'victory' && ( +
+
Победа!
+
Все корабли соперника уничтожены
+ +
+ )} + + {status === 'defeat' && ( +
+
Поражение
+
Все ваши корабли были уничтожены
+
)} diff --git a/frontend/src/components/SeaBoard.jsx b/frontend/src/components/SeaBoard.jsx index aa92d44..09754c7 100644 --- a/frontend/src/components/SeaBoard.jsx +++ b/frontend/src/components/SeaBoard.jsx @@ -3,6 +3,10 @@ import { ROW_LETTERS, COL_NUMBERS } from '../shipLogic' /** * Компонент игрового поля 10x10 с вертикальной (А-К) и горизонтальной (1-10) линейками. * Линейки идут слева и сверху параллельно полю и отделены небольшим отступом. + * + * Поддерживает два режима: + * 1. Режим расстановки (передаются ships, preview, onShipPointerDown) + * 2. Режим боя (передается 10x10 матрица cells, aiming, onCellHover, onCellClick) */ export default function SeaBoard({ ships = [], @@ -10,6 +14,13 @@ export default function SeaBoard({ onShipPointerDown, gridRef, className = '', + // Свойства для режима боя + cells = null, + aiming = null, + onCellHover = null, + onCellLeave = null, + onCellClick = null, + interactive = false, }) { return (
@@ -37,52 +48,87 @@ export default function SeaBoard({ {/* Само игровое поле 10x10 */}
{/* Базовая сетка 10x10 клеток */} -
- {Array.from({ length: 100 }).map((_, idx) => ( -
- ))} -
+
+ {cells + ? Array.from({ length: 10 }).map((_, y) => + Array.from({ length: 10 }).map((_, x) => { + const status = cells[y]?.[x] || 'empty' + const isTarget = aiming && aiming.x === x && aiming.y === y + const isAimRow = aiming && aiming.y === y + const isAimCol = aiming && aiming.x === x - {/* Слой расставленных кораблей */} -
- {ships.map((ship) => { - if (!ship.placed) return null - const isHorizontal = !ship.orientation - const left = 3 + ship.x * 33 - const top = 3 + ship.y * 33 - const width = isHorizontal ? ship.size * 30 + (ship.size - 1) * 3 : 30 - const height = isHorizontal ? 30 : ship.size * 30 + (ship.size - 1) * 3 + const classNames = ['sea-cell'] + if (status === 'ship') classNames.push('ship-cell') + else if (status === 'miss') classNames.push('miss-cell') + else if (status === 'damaged') classNames.push('damaged-ship-cell') + else if (status === 'destroyed') classNames.push('destroyed-ship-cell') - return ( -
{ - if (onShipPointerDown) { - onShipPointerDown(e, ship) + if (interactive && aiming) { + if (isTarget) classNames.push('aim-target') + else if (isAimRow) classNames.push('aim-row') + else if (isAimCol) classNames.push('aim-col') } - }} - > - {Array.from({ length: ship.size }).map((_, segIdx) => ( -
- ))} -
- ) - })} + + return ( +
onCellHover && onCellHover(x, y)} + onClick={() => onCellClick && onCellClick(x, y)} + /> + ) + }) + ) + : Array.from({ length: 100 }).map((_, idx) => ( +
+ ))}
+ {/* Слой расставленных кораблей (в режиме расстановки) */} + {!cells && ( +
+ {ships.map((ship) => { + if (!ship.placed) return null + const isHorizontal = !ship.orientation + const left = 3 + ship.x * 33 + const top = 3 + ship.y * 33 + const width = isHorizontal ? ship.size * 30 + (ship.size - 1) * 3 : 30 + const height = isHorizontal ? 30 : ship.size * 30 + (ship.size - 1) * 3 + + return ( +
{ + if (onShipPointerDown) { + onShipPointerDown(e, ship) + } + }} + > + {Array.from({ length: ship.size }).map((_, segIdx) => ( +
+ ))} +
+ ) + })} +
+ )} + {/* Слой призрака при перетаскивании (ghost preview) */} - {preview && ( + {!cells && preview && (