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 && (