implement full game ui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="battle-screen-container">
|
||||
{/* Верхняя панель со статусом и кнопкой сдаться в углу */}
|
||||
<div className="battle-header">
|
||||
<div className="battle-header-left">
|
||||
<div className="battle-status-badge">
|
||||
{isMyTurn ? 'Ваш ход — выберите цель' : 'Ожидайте ход соперника'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="battle-header-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary surrender-btn"
|
||||
onClick={() => setShowSurrenderModal(true)}
|
||||
>
|
||||
Сдаться
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основная боевая область с двумя полями и стрелкой посередине */}
|
||||
<div className="battle-boards-layout">
|
||||
{/* Левая половина: собственное поле игрока */}
|
||||
<div className="battle-field-panel">
|
||||
<div className="battle-field-title">Ваш флот</div>
|
||||
<SeaBoard cells={myBoard} />
|
||||
</div>
|
||||
|
||||
{/* Между половинами: голубая стрелка, показывающая направление атаки */}
|
||||
<div className="battle-turn-indicator-col">
|
||||
<div
|
||||
className={`battle-arrow ${isMyTurn ? 'arrow-right' : 'arrow-left'}`}
|
||||
title={isMyTurn ? 'Вы атакуете поле соперника' : 'Соперник атакует ваше поле'}
|
||||
>
|
||||
<svg
|
||||
width="44"
|
||||
height="44"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="6,4 18,12 6,20" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="battle-arrow-text">
|
||||
{isMyTurn ? 'Атака' : 'Оборона'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая половина: поле соперника */}
|
||||
<div className={`battle-field-panel ${isMyTurn ? 'active-target-panel' : ''}`}>
|
||||
<div className="battle-field-title">Флот соперника</div>
|
||||
<SeaBoard
|
||||
cells={opponentBoard}
|
||||
interactive={isMyTurn && !isFiring}
|
||||
aiming={isMyTurn && !isFiring ? aiming : null}
|
||||
onCellHover={handleCellHover}
|
||||
onCellLeave={handleCellLeave}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно заглушки "Сдаться" */}
|
||||
{showSurrenderModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal surrender-modal">
|
||||
<h2 className="modal-title">Сдаться</h2>
|
||||
<p className="surrender-desc">
|
||||
Функция «Сдаться» в данный момент находится в разработке на сервере.
|
||||
</p>
|
||||
<div className="surrender-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowSurrenderModal(false)}
|
||||
>
|
||||
Вернуться в бой
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onLeave}
|
||||
>
|
||||
Выйти в меню
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="page game-page">
|
||||
{status === 'connecting' && (
|
||||
@@ -121,17 +127,45 @@ export default function GameScreen({ session, onLeave }) {
|
||||
</main>
|
||||
)}
|
||||
|
||||
{status === 'game' && (
|
||||
{status === 'waiting_opponent' && (
|
||||
<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">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
<div className="search-label">Соперник расставляет корабли...</div>
|
||||
<div className="bouncing-dots" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{status === 'battle' && (
|
||||
<main className="game-screen-main">
|
||||
<BattleScreen
|
||||
myShips={myShips}
|
||||
gameState={gameState}
|
||||
connection={connectionRef.current}
|
||||
onLeave={onLeave}
|
||||
/>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{status === 'victory' && (
|
||||
<main className="search-screen">
|
||||
<div className="end-game-title victory-title">Победа!</div>
|
||||
<div className="end-game-subtitle">Все корабли соперника уничтожены</div>
|
||||
<button className="btn btn-big btn-primary" onClick={onLeave}>
|
||||
В главное меню
|
||||
</button>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{status === 'defeat' && (
|
||||
<main className="search-screen">
|
||||
<div className="end-game-title defeat-title">Поражение</div>
|
||||
<div className="end-game-subtitle">Все ваши корабли были уничтожены</div>
|
||||
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
||||
В главное меню
|
||||
</button>
|
||||
</main>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={`sea-board-wrapper ${className}`}>
|
||||
@@ -37,52 +48,87 @@ export default function SeaBoard({
|
||||
{/* Само игровое поле 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-grid ${interactive ? 'interactive-grid' : ''}`}
|
||||
onMouseLeave={onCellLeave}
|
||||
>
|
||||
{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
|
||||
|
||||
{/* Слой расставленных кораблей */}
|
||||
<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
|
||||
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 (
|
||||
<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)
|
||||
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) => (
|
||||
<div
|
||||
key={segIdx}
|
||||
className="ship-segment"
|
||||
data-segment={segIdx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${x}-${y}`}
|
||||
className={classNames.join(' ')}
|
||||
onMouseEnter={() => onCellHover && onCellHover(x, y)}
|
||||
onClick={() => onCellClick && onCellClick(x, y)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
: Array.from({ length: 100 }).map((_, idx) => (
|
||||
<div key={idx} className="sea-cell" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Слой расставленных кораблей (в режиме расстановки) */}
|
||||
{!cells && (
|
||||
<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 && (
|
||||
{!cells && preview && (
|
||||
<div
|
||||
className={`board-ship ghost-preview ${
|
||||
preview.orientation ? 'vertical' : 'horizontal'
|
||||
|
||||
@@ -11,6 +11,8 @@ export const OP_END = 2
|
||||
export const OP_STATE = 3
|
||||
export const OP_ACK = 4
|
||||
export const OP_PLACE_SHIPS = 5
|
||||
export const OP_FIRE = 6
|
||||
export const OP_FIRE_RESULT = 7
|
||||
|
||||
// Коды состояний протокола game-server
|
||||
export const STATE_PLACING_SHIPS = 1 // оба игрока расставляют корабли
|
||||
@@ -19,6 +21,17 @@ export const STATE_WAIT_OPPONENT_READY = 3 // ожидайте готовнос
|
||||
export const STATE_YOUR_TURN = 4 // ваш ход
|
||||
export const STATE_WAIT_OPPONENT_TURN = 5 // ожидайте ход соперника
|
||||
|
||||
// Коды результатов выстрела (FIRE_RESULT)
|
||||
export const FIRE_RESULT_MISS = 1 // промах
|
||||
export const FIRE_RESULT_HIT = 2 // попадание
|
||||
export const FIRE_RESULT_SUNK = 3 // корабль потоплен
|
||||
export const FIRE_RESULT_INVALID_POSITION = 4 // неверная позиция
|
||||
|
||||
// Коды завершения партии (END)
|
||||
export const END_CODE_TIMEOUT = -1 // join timeout
|
||||
export const END_CODE_VICTORY = 1 // победа
|
||||
export const END_CODE_DEFEAT = 2 // поражение
|
||||
|
||||
const CLOSE_REASONS = {
|
||||
4000: 'Соединение заменено новым',
|
||||
4001: 'Ожидалось сообщение JOIN',
|
||||
@@ -69,7 +82,11 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
||||
}
|
||||
sendMessage(payload)
|
||||
},
|
||||
fire(x, y) {
|
||||
sendMessage([OP_FIRE, Number(x), Number(y)])
|
||||
},
|
||||
onState: null,
|
||||
onFireResult: null,
|
||||
onEnd: null,
|
||||
onClose: null,
|
||||
onError: null,
|
||||
@@ -102,6 +119,11 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
||||
if (connection && typeof connection.onState === 'function') {
|
||||
connection.onState(state)
|
||||
}
|
||||
} else if (opcode === OP_FIRE_RESULT) {
|
||||
const resultCode = msg[2]
|
||||
if (connection && typeof connection.onFireResult === 'function') {
|
||||
connection.onFireResult(resultCode)
|
||||
}
|
||||
} else if (opcode === OP_END) {
|
||||
const code = msg[2]
|
||||
const reason = msg[3] || 'Партия завершена'
|
||||
|
||||
@@ -915,3 +915,215 @@ body.is-dragging * {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Боевой экран (Battle Screen)
|
||||
========================================================================== */
|
||||
|
||||
.battle-screen-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 920px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.battle-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.battle-status-badge {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-darker);
|
||||
font-family: 'Comfortaa', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(47, 128, 237, 0.12);
|
||||
}
|
||||
|
||||
.surrender-btn {
|
||||
font-size: 14px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.battle-boards-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.battle-field-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.battle-field-title {
|
||||
font-family: 'Comfortaa', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Стрелка между полями */
|
||||
.battle-turn-indicator-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.battle-arrow {
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
filter: drop-shadow(0 2px 6px rgba(47, 128, 237, 0.3));
|
||||
}
|
||||
|
||||
.battle-arrow.arrow-right {
|
||||
transform: scaleX(1);
|
||||
animation: arrow-nudge-right 1.6s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.battle-arrow.arrow-left {
|
||||
transform: scaleX(-1);
|
||||
animation: arrow-nudge-left 1.6s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes arrow-nudge-right {
|
||||
0%, 100% {
|
||||
transform: scaleX(1) translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(1) translateX(6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arrow-nudge-left {
|
||||
0%, 100% {
|
||||
transform: scaleX(-1) translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(-1) translateX(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.battle-arrow-text {
|
||||
font-family: 'Comfortaa', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Прицеливание на интерактивном поле соперника */
|
||||
.interactive-grid .sea-cell:not(.miss-cell):not(.damaged-ship-cell):not(.destroyed-ship-cell) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Подсветка строки и столбца более тёмным цветом */
|
||||
.sea-cell.aim-row,
|
||||
.sea-cell.aim-col {
|
||||
background-color: #d7e8fa !important;
|
||||
}
|
||||
|
||||
/* Медленно мигающая клетка на пересечении прицела */
|
||||
.sea-cell.aim-target {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
cursor: crosshair;
|
||||
animation: target-slow-blink 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes target-slow-blink {
|
||||
0%, 100% {
|
||||
background-color: #82b3f4;
|
||||
box-shadow: inset 0 0 0 2px var(--primary);
|
||||
}
|
||||
50% {
|
||||
background-color: #2f80ed;
|
||||
box-shadow: inset 0 0 0 3px #134c9c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Модальное окно "Сдаться" */
|
||||
.surrender-modal {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.surrender-desc {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-soft);
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.surrender-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Экраны Победы / Поражения */
|
||||
.end-game-title {
|
||||
font-family: 'Comfortaa', sans-serif;
|
||||
font-size: clamp(40px, 8vw, 68px);
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.victory-title {
|
||||
color: var(--primary);
|
||||
text-shadow: 0 4px 12px rgba(47, 128, 237, 0.25);
|
||||
}
|
||||
|
||||
.defeat-title {
|
||||
color: var(--danger);
|
||||
text-shadow: 0 4px 12px rgba(224, 72, 62, 0.25);
|
||||
}
|
||||
|
||||
.end-game-subtitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.battle-boards-layout {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.battle-turn-indicator-col {
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.battle-arrow.arrow-left {
|
||||
transform: rotate(90deg) !important;
|
||||
}
|
||||
|
||||
.battle-arrow.arrow-right {
|
||||
transform: rotate(-90deg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user