implement ship placing UI
This commit is contained in:
@@ -1,19 +1,70 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { createGameConnection } from '../game'
|
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 }) {
|
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 [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const connectionRef = useRef(null)
|
||||||
const attemptRef = useRef(0)
|
const attemptRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const attempt = ++attemptRef.current
|
const attempt = ++attemptRef.current
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const connection = createGameConnection(session)
|
const connInstance = createGameConnection(session)
|
||||||
|
|
||||||
connection.promise
|
connInstance.promise
|
||||||
.then(() => {
|
.then((conn) => {
|
||||||
if (!cancelled && attempt === attemptRef.current) setStatus('ready')
|
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) => {
|
.catch((err) => {
|
||||||
if (cancelled || attempt !== attemptRef.current) return
|
if (cancelled || attempt !== attemptRef.current) return
|
||||||
@@ -23,33 +74,89 @@ export default function GameScreen({ session, onLeave }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
connection.cancel()
|
connInstance.cancel()
|
||||||
}
|
}
|
||||||
}, [session])
|
}, [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 (
|
return (
|
||||||
<div className="page">
|
<div className="page game-page">
|
||||||
<main className="search-screen">
|
{status === 'connecting' && (
|
||||||
{status === 'connecting' && (
|
<main className="search-screen">
|
||||||
<>
|
<div className="search-label">Подключение к игре...</div>
|
||||||
<div className="search-label">Подключение к игре...</div>
|
<div className="bouncing-dots" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'placing' && (
|
||||||
|
<main className="game-screen-main">
|
||||||
|
<ShipPlacementScreen
|
||||||
|
onReady={handleReady}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
opponentReady={opponentReady}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'game' && (
|
||||||
|
<main className="search-screen">
|
||||||
|
<div className="search-label">{getGameStubText()}</div>
|
||||||
|
{(gameState === STATE_WAIT_OPPONENT_READY ||
|
||||||
|
gameState === STATE_WAIT_OPPONENT_TURN) && (
|
||||||
<div className="bouncing-dots" aria-hidden="true">
|
<div className="bouncing-dots" aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</main>
|
||||||
{status === 'ready' && <div className="search-label">Расстановка кораблей</div>}
|
)}
|
||||||
{status === 'error' && (
|
|
||||||
<>
|
{status === 'error' && (
|
||||||
<div className="search-error">{error}</div>
|
<main className="search-screen">
|
||||||
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
<div className="search-error">{error}</div>
|
||||||
Назад в меню
|
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
||||||
</button>
|
Назад в меню
|
||||||
</>
|
</button>
|
||||||
)}
|
</main>
|
||||||
</main>
|
)}
|
||||||
|
|
||||||
|
{/* Модальный pop-up с надписью "Загрузка...", пока сервер не ответит сменой состояния */}
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal loading-modal">
|
||||||
|
<h2 className="modal-title">Загрузка...</h2>
|
||||||
|
<div className="bouncing-dots" style={{ justifyContent: 'center', marginTop: 16 }}>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={`sea-board-wrapper ${className}`}>
|
||||||
|
{/* Пустой угол (пересечение линеек) */}
|
||||||
|
<div className="sea-board-corner" aria-hidden="true" />
|
||||||
|
|
||||||
|
{/* Горизонтальная линейка (цифры 1..10) */}
|
||||||
|
<div className="sea-board-col-ruler" aria-hidden="true">
|
||||||
|
{COL_NUMBERS.map((num) => (
|
||||||
|
<div key={num} className="sea-ruler-cell">
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Вертикальная линейка (буквы А..К) */}
|
||||||
|
<div className="sea-board-row-ruler" aria-hidden="true">
|
||||||
|
{ROW_LETTERS.map((letter) => (
|
||||||
|
<div key={letter} className="sea-ruler-cell">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Само игровое поле 10x10 */}
|
||||||
|
<div className="sea-grid-container" ref={gridRef}>
|
||||||
|
{/* Базовая сетка 10x10 клеток */}
|
||||||
|
<div className="sea-grid">
|
||||||
|
{Array.from({ length: 100 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="sea-cell" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Слой расставленных кораблей */}
|
||||||
|
<div className="sea-ships-layer">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={ship.id}
|
||||||
|
className={`board-ship ${ship.orientation ? 'vertical' : 'horizontal'}`}
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (onShipPointerDown) {
|
||||||
|
onShipPointerDown(e, ship)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: ship.size }).map((_, segIdx) => (
|
||||||
|
<div
|
||||||
|
key={segIdx}
|
||||||
|
className="ship-segment"
|
||||||
|
data-segment={segIdx}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Слой призрака при перетаскивании (ghost preview) */}
|
||||||
|
{preview && (
|
||||||
|
<div
|
||||||
|
className={`board-ship ghost-preview ${
|
||||||
|
preview.orientation ? 'vertical' : 'horizontal'
|
||||||
|
} ${preview.isValid ? 'ghost-valid' : 'ghost-invalid'}`}
|
||||||
|
style={{
|
||||||
|
left: `${3 + preview.x * 33}px`,
|
||||||
|
top: `${3 + preview.y * 33}px`,
|
||||||
|
width: `${
|
||||||
|
!preview.orientation ? preview.size * 30 + (preview.size - 1) * 3 : 30
|
||||||
|
}px`,
|
||||||
|
height: `${
|
||||||
|
!preview.orientation ? 30 : preview.size * 30 + (preview.size - 1) * 3
|
||||||
|
}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: preview.size }).map((_, segIdx) => (
|
||||||
|
<div key={segIdx} className="ship-segment" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 12.7 12.7"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
}}
|
||||||
|
d="m 9.4816001,7.6107086 c 0,2.5748604 0,2.5748604 0,2.5748604 L 10.700616,8.9665533 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.17,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
}}
|
||||||
|
d="m 1.9172753,3.9133276 h 1.3938432 c 0,0 0.418658,0.043751 0.7439689,0.3929024 l 3.2927061,4.3012881 c 0,0 0.3355245,0.3407588 0.6968466,0.321765 l 1.9478831,-0.00957"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
}}
|
||||||
|
d="m 9.4810176,5.2347575 c 0,-2.5749117 0,-2.5749117 0,-2.5749117 L 10.699942,3.878886 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'currentColor',
|
||||||
|
strokeWidth: 1.77,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
}}
|
||||||
|
d="M 7.9405757,3.3290259 C 7.2803298,3.3554207 6.9286279,3.8253665 6.9286279,3.8253665 L 6.9047744,3.8538074 6.3276981,4.6070341 7.0497314,5.5923757 7.7745171,4.6437321 c 0.01942,-0.01041 0.2210083,-0.1184824 0.240372,-0.1394525 h 9.175e-4 v -9.175e-4 -9.173e-4 h -9.175e-4 v -9.175e-4 h 0.012844 l 1.9633434,0.011009 A 0.5871519,0.5871519 0 0 0 10.580079,3.9299557 0.5871519,0.5871519 0 0 0 9.9938292,3.3400353 L 8.0763583,3.3299434 c -0.04675,-0.00246 -0.091766,-0.00268 -0.1357826,-9.175e-4 z M 4.3432533,7.2006658 3.62122,8.1428873 C 3.4547894,8.317437 3.2641018,8.3428563 3.2569921,8.3438089 H 1.9184323 A 0.5871519,0.5871519 0 0 0 1.3285117,8.9337295 0.5871519,0.5871519 0 0 0 1.9184323,9.5190627 h 1.4211304 l 0.031193,-0.00367 c 0,0 0.6339103,-0.06015 1.1137846,-0.5752412 l 0.020184,-0.021101 0.5605621,-0.7330428 z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="placement-screen-layout">
|
||||||
|
{opponentReady && (
|
||||||
|
<div className="placement-opponent-badge">
|
||||||
|
Соперник готов к бою!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="placement-container">
|
||||||
|
{/* Левая половина: игровое поле 10x10 с линейками и кнопками под ним */}
|
||||||
|
<div className="placement-left">
|
||||||
|
<SeaBoard
|
||||||
|
ships={ships}
|
||||||
|
preview={activeDrag?.candidate}
|
||||||
|
onShipPointerDown={(e, ship) => handleStartDrag(e, ship, true)}
|
||||||
|
gridRef={gridRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="placement-board-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary ship-action-btn"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={placedCount === 0 || isSubmitting}
|
||||||
|
>
|
||||||
|
Очистить поле
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary ship-action-btn ship-auto-btn"
|
||||||
|
title="Случайная расстановка (Авто)"
|
||||||
|
onClick={handleRandom}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<ShuffleIcon size={20} />
|
||||||
|
<span>Авто</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая половина: инвентарь кораблей в 4 строки и кнопка Готово */}
|
||||||
|
<div className="placement-right">
|
||||||
|
<div className="ship-inventory">
|
||||||
|
<div className="inventory-header">
|
||||||
|
<span className="inventory-title">Корабли</span>
|
||||||
|
<span className="inventory-hint">
|
||||||
|
{allPlaced ? 'Все корабли на поле' : `Расставлено: ${placedCount} из 10`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inventory-rows">
|
||||||
|
{inventoryRows.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="inventory-row">
|
||||||
|
{row.map((ship) => {
|
||||||
|
const slotWidth = ship.size * 30 + (ship.size - 1) * 3
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ship.id}
|
||||||
|
className="inventory-slot"
|
||||||
|
style={{
|
||||||
|
width: `${slotWidth}px`,
|
||||||
|
height: '30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!ship.placed && (
|
||||||
|
<div
|
||||||
|
className="board-ship horizontal in-inventory"
|
||||||
|
onPointerDown={(e) => handleStartDrag(e, ship, false)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: ship.size }).map((_, segIdx) => (
|
||||||
|
<div
|
||||||
|
key={segIdx}
|
||||||
|
className="ship-segment"
|
||||||
|
data-segment={segIdx}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка по повороту кораблей */}
|
||||||
|
<div className="placement-hint">
|
||||||
|
<span className="placement-hint-icon" aria-hidden="true">💡</span>
|
||||||
|
<span className="placement-hint-text">
|
||||||
|
Корабли можно поворачивать во время перетаскивания (клавиши <strong>Пробел</strong> или <strong>R</strong>), а также одиночным нажатием после перетаскивания.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="placement-ready-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-big ready-btn"
|
||||||
|
disabled={!allPlaced || isSubmitting}
|
||||||
|
onClick={() => onReady(ships)}
|
||||||
|
>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Парящий под курсором корабль при перетаскивании */}
|
||||||
|
{activeDrag && (
|
||||||
|
<div
|
||||||
|
className={`floating-drag-ship ${
|
||||||
|
activeDrag.orientation ? 'vertical' : 'horizontal'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
left: `${
|
||||||
|
activeDrag.currentPos.x -
|
||||||
|
(!activeDrag.orientation ? activeDrag.segIndex * 33 + 15 : 15)
|
||||||
|
}px`,
|
||||||
|
top: `${
|
||||||
|
activeDrag.currentPos.y -
|
||||||
|
(activeDrag.orientation ? activeDrag.segIndex * 33 + 15 : 15)
|
||||||
|
}px`,
|
||||||
|
width: `${
|
||||||
|
!activeDrag.orientation
|
||||||
|
? activeDrag.ship.size * 30 + (activeDrag.ship.size - 1) * 3
|
||||||
|
: 30
|
||||||
|
}px`,
|
||||||
|
height: `${
|
||||||
|
activeDrag.orientation
|
||||||
|
? activeDrag.ship.size * 30 + (activeDrag.ship.size - 1) * 3
|
||||||
|
: 30
|
||||||
|
}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: activeDrag.ship.size }).map((_, segIdx) => (
|
||||||
|
<div key={segIdx} className="ship-segment" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+52
-16
@@ -6,10 +6,18 @@ export class GameConnectionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Опкоды протокола game-server
|
// Опкоды протокола game-server
|
||||||
const OP_JOIN = 1
|
export const OP_JOIN = 1
|
||||||
const OP_END = 2
|
export const OP_END = 2
|
||||||
const OP_STATE = 3
|
export const OP_STATE = 3
|
||||||
const OP_ACK = 4
|
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 = {
|
const CLOSE_REASONS = {
|
||||||
4000: 'Соединение заменено новым',
|
4000: 'Соединение заменено новым',
|
||||||
@@ -31,6 +39,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
let settled = false
|
let settled = false
|
||||||
let lastSeq = 0
|
let lastSeq = 0
|
||||||
|
|
||||||
|
let connection = null
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
const fail = (err) => {
|
const fail = (err) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
@@ -39,19 +49,32 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const succeed = (connection) => {
|
const succeed = (conn) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
resolve(connection)
|
resolve(conn)
|
||||||
// Сокет НЕ закрываем — соединение переиспользуется для игры
|
|
||||||
ws.onerror = null
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
if (connection.onClose) connection.onClose(event.code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws = new WebSocket(gameServerUrl)
|
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.onopen = () => {
|
||||||
ws.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
|
ws.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
|
||||||
}
|
}
|
||||||
@@ -73,18 +96,29 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
|
|
||||||
if (opcode === OP_STATE) {
|
if (opcode === OP_STATE) {
|
||||||
const state = msg[2]
|
const state = msg[2]
|
||||||
if (state === 1) {
|
if (!settled && state === STATE_PLACING_SHIPS) {
|
||||||
succeed({ socket: ws, send: sendMessage })
|
succeed(connection)
|
||||||
|
}
|
||||||
|
if (connection && typeof connection.onState === 'function') {
|
||||||
|
connection.onState(state)
|
||||||
}
|
}
|
||||||
} else if (opcode === OP_END) {
|
} else if (opcode === OP_END) {
|
||||||
const code = msg[2]
|
const code = msg[2]
|
||||||
const reason = msg[3] || 'Партия завершена'
|
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 = () => {
|
ws.onerror = (err) => {
|
||||||
if (!settled) fail(new GameConnectionError('connection', 'Сетевая ошибка'))
|
if (!settled) {
|
||||||
|
fail(new GameConnectionError('connection', 'Сетевая ошибка'))
|
||||||
|
} else if (connection && typeof connection.onError === 'function') {
|
||||||
|
connection.onError(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
@@ -93,6 +127,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
CLOSE_REASONS[event.code] ||
|
CLOSE_REASONS[event.code] ||
|
||||||
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
|
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
|
||||||
fail(new GameConnectionError('closed', message))
|
fail(new GameConnectionError('closed', message))
|
||||||
|
} else if (connection && typeof connection.onClose === 'function') {
|
||||||
|
connection.onClose(event.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -510,3 +510,408 @@ input:not([type]), input[type="text"] {
|
|||||||
height: 58px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user