diff --git a/frontend/src/components/GameScreen.jsx b/frontend/src/components/GameScreen.jsx
index 94bfcf7..c953966 100644
--- a/frontend/src/components/GameScreen.jsx
+++ b/frontend/src/components/GameScreen.jsx
@@ -1,19 +1,70 @@
-import { useEffect, useRef, useState } from 'react'
-import { createGameConnection } from '../game'
+import { useEffect, useRef, useState, useCallback } from 'react'
+import {
+ createGameConnection,
+ STATE_PLACING_SHIPS,
+ STATE_OPPONENT_READY,
+ STATE_WAIT_OPPONENT_READY,
+ STATE_YOUR_TURN,
+ STATE_WAIT_OPPONENT_TURN,
+} from '../game'
+import ShipPlacementScreen from './ShipPlacementScreen'
export default function GameScreen({ session, onLeave }) {
- const [status, setStatus] = useState('connecting') // 'connecting' | 'ready' | 'error'
+ const [status, setStatus] = useState('connecting') // 'connecting' | 'placing' | 'game' | 'error'
+ const [gameState, setGameState] = useState(STATE_PLACING_SHIPS)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [opponentReady, setOpponentReady] = useState(false)
const [error, setError] = useState(null)
+
+ const connectionRef = useRef(null)
const attemptRef = useRef(0)
useEffect(() => {
const attempt = ++attemptRef.current
let cancelled = false
- const connection = createGameConnection(session)
+ const connInstance = createGameConnection(session)
- connection.promise
- .then(() => {
- if (!cancelled && attempt === attemptRef.current) setStatus('ready')
+ connInstance.promise
+ .then((conn) => {
+ if (cancelled || attempt !== attemptRef.current) return
+ connectionRef.current = conn
+
+ conn.onState = (newState) => {
+ if (cancelled || attempt !== attemptRef.current) return
+
+ if (newState === STATE_OPPONENT_READY) {
+ setOpponentReady(true)
+ } else if (
+ newState === STATE_WAIT_OPPONENT_READY ||
+ newState === STATE_YOUR_TURN ||
+ newState === STATE_WAIT_OPPONENT_TURN
+ ) {
+ setIsSubmitting(false)
+ setGameState(newState)
+ setStatus('game')
+ }
+ }
+
+ conn.onEnd = (_code, reason) => {
+ if (cancelled || attempt !== attemptRef.current) return
+ setIsSubmitting(false)
+ setError(reason || 'Партия завершена')
+ setStatus('error')
+ }
+
+ conn.onClose = () => {
+ if (cancelled || attempt !== attemptRef.current) return
+ setIsSubmitting(false)
+ setStatus((prev) => {
+ if (prev !== 'error') {
+ setError('Соединение с сервером прервано')
+ return 'error'
+ }
+ return prev
+ })
+ }
+
+ setStatus('placing')
})
.catch((err) => {
if (cancelled || attempt !== attemptRef.current) return
@@ -23,33 +74,89 @@ export default function GameScreen({ session, onLeave }) {
return () => {
cancelled = true
- connection.cancel()
+ connInstance.cancel()
}
}, [session])
+ const handleReady = useCallback((ships) => {
+ if (!connectionRef.current) return
+ 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' && (
- <>
- Подключение к игре...
+
+ {status === 'connecting' && (
+
+ Подключение к игре...
+
+
+
+
+
+
+ )}
+
+ {status === 'placing' && (
+
+
+
+ )}
+
+ {status === 'game' && (
+
+ {getGameStubText()}
+ {(gameState === STATE_WAIT_OPPONENT_READY ||
+ gameState === STATE_WAIT_OPPONENT_TURN) && (
- >
- )}
- {status === 'ready' && Расстановка кораблей
}
- {status === 'error' && (
- <>
- {error}
-
- >
- )}
-
+ )}
+
+ )}
+
+ {status === 'error' && (
+
+ {error}
+
+
+ )}
+
+ {/* Модальный pop-up с надписью "Загрузка...", пока сервер не ответит сменой состояния */}
+ {isSubmitting && (
+
+
+
Загрузка...
+
+
+
+
+
+
+
+ )}
)
}
diff --git a/frontend/src/components/SeaBoard.jsx b/frontend/src/components/SeaBoard.jsx
new file mode 100644
index 0000000..aa92d44
--- /dev/null
+++ b/frontend/src/components/SeaBoard.jsx
@@ -0,0 +1,109 @@
+import { ROW_LETTERS, COL_NUMBERS } from '../shipLogic'
+
+/**
+ * Компонент игрового поля 10x10 с вертикальной (А-К) и горизонтальной (1-10) линейками.
+ * Линейки идут слева и сверху параллельно полю и отделены небольшим отступом.
+ */
+export default function SeaBoard({
+ ships = [],
+ preview = null,
+ onShipPointerDown,
+ gridRef,
+ className = '',
+}) {
+ return (
+
+ {/* Пустой угол (пересечение линеек) */}
+
+
+ {/* Горизонтальная линейка (цифры 1..10) */}
+
+ {COL_NUMBERS.map((num) => (
+
+ {num}
+
+ ))}
+
+
+ {/* Вертикальная линейка (буквы А..К) */}
+
+ {ROW_LETTERS.map((letter) => (
+
+ {letter}
+
+ ))}
+
+
+ {/* Само игровое поле 10x10 */}
+
+ {/* Базовая сетка 10x10 клеток */}
+
+ {Array.from({ length: 100 }).map((_, idx) => (
+
+ ))}
+
+
+ {/* Слой расставленных кораблей */}
+
+ {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 && (
+
+ {Array.from({ length: preview.size }).map((_, segIdx) => (
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/ShipPlacementScreen.jsx b/frontend/src/components/ShipPlacementScreen.jsx
new file mode 100644
index 0000000..493d5df
--- /dev/null
+++ b/frontend/src/components/ShipPlacementScreen.jsx
@@ -0,0 +1,455 @@
+import { useState, useRef, useEffect, useCallback } from 'react'
+import SeaBoard from './SeaBoard'
+import {
+ createInitialShips,
+ canPlaceShip,
+ findValidRotation,
+ generateRandomPlacement,
+} from '../shipLogic'
+
+function ShuffleIcon({ size = 20 }) {
+ return (
+
+ )
+}
+
+export default function ShipPlacementScreen({
+ onReady,
+ isSubmitting = false,
+ opponentReady = false,
+}) {
+ const [ships, setShips] = useState(createInitialShips)
+ const [activeDrag, setActiveDrag] = useState(null)
+ const shipsRef = useRef(ships)
+ shipsRef.current = ships
+
+ const gridRef = useRef(null)
+ const dragRef = useRef(null)
+
+ const allPlaced = ships.every((s) => s.placed)
+ const placedCount = ships.filter((s) => s.placed).length
+
+ // Очистка класса is-dragging при размонтировании
+ useEffect(() => {
+ return () => {
+ document.body.classList.remove('is-dragging')
+ }
+ }, [])
+
+ // Расчет сетки при перетаскивании
+ const computeGridCandidate = useCallback((ship, pointerPos, orientation, segIndex) => {
+ if (!gridRef.current) return null
+ const rect = gridRef.current.getBoundingClientRect()
+ const cellPitch = 33 // 30px ячейка + 3px gap
+ const pad = 3
+
+ // Вычисляем положение левого верхнего угла корабля (shipLeft, shipTop) на экране:
+ const shipLeft = pointerPos.x - (!orientation ? segIndex * cellPitch + 15 : 15)
+ const shipTop = pointerPos.y - (orientation ? segIndex * cellPitch + 15 : 15)
+
+ // Положение левого верхнего угла корабля относительно сетки:
+ const relX = shipLeft - (rect.left + pad)
+ const relY = shipTop - (rect.top + pad)
+
+ // Симметричное математическое округление к ближайшей колонке и строке сетки:
+ const targetX = Math.round(relX / cellPitch)
+ const targetY = Math.round(relY / cellPitch)
+
+ const maxX = orientation ? targetX : targetX + ship.size - 1
+ const maxY = orientation ? targetY + ship.size - 1 : targetY
+
+ // Проекция отображается ТОЛЬКО если корабль полностью внутри поля 10x10:
+ if (targetX < 0 || targetY < 0 || maxX >= 10 || maxY >= 10) {
+ return null
+ }
+
+ // Проверяем возможность размещения (нет пересечений и зазор минимум 1 клетка)
+ const otherPlaced = shipsRef.current.filter((s) => s.id !== ship.id && s.placed)
+ const isValid = canPlaceShip(ship, targetX, targetY, orientation, otherPlaced)
+
+ return {
+ x: targetX,
+ y: targetY,
+ orientation,
+ size: ship.size,
+ isValid,
+ }
+ }, [])
+
+ // Поворот корабля на поле при одиночном клике
+ const handleRotateShip = useCallback((ship) => {
+ const rot = findValidRotation(ship, shipsRef.current)
+ if (rot) {
+ setShips((prev) =>
+ prev.map((s) =>
+ s.id === ship.id
+ ? {
+ ...s,
+ x: rot.x,
+ y: rot.y,
+ orientation: rot.orientation,
+ }
+ : s
+ )
+ )
+ }
+ }, [])
+
+ // Начало перетаскивания или клика
+ const handleStartDrag = useCallback(
+ (e, ship, fromBoard) => {
+ // Только левая кнопка мыши или тач
+ if (e.button !== undefined && e.button !== 0) return
+
+ // Предотвращаем выделение текста на странице
+ e.preventDefault()
+
+ const segEl = e.target.closest('[data-segment]')
+ let segIndex = 0
+ if (segEl) {
+ segIndex = parseInt(segEl.getAttribute('data-segment'), 10) || 0
+ }
+
+ const startX = e.clientX
+ const startY = e.clientY
+ let isDragging = false
+
+ dragRef.current = {
+ ship,
+ fromBoard,
+ segIndex,
+ orientation: fromBoard ? ship.orientation : false,
+ startX,
+ startY,
+ currentPos: { x: startX, y: startY },
+ candidate: null,
+ }
+
+ const onPointerMove = (moveEvent) => {
+ const drag = dragRef.current
+ if (!drag) return
+
+ moveEvent.preventDefault()
+
+ const dist = Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY)
+ if (!isDragging && dist > 5) {
+ isDragging = true
+ document.body.classList.add('is-dragging')
+ // Снимаем корабль с поля на время переноса
+ if (drag.fromBoard) {
+ setShips((prev) =>
+ prev.map((s) => (s.id === drag.ship.id ? { ...s, placed: false } : s))
+ )
+ }
+ }
+
+ if (isDragging) {
+ const currentPos = { x: moveEvent.clientX, y: moveEvent.clientY }
+ const candidate = computeGridCandidate(
+ drag.ship,
+ currentPos,
+ drag.orientation,
+ drag.segIndex
+ )
+ drag.currentPos = currentPos
+ drag.candidate = candidate
+
+ setActiveDrag({
+ ship: drag.ship,
+ orientation: drag.orientation,
+ currentPos,
+ segIndex: drag.segIndex,
+ candidate,
+ })
+ }
+ }
+
+ const onPointerUp = () => {
+ window.removeEventListener('pointermove', onPointerMove)
+ window.removeEventListener('pointerup', onPointerUp)
+ window.removeEventListener('keydown', onKeyDown)
+ document.body.classList.remove('is-dragging')
+
+ const drag = dragRef.current
+ dragRef.current = null
+ setActiveDrag(null)
+
+ if (!drag) return
+
+ if (!isDragging) {
+ // Одиночный клик
+ if (drag.fromBoard) {
+ handleRotateShip(drag.ship)
+ }
+ return
+ }
+
+ // Завершение перетаскивания
+ if (drag.candidate && drag.candidate.isValid) {
+ // Успешная установка на поле
+ setShips((prev) =>
+ prev.map((s) =>
+ s.id === drag.ship.id
+ ? {
+ ...s,
+ placed: true,
+ x: drag.candidate.x,
+ y: drag.candidate.y,
+ orientation: drag.candidate.orientation,
+ }
+ : s
+ )
+ )
+ } else {
+ // Некорректная позиция или за пределами поля -> возвращается в инвентарь
+ setShips((prev) =>
+ prev.map((s) =>
+ s.id === drag.ship.id
+ ? {
+ ...s,
+ placed: false,
+ x: 0,
+ y: 0,
+ orientation: false,
+ }
+ : s
+ )
+ )
+ }
+ }
+
+ const onKeyDown = (keyEvent) => {
+ if (keyEvent.code === 'Space' || keyEvent.key === 'r' || keyEvent.key === 'R') {
+ keyEvent.preventDefault()
+ const drag = dragRef.current
+ if (!drag) return
+ drag.orientation = !drag.orientation
+ const candidate = computeGridCandidate(
+ drag.ship,
+ drag.currentPos,
+ drag.orientation,
+ drag.segIndex
+ )
+ drag.candidate = candidate
+ setActiveDrag((prev) =>
+ prev ? { ...prev, orientation: drag.orientation, candidate } : null
+ )
+ }
+ }
+
+ window.addEventListener('pointermove', onPointerMove)
+ window.addEventListener('pointerup', onPointerUp)
+ window.addEventListener('keydown', onKeyDown)
+ },
+ [computeGridCandidate, handleRotateShip]
+ )
+
+ // Очистить сетку
+ const handleClear = useCallback(() => {
+ setShips(createInitialShips())
+ }, [])
+
+ // Случайная расстановка с backtracking
+ const handleRandom = useCallback(() => {
+ const placed = generateRandomPlacement()
+ setShips(placed)
+ }, [])
+
+ // 4 строчки инвентаря по возрастанию размера:
+ // Строка 1: 4 однопалубных (id 0..3)
+ // Строка 2: 3 двухпалубных (id 4..6)
+ // Строка 3: 2 трехпалубных (id 7..8)
+ // Строка 4: 1 четырехпалубный (id 9)
+ const inventoryRows = [
+ ships.filter((s) => s.size === 1),
+ ships.filter((s) => s.size === 2),
+ ships.filter((s) => s.size === 3),
+ ships.filter((s) => s.size === 4),
+ ]
+
+ return (
+
+ {opponentReady && (
+
+ Соперник готов к бою!
+
+ )}
+
+
+ {/* Левая половина: игровое поле 10x10 с линейками и кнопками под ним */}
+
+
handleStartDrag(e, ship, true)}
+ gridRef={gridRef}
+ />
+
+
+
+
+
+
+
+ {/* Правая половина: инвентарь кораблей в 4 строки и кнопка Готово */}
+
+
+
+ Корабли
+
+ {allPlaced ? 'Все корабли на поле' : `Расставлено: ${placedCount} из 10`}
+
+
+
+
+ {inventoryRows.map((row, rowIndex) => (
+
+ {row.map((ship) => {
+ const slotWidth = ship.size * 30 + (ship.size - 1) * 3
+ return (
+
+ {!ship.placed && (
+
handleStartDrag(e, ship, false)}
+ >
+ {Array.from({ length: ship.size }).map((_, segIdx) => (
+
+ ))}
+
+ )}
+
+ )
+ })}
+
+ ))}
+
+
+
+ {/* Подсказка по повороту кораблей */}
+
+ 💡
+
+ Корабли можно поворачивать во время перетаскивания (клавиши Пробел или R), а также одиночным нажатием после перетаскивания.
+
+
+
+
+
+
+
+
+
+ {/* Парящий под курсором корабль при перетаскивании */}
+ {activeDrag && (
+
+ {Array.from({ length: activeDrag.ship.size }).map((_, segIdx) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/game.js b/frontend/src/game.js
index 6aaa35f..89baa4d 100644
--- a/frontend/src/game.js
+++ b/frontend/src/game.js
@@ -6,10 +6,18 @@ export class GameConnectionError extends Error {
}
// Опкоды протокола game-server
-const OP_JOIN = 1
-const OP_END = 2
-const OP_STATE = 3
-const OP_ACK = 4
+export const OP_JOIN = 1
+export const OP_END = 2
+export const OP_STATE = 3
+export const OP_ACK = 4
+export const OP_PLACE_SHIPS = 5
+
+// Коды состояний протокола game-server
+export const STATE_PLACING_SHIPS = 1 // оба игрока расставляют корабли
+export const STATE_OPPONENT_READY = 2 // соперник готов
+export const STATE_WAIT_OPPONENT_READY = 3 // ожидайте готовности соперника
+export const STATE_YOUR_TURN = 4 // ваш ход
+export const STATE_WAIT_OPPONENT_TURN = 5 // ожидайте ход соперника
const CLOSE_REASONS = {
4000: 'Соединение заменено новым',
@@ -31,6 +39,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
let settled = false
let lastSeq = 0
+ let connection = null
+
const promise = new Promise((resolve, reject) => {
const fail = (err) => {
if (settled) return
@@ -39,19 +49,32 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
reject(err)
}
- const succeed = (connection) => {
+ const succeed = (conn) => {
if (settled) return
settled = true
- resolve(connection)
- // Сокет НЕ закрываем — соединение переиспользуется для игры
- ws.onerror = null
- ws.onclose = (event) => {
- if (connection.onClose) connection.onClose(event.code)
- }
+ resolve(conn)
}
ws = new WebSocket(gameServerUrl)
+ connection = {
+ socket: ws,
+ send: sendMessage,
+ placeShips(ships) {
+ // Сортируем строго по возрастанию размера: 4x1, 3x2, 2x3, 1x4
+ const sorted = [...ships].sort((a, b) => a.size - b.size)
+ const payload = [OP_PLACE_SHIPS]
+ for (const s of sorted) {
+ payload.push(Boolean(s.orientation), Number(s.x), Number(s.y))
+ }
+ sendMessage(payload)
+ },
+ onState: null,
+ onEnd: null,
+ onClose: null,
+ onError: null,
+ }
+
ws.onopen = () => {
ws.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
}
@@ -73,18 +96,29 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
if (opcode === OP_STATE) {
const state = msg[2]
- if (state === 1) {
- succeed({ socket: ws, send: sendMessage })
+ if (!settled && state === STATE_PLACING_SHIPS) {
+ succeed(connection)
+ }
+ if (connection && typeof connection.onState === 'function') {
+ connection.onState(state)
}
} else if (opcode === OP_END) {
const code = msg[2]
const reason = msg[3] || 'Партия завершена'
- fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason))
+ if (!settled) {
+ fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason))
+ } else if (connection && typeof connection.onEnd === 'function') {
+ connection.onEnd(code, reason)
+ }
}
}
- ws.onerror = () => {
- if (!settled) fail(new GameConnectionError('connection', 'Сетевая ошибка'))
+ ws.onerror = (err) => {
+ if (!settled) {
+ fail(new GameConnectionError('connection', 'Сетевая ошибка'))
+ } else if (connection && typeof connection.onError === 'function') {
+ connection.onError(err)
+ }
}
ws.onclose = (event) => {
@@ -93,6 +127,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
CLOSE_REASONS[event.code] ||
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
fail(new GameConnectionError('closed', message))
+ } else if (connection && typeof connection.onClose === 'function') {
+ connection.onClose(event.code)
}
}
})
diff --git a/frontend/src/shipLogic.js b/frontend/src/shipLogic.js
new file mode 100644
index 0000000..76fa131
--- /dev/null
+++ b/frontend/src/shipLogic.js
@@ -0,0 +1,226 @@
+// Размеры 10 кораблей по возрастанию размера:
+// 4 однопалубных, 3 двухпалубных, 2 трехпалубных, 1 четырехпалубный
+export const SHIP_CONFIGS = [
+ { id: 0, size: 1 },
+ { id: 1, size: 1 },
+ { id: 2, size: 1 },
+ { id: 3, size: 1 },
+ { id: 4, size: 2 },
+ { id: 5, size: 2 },
+ { id: 6, size: 2 },
+ { id: 7, size: 3 },
+ { id: 8, size: 3 },
+ { id: 9, size: 4 },
+]
+
+export const ROW_LETTERS = ['А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К']
+export const COL_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+
+/**
+ * Создает начальное состояние кораблей (все в инвентаре, горизонтальные).
+ */
+export function createInitialShips() {
+ return SHIP_CONFIGS.map((cfg) => ({
+ id: cfg.id,
+ size: cfg.size,
+ placed: false,
+ x: 0,
+ y: 0,
+ orientation: false, // false = горизонтально, true = вертикально
+ }))
+}
+
+/**
+ * Возвращает массив клеток { x, y }, занимаемых кораблем.
+ */
+export function getShipCells(ship) {
+ const cells = []
+ for (let i = 0; i < ship.size; i++) {
+ cells.push({
+ x: ship.orientation ? ship.x : ship.x + i,
+ y: ship.orientation ? ship.y + i : ship.y,
+ })
+ }
+ return cells
+}
+
+/**
+ * Проверяет, соприкасаются ли или перекрываются ли два корабля.
+ * Корабли не должны соприкасаться сторонами или углами (зазор минимум 1 клетка).
+ */
+export function shipsCollideOrTouch(s1, s2) {
+ const cells1 = getShipCells(s1)
+ const cells2 = getShipCells(s2)
+ for (const c1 of cells1) {
+ for (const c2 of cells2) {
+ if (Math.abs(c1.x - c2.x) <= 1 && Math.abs(c1.y - c2.y) <= 1) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+/**
+ * Проверяет, можно ли поставить корабль в позицию (x, y) с заданной ориентацией
+ * среди массива уже расставленных кораблей placedShips.
+ */
+export function canPlaceShip(ship, x, y, orientation, placedShips) {
+ const maxX = orientation ? x : x + ship.size - 1
+ const maxY = orientation ? y + ship.size - 1 : y
+
+ if (x < 0 || maxX >= 10 || y < 0 || maxY >= 10) {
+ return false
+ }
+
+ const testShip = { ...ship, x, y, orientation }
+
+ for (const other of placedShips) {
+ if (other.id === ship.id || !other.placed) continue
+ if (shipsCollideOrTouch(testShip, other)) {
+ return false
+ }
+ }
+
+ return true
+}
+
+/**
+ * Вычисляет список позиций-кандидатов для поворота корабля:
+ * При повороте корабль размера s, стоящий горизонтально в позиции (x, y),
+ * должен попытаться встать во все позиции, имеющие общую точку с позицией до поворота:
+ * (x + dx, y + dy), dx = 0...s-1, dy = -s+1...0.
+ * И аналогично для вертикального корабля:
+ * (x + dx, y + dy), dy = 0...s-1, dx = -s+1...0.
+ *
+ * Кандидаты сортируются: сначала без смещения (dx=0, dy=0),
+ * затем по возрастанию расстояния/сдвига от исходной клетки.
+ */
+export function getRotationCandidates(ship) {
+ const { size, orientation, x, y } = ship
+ const candidates = []
+
+ if (!orientation) {
+ // Текущий: горизонтальный -> поворачивается в вертикальный (orientation: true)
+ for (let dx = 0; dx < size; dx++) {
+ for (let dy = -size + 1; dy <= 0; dy++) {
+ candidates.push({
+ x: x + dx,
+ y: y + dy,
+ orientation: true,
+ dist: dx * dx + dy * dy,
+ absDist: Math.abs(dx) + Math.abs(dy),
+ dx,
+ dy,
+ })
+ }
+ }
+ } else {
+ // Текущий: вертикальный -> поворачивается в горизонтальный (orientation: false)
+ for (let dy = 0; dy < size; dy++) {
+ for (let dx = -size + 1; dx <= 0; dx++) {
+ candidates.push({
+ x: x + dx,
+ y: y + dy,
+ orientation: false,
+ dist: dx * dx + dy * dy,
+ absDist: Math.abs(dx) + Math.abs(dy),
+ dx,
+ dy,
+ })
+ }
+ }
+ }
+
+ // Приоритет: сначала dx=0, dy=0, затем по минимальному расстоянию
+ candidates.sort((a, b) => {
+ if (a.dist !== b.dist) return a.dist - b.dist
+ if (a.absDist !== b.absDist) return a.absDist - b.absDist
+ if (a.dx !== b.dx) return a.dx - b.dx
+ return a.dy - b.dy
+ })
+
+ return candidates
+}
+
+/**
+ * Находит первую подходящую позицию для поворота корабля.
+ * Если ни одна позиция не подходит — возвращает null (корабль не поворачивается).
+ */
+export function findValidRotation(ship, allShips) {
+ const candidates = getRotationCandidates(ship)
+ const otherPlacedShips = allShips.filter((s) => s.id !== ship.id && s.placed)
+
+ for (const cand of candidates) {
+ if (canPlaceShip(ship, cand.x, cand.y, cand.orientation, otherPlacedShips)) {
+ return {
+ x: cand.x,
+ y: cand.y,
+ orientation: cand.orientation,
+ }
+ }
+ }
+
+ return null
+}
+
+/**
+ * Генерирует случайную валидную расстановку всех 10 кораблей с помощью алгоритма с backtracking.
+ */
+export function generateRandomPlacement() {
+ // Начинаем с самых больших кораблей (размер 4, 3, 3, 2, 2, 2, 1, 1, 1, 1)
+ const sortedConfig = [...SHIP_CONFIGS].sort((a, b) => b.size - a.size)
+ const placed = []
+
+ function backtrack(index) {
+ if (index === sortedConfig.length) {
+ return true
+ }
+
+ const cfg = sortedConfig[index]
+ const candidates = []
+
+ for (let x = 0; x < 10; x++) {
+ for (let y = 0; y < 10; y++) {
+ for (const orientation of [false, true]) {
+ const testShip = { id: cfg.id, size: cfg.size, x, y, orientation, placed: true }
+ if (canPlaceShip(testShip, x, y, orientation, placed)) {
+ candidates.push({ x, y, orientation })
+ }
+ }
+ }
+ }
+
+ // Случайное перемешивание кандидатов (Fisher-Yates)
+ for (let i = candidates.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ ;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
+ }
+
+ for (const cand of candidates) {
+ const newShip = {
+ id: cfg.id,
+ size: cfg.size,
+ placed: true,
+ x: cand.x,
+ y: cand.y,
+ orientation: cand.orientation,
+ }
+ placed.push(newShip)
+
+ if (backtrack(index + 1)) {
+ return true
+ }
+
+ placed.pop()
+ }
+
+ return false
+ }
+
+ backtrack(0)
+
+ // Возвращаем в исходном порядке по id 0..9
+ placed.sort((a, b) => a.id - b.id)
+ return placed
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 39b9f3b..9b8eb32 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -510,3 +510,408 @@ input:not([type]), input[type="text"] {
height: 58px;
}
}
+
+/* Экран расстановки кораблей */
+.game-screen-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 30px 20px 40px;
+}
+
+body.is-dragging,
+body.is-dragging * {
+ user-select: none !important;
+ -webkit-user-select: none !important;
+ cursor: grabbing !important;
+}
+
+.placement-screen-layout {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 900px;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.placement-opponent-badge {
+ background: var(--primary-soft);
+ color: var(--primary-darker);
+ font-family: 'Comfortaa';
+ font-size: 15px;
+ font-weight: 700;
+ padding: 8px 18px;
+ border-radius: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 8px rgba(47, 128, 237, 0.15);
+ animation: badge-pulse 2s infinite ease-in-out;
+}
+
+@keyframes badge-pulse {
+ 0%, 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.03);
+ }
+}
+
+.placement-container {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 50px;
+ width: 100%;
+}
+
+.placement-left {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.placement-right {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 400px;
+ min-width: 300px;
+}
+
+/* Игровое поле с линейками */
+.sea-board-wrapper {
+ display: inline-grid;
+ grid-template-columns: auto auto;
+ grid-template-rows: auto auto;
+ user-select: none;
+}
+
+.sea-board-corner {
+ grid-column: 1;
+ grid-row: 1;
+ width: 28px;
+ height: 28px;
+ margin-right: 8px;
+ margin-bottom: 8px;
+}
+
+.sea-board-col-ruler {
+ grid-column: 2;
+ grid-row: 1;
+ display: flex;
+ gap: 3px;
+ padding: 0 3px;
+ margin-bottom: 8px; /* небольшой отступ от самого поля */
+}
+
+.sea-board-row-ruler {
+ grid-column: 1;
+ grid-row: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 3px 0;
+ margin-right: 8px; /* небольшой отступ от самого поля */
+}
+
+.sea-ruler-cell {
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: 'Comfortaa', sans-serif;
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-soft);
+}
+
+.sea-grid-container {
+ grid-column: 2;
+ grid-row: 2;
+ position: relative;
+ width: 333px;
+ height: 333px;
+ box-shadow: 0 8px 24px rgba(30, 58, 95, 0.08);
+ border-radius: 4px;
+ overflow: visible;
+}
+
+.sea-ships-layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: auto;
+}
+
+/* Корабли */
+.board-ship {
+ position: absolute;
+ display: flex;
+ gap: 3px;
+ z-index: 2;
+ cursor: grab;
+ touch-action: none;
+ transition: transform 0.08s ease;
+}
+
+.board-ship:active {
+ cursor: grabbing;
+}
+
+.board-ship.horizontal {
+ flex-direction: row;
+}
+
+.board-ship.vertical {
+ flex-direction: column;
+}
+
+.ship-segment {
+ width: 30px;
+ height: 30px;
+ background-color: var(--ship);
+ border-radius: 4px;
+ flex-shrink: 0;
+ box-sizing: border-box;
+}
+
+/* Закругление концов корабля */
+.board-ship.horizontal .ship-segment:first-child {
+ border-top-left-radius: 8px;
+ border-bottom-left-radius: 8px;
+}
+
+.board-ship.horizontal .ship-segment:last-child {
+ border-top-right-radius: 8px;
+ border-bottom-right-radius: 8px;
+}
+
+.board-ship.vertical .ship-segment:first-child {
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
+}
+
+.board-ship.vertical .ship-segment:last-child {
+ border-bottom-left-radius: 8px;
+ border-bottom-right-radius: 8px;
+}
+
+/* Корабль в инвентаре */
+.board-ship.in-inventory {
+ position: relative;
+ left: 0 !important;
+ top: 0 !important;
+ cursor: grab;
+}
+
+.board-ship.in-inventory:hover .ship-segment {
+ filter: brightness(1.15);
+}
+
+/* Призрак при переносе на сетке */
+.ghost-preview {
+ pointer-events: none;
+ z-index: 3;
+}
+
+.ghost-valid .ship-segment {
+ background-color: rgba(47, 128, 237, 0.6);
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+
+.ghost-invalid .ship-segment {
+ background-color: rgba(224, 72, 62, 0.65);
+ outline: 2px solid var(--danger);
+ outline-offset: -2px;
+}
+
+/* Парящий под курсором корабль */
+.floating-drag-ship {
+ position: fixed;
+ display: flex;
+ gap: 3px;
+ pointer-events: none;
+ z-index: 9999;
+ opacity: 0.92;
+ filter: drop-shadow(0 8px 18px rgba(29, 58, 95, 0.35));
+ transform: scale(1.04);
+}
+
+.floating-drag-ship.horizontal {
+ flex-direction: row;
+}
+
+.floating-drag-ship.vertical {
+ flex-direction: column;
+}
+
+.floating-drag-ship .ship-segment {
+ background-color: var(--ship);
+}
+
+.floating-drag-ship.horizontal .ship-segment:first-child {
+ border-top-left-radius: 8px;
+ border-bottom-left-radius: 8px;
+}
+
+.floating-drag-ship.horizontal .ship-segment:last-child {
+ border-top-right-radius: 8px;
+ border-bottom-right-radius: 8px;
+}
+
+.floating-drag-ship.vertical .ship-segment:first-child {
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
+}
+
+.floating-drag-ship.vertical .ship-segment:last-child {
+ border-bottom-left-radius: 8px;
+ border-bottom-right-radius: 8px;
+}
+
+/* Кнопки под полем */
+.placement-board-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 18px;
+ width: 100%;
+ justify-content: center;
+}
+
+.ship-action-btn {
+ padding: 10px 18px;
+ font-size: 15px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border-radius: 12px;
+}
+
+.ship-auto-btn {
+ color: var(--primary);
+}
+
+/* Инвентарь */
+.ship-inventory {
+ background: rgba(255, 255, 255, 0.65);
+ backdrop-filter: blur(8px);
+ border: 1px solid rgba(47, 128, 237, 0.15);
+ border-radius: 18px;
+ padding: 18px 22px;
+ box-shadow: 0 8px 24px rgba(30, 58, 95, 0.05);
+}
+
+.inventory-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 16px;
+}
+
+.inventory-title {
+ font-family: 'Comfortaa', sans-serif;
+ font-weight: 700;
+ font-size: 19px;
+ color: var(--text);
+}
+
+.inventory-hint {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-soft);
+}
+
+.inventory-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.inventory-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 30px;
+}
+
+.inventory-slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 30px;
+ background: transparent;
+}
+
+/* Текстовая подсказка */
+.placement-hint {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ background: rgba(255, 255, 255, 0.65);
+ border: 1px solid rgba(47, 128, 237, 0.2);
+ border-radius: 14px;
+ padding: 12px 14px;
+ margin-top: 14px;
+ box-shadow: 0 4px 14px rgba(30, 58, 95, 0.04);
+ backdrop-filter: blur(8px);
+}
+
+.placement-hint-icon {
+ font-size: 18px;
+ line-height: 1.2;
+ flex-shrink: 0;
+}
+
+.placement-hint-text {
+ font-size: 14px;
+ line-height: 1.4;
+ color: var(--text-soft);
+}
+
+.placement-hint-text strong {
+ color: var(--primary-darker);
+ font-weight: 700;
+}
+
+/* Кнопка Готово */
+.placement-ready-wrapper {
+ margin-top: 24px;
+ display: flex;
+ justify-content: flex-end;
+}
+
+.ready-btn {
+ min-width: 170px;
+ font-size: 20px;
+ padding: 14px 28px;
+}
+
+.loading-modal {
+ text-align: center;
+ align-items: center;
+}
+
+@media (max-width: 780px) {
+ .placement-container {
+ flex-direction: column;
+ align-items: center;
+ gap: 28px;
+ }
+
+ .placement-right {
+ min-width: auto;
+ width: 100%;
+ max-width: 360px;
+ }
+
+ .placement-ready-wrapper {
+ justify-content: center;
+ }
+}