Привет, Хабр!
В этой статье расскажу о создании популярной игры. Можно будет поиграть со случайным соперником и попробовать свои силы с другом.
Исходники в конце статьи.
Технологический стэк:
Frontend: VueJS [Options API] (TypeScript, Vuex, Canvas, Bootstrap, Nginx)
Backend: Python (FastAPI, Postgres, SQLAlchemy, Websockets)
Все приложение развернуто на сервере средствами docker-compose.
Рассказывать про саму игру в морской бой я не буду. Каждый из нас наверняка играл в нее в том или ином возрасте. Здесь же я расскажу конкретно про мое приложение, о технических деталях реализации и т.д.
От вас жду конструктивной критики и полезных советов.
Об игре
Игра представляет собой одностраничное SPA-приложение, написанное на фреймворке VueJS с использованием преимуществ языка TypeScript (далее - TS). Да, в игре есть кейсы, когда она может попросить пользователя обновить страницу для "полной перезагрузки" объектов (например, после отключения от игры одного из активных игроков), но в остальном можно считать эту игру полноценным SPA-приложением.
Чтобы играть было удобно не только с компьютера, но и мобильного устройства — старался соблюдать правила Responsive Design, в чем мне очень помогла библиотека Bootstrap [v5.3.3]. Все события для перемещения и изменения положения кораблей на канвасе привязаны к тачам, поэтому можно легко управлять ими и с мобильных устройств.
Правила игры
На текущий момент доступны два режима игры — «Игра со случайным соперником» и «Игра с другом». В «бэклоге» также предусмотрено создание «Игры с компьютером».
При проектировании UI игры и ее функционала — я старался сделать ее максимально user-френдли и интуитивно понятной, чтобы не расписывать подробно правила. А также для того, чтобы игрок не кликнул туда, куда кликать не надо, а если и кликнул, то получал информативные предупреждения, типа:
которые появляются в левом нижнем углу окна.
Но кратко все-таки распишу основные правила игры.
Для игры обязательно нужно ввести ник и пройти капчу (она не сложная, всего 4 хорошо читаемых символа).
Далее выбираем режим игры: нажимаем соответствующую кнопку.
Игра со случайным соперником
При выборе игры со случайным соперником — появится мигающая надпись «Поиск соперника» и система будет ждать первого свободного игрока, который зайдет в игру и тоже выберет аналогичный тип игры. После этого игра «соединит» их вместе и выведет у обоих игроков на экран надпись:
Это значит, что соперник найден. Приступаем к расстановке кораблей.
На левом гриде находится Ваша флотилия, которую необходимо расставить на поле боя. Количество кораблей — 10 штук. Один 4-х палубный, два 3-х палубных, три 2-х палубных и четыре однопалубных . Перемещать корабли можно только за первую клетку. Если кликнуть (тапнуть) по ней двойным кликом — корабль поменяет положение (с горизонтального на вертикальный и наоборот). При перемещении кораблей на гриде и изменении
положения — игра следит, чтобы не было пересечений, а в случае их появлений — подсвечивает места пересечений (касаний) красным цветом. При такой расстановке приложение не позволит игроку начать игру и выведет соответствующее предупреждение. При попытке изменить положение корабля — если он стоит рядом с границей и при новом положении будет ее пересекать — игра также предупредит об этом. Т.е. если, например, попытаться изменить положение трехпалубного корабля на рисунке 3 с вертикального на горизонтальный — будет получено следующее предупреждение:
После того как корабли расставлены — нажимаем кнопку "Играть", это проинформирует соперника о том, что Вы готовы к игре:
Если Вы и Ваш соперник оба нажали «Играть» — появляется надпись «Игра началась» и игра начинается. Возможность расстановки кораблей во время игры блокируется. Первенство хода определяется на сервере случайным образом. У игрока, чья очередь стрелять — появляется мигающая надпись «ваш ход»:
Далее игроки делают выстрелы путем клика по той или иной локации (клетке с координатами) на гриде справа. При попадании или потоплении корабля — за игроком сохраняется очередность выстрела; если же игрок выстрелил мимо, очередь переходит к его сопернику.
При попадании/потоплении корабля — соответствующие граничные (угловые) клетки подсвечиваются автоматически на обоих гридах:
Игра продолжается до тех пор пока кто-нибудь из соперников полностью не потопит вражескую флотилию. После победы/поражения у игроков появляются соответствующие статусы, а у проигравшего отображаются красным цветом непотопленные корабли соперника:
При желании — можно сыграть с тем же соперником еще раз, не проходя заново вышеописанные действия по вводу ника, капчи, созданию игры, поиску и т.п. Для этого после окончания игры появится кнопка:
Нажав на нее — у соперника появится соответствующее уведомление:
Если Ваш соперник тоже нажмет эту кнопку — у обоих игроков появится возможность расставить заново корабли и начать новую игру. Ограничений на количество игр нет. Для выхода из игры достаточно обновить страницу.
Если один из игроков выходит из игры (обновляет страницу, либо закрывает вкладку) в ее «активной фазе» — другому приходит уведомление о разрыве соединения:
Игра с другом
При выборе игры с другом — появится форма с отображением Вашего id и поле ввода для id друга с соответствующими инструкциями:
Согласно инструкциям — необходимо обменяться с другом id-шниками, ввести id друга в нужное поле и нажать кнопку «Создать игру». Далее — аналогично игре со случайным соперником — нужно расставить корабли и нажать «Играть». После завершения игры с другом — у вас также будет возможность сыграть с ним повторно без нового создания игры.
Backend
Бэк реализован на языке Python, фреймворк FastAPI. Для обслуживания запросов в докере запущен один единственный uvicorn-воркер. Фронт общается с сервером посредством WebSocket-подключений. БД на данный момент состоит только из трех табличек:
Модель БД
Две из них справочные — с типом игры и статусом игрока, а третья (TRivalCouple - соперничающая пара) — буферная таблица, в которой игроки "соединяются" между собой для игры, а после завершения игры запись удаляется.
Для хранения сокетов и управления ими был создан класс ConnectionManager, который хранит uuid клиента и его WS-подключение:
class ConnectionManager
class ConnectionManager:
def __init__(self):
self.active_connections: dict[uuid.UUID, WebSocket] = {}
async def connect(self, client_uuid: uuid.UUID, websocket: WebSocket):
await websocket.accept()
self.active_connections[client_uuid] = websocket
async def disconnect(self, client_uuid: uuid.UUID):
self.active_connections.pop(client_uuid)
async def send_personal_message(self, client_uuid: uuid.UUID, message: dict):
if client_uuid in self.active_connections:
await self.active_connections[client_uuid].send_json(message)
async def send_structured_data(self, client_uuid: uuid.UUID, msg_type: str, data: dict, is_status_ok: bool = True):
message_to_send = {'msg_type': msg_type, 'data': data, 'is_status_ok': is_status_ok}
await self.send_personal_message(client_uuid, message_to_send)
async def broadcast(self, message: str): # но использовать широковещательную рассылку пока не планировал
for connection in self.active_connections.values():
await connection.send_text(message)
async def print_number_of_clients(self):
print('Number of clients: ', len(self.active_connections))
Входной точкой служит ws-эндпоинт, принимающий запросы:
manager = ConnectionManager()
@router.websocket("/client/{client_uuid}/ws")
async def websocket_endpoint(ws: WebSocket, client_uuid: uuid.UUID):
await manager.connect(client_uuid, ws)
try:
while True:
data_from_client = await ws.receive_json()
await process_data(client_uuid, data_from_client, manager)
except WebSocketDisconnect:
await manager.disconnect(client_uuid)
await delete_rival_couple_and_notify(client_uuid, manager)
await manager.print_number_of_clients()
Метод process_data()
обрабатывает сообщения от клиента и отправляет им сообщения в ответ (см. controller.py):
controller.py
import uuid
from datetime import datetime
from random import randint
from sqlalchemy import select, func, or_, and_, ColumnElement, asc
import db
from entities.model import TRivalCouple, TGameType
from helper.game_state import GameState
from helper.game_type import GameType
from helper.message_type import MessageType
from websocket.common import notify_enemy_about_game_creation
from websocket.connection_manager import ConnectionManager
from websocket.friend_game_controller import process_friend_game_creation
player_available_condition: ColumnElement[bool] = or_(
and_(TRivalCouple.dfplayer1.__ne__(None), TRivalCouple.dfplayer2.__eq__(None)),
and_(TRivalCouple.dfplayer1.__eq__(None), TRivalCouple.dfplayer2.__ne__(None)),
)
random_game_clause: ColumnElement[bool] = TGameType.id.__eq__(GameType.RANDOM.value)
def find_by_client_id_clause(client_uuid: uuid.UUID) -> ColumnElement[bool]:
clause: ColumnElement[bool] = or_(
TRivalCouple.dfplayer1.__eq__(client_uuid),
TRivalCouple.dfplayer2.__eq__(client_uuid)
)
return clause
def available_random_couple_exists() -> bool:
with db.session_scope() as s_:
stmt = select(func.count(TRivalCouple.id)).join(TGameType, TRivalCouple.dfgame_type.__eq__(TGameType.id),
isouter=True).where(
and_(player_available_condition, random_game_clause))
count = s_.execute(stmt).scalar()
if count == 0:
return False
return True
async def create_random_couple(client_uuid, data_from_client):
with db.session_scope() as s_:
rival_couple_player: TRivalCouple = TRivalCouple(
id=uuid.uuid4(),
dfplayer1=client_uuid,
dfplayer1_nickname=data_from_client['nickName'],
dfplayer1_state=GameState.SEARCHING_FOR_OPPONENT.value,
dfcreated_on=datetime.now(),
dfgame_type=GameType.RANDOM.value
)
s_.add(rival_couple_player)
def find_available_random_couple() -> TRivalCouple:
with db.session_scope() as s_:
stmt = select(TRivalCouple).join(TGameType, TRivalCouple.dfgame_type.__eq__(TGameType.id),
isouter=True).where(player_available_condition, random_game_clause).order_by(
asc(TRivalCouple.dfcreated_on)).limit(1)
return s_.execute(stmt).scalar()
async def add_player_to_rival_couple(rc: TRivalCouple, client_uuid: uuid.UUID, data_from_client: dict):
with db.session_scope() as s_:
if not rc.dfplayer1:
rc.dfplayer1 = client_uuid
rc.dfplayer1_nickname = data_from_client['nickName']
rc.dfplayer1_state = GameState.SHIPS_POSITIONING.value
rc.dfplayer2_state = GameState.SHIPS_POSITIONING.value
else:
rc.dfplayer2 = client_uuid
rc.dfplayer2_nickname = data_from_client['nickName']
rc.dfplayer2_state = GameState.SHIPS_POSITIONING.value
rc.dfplayer1_state = GameState.SHIPS_POSITIONING.value
s_.add(rc) # add to s_.dirty for subsequent commit to DB
def find_rival_couple_by_id(game_id: uuid.UUID) -> TRivalCouple:
with db.session_scope() as s_:
return s_.get(TRivalCouple, game_id)
def find_rival_couple_by_client_id(client_uuid: uuid.UUID) -> TRivalCouple:
"""
Возвращает объект типа TRivalCouple по client_uuid
:param client_uuid: уникальный идентификатор клиента (websocket-подключения)
:return: сопернищающая пара (TRivalCouple)
"""
with db.session_scope() as s_:
# запись в БД с таким фильтром по-хорошему должна быть только одна
stmt = select(TRivalCouple).where(find_by_client_id_clause(client_uuid)).limit(1)
return s_.scalar(stmt)
async def delete_rival_couple_and_notify(client_uuid: uuid.UUID, manager: ConnectionManager):
couple = find_rival_couple_by_client_id(client_uuid)
if not couple:
return
with db.session_scope() as s_:
if couple.dfplayer1 and couple.dfplayer2:
if couple.dfplayer1 == client_uuid: # если отключается player1, то оповещаем player2
await manager.send_structured_data(couple.dfplayer2, MessageType.DISCONNECTION.value,
{'enemy_nickname': couple.dfplayer1_nickname})
else: # и наоборот
await manager.send_structured_data(couple.dfplayer1, MessageType.DISCONNECTION.value,
{'enemy_nickname': couple.dfplayer2_nickname})
s_.delete(couple)
async def process_data(client_uuid: uuid.UUID, data_from_client: dict, manager: ConnectionManager):
msg_type = data_from_client['msg_type']
if msg_type == MessageType.GAME_CREATION.value:
if data_from_client['game_type'] == GameType.RANDOM.value:
if not available_random_couple_exists():
await create_random_couple(client_uuid, data_from_client)
else:
rc: TRivalCouple = find_available_random_couple()
await add_player_to_rival_couple(rc, client_uuid, data_from_client)
await notify_enemy_about_game_creation(rc, manager)
else: # game_type == GameType.FRIEND.value:
await process_friend_game_creation(client_uuid, data_from_client, manager)
if msg_type == MessageType.SHIPS_ARE_ARRANGED.value:
rc = find_rival_couple_by_id(data_from_client['game_id'])
if not rc:
return
with db.session_scope() as s_:
if rc.dfplayer1 == client_uuid: # если первый игрок нажимает "Играть"
rc.dfplayer1_state = GameState.PLAYING.value
await manager.send_structured_data(rc.dfplayer2, msg_type, {'enemy_client_id': str(rc.dfplayer1)})
else:
rc.dfplayer2_state = GameState.PLAYING.value
await manager.send_structured_data(rc.dfplayer1, msg_type, {'enemy_client_id': str(rc.dfplayer2)})
# Если оба игрока расставили корабли и нажали "Играть"
# отправляем каждому сообщение с msg_type = PLAY, сигнализирующее о начале игры
if rc.dfplayer1_state == GameState.PLAYING.value and rc.dfplayer2_state == GameState.PLAYING.value:
# определяем кто из игроков ходит првым и оповещаем игроков
turn = randint(1, 2)
if turn == 1:
await manager.send_structured_data(rc.dfplayer1, MessageType.PLAY.value, {'turn_to_shoot': True})
await manager.send_structured_data(rc.dfplayer2, MessageType.PLAY.value, {'turn_to_shoot': False})
else:
await manager.send_structured_data(rc.dfplayer1, MessageType.PLAY.value, {'turn_to_shoot': False})
await manager.send_structured_data(rc.dfplayer2, MessageType.PLAY.value, {'turn_to_shoot': True})
s_.add(rc) # и обновляем запись в БД
if msg_type == MessageType.FIRE_REQUEST.value:
await manager.send_structured_data(uuid.UUID(data_from_client['enemy_client_id']), msg_type,
{'shot_location': data_from_client['shot_location']})
if msg_type == MessageType.FIRE_RESPONSE.value:
data_for_sending = {'shot_result': data_from_client['shot_result'],
'shot_location': data_from_client['shot_location']}
if 'sunkShip' in data_from_client:
data_for_sending['sunkShip'] = data_from_client['sunkShip']
await manager.send_structured_data(uuid.UUID(data_from_client['enemy_client_id']), msg_type,
data_for_sending)
if msg_type == MessageType.UNSUNK_SHIPS.value:
await manager.send_structured_data(uuid.UUID(data_from_client['enemy_client_id']), msg_type,
{'unSunkShips': data_from_client['unSunkShips']})
if msg_type == MessageType.GAME_OVER.value:
await manager.send_structured_data(uuid.UUID(data_from_client['enemy_client_id']), msg_type, data={})
if msg_type == MessageType.PLAY_AGAIN.value:
# оповестим игрока о том, что противник хочет сыграть с ним еще раз
await manager.send_structured_data(uuid.UUID(data_from_client['enemy_client_id']), msg_type, {})
rc = find_rival_couple_by_id(data_from_client['game_id'])
if not rc:
return
with db.session_scope() as s_:
sp: int = GameState.SHIPS_POSITIONING.value
if rc.dfplayer1 == client_uuid:
rc.dfplayer1_state = sp
else:
rc.dfplayer2_state = sp
s_.add(rc) # и обновляем запись в БД
Типы сообщений:
message_type.py
from enum import Enum
class MessageType(Enum):
GAME_CREATION = 0
SHIPS_ARE_ARRANGED = 1
PLAY = 2
FIRE_REQUEST = 3
FIRE_RESPONSE = 4
UNSUNK_SHIPS = 5
GAME_OVER = 6
DISCONNECTION = 7
PLAY_AGAIN = 8
Модель данных:
model.py
from sqlalchemy import Uuid, DateTime, ForeignKey, String, SmallInteger
from sqlalchemy.orm import DeclarativeBase, mapped_column
class Base(DeclarativeBase):
pass
class TRivalCouple(Base):
__tablename__ = "trival_couple"
id = mapped_column(Uuid, primary_key=True)
dfplayer1 = mapped_column(Uuid, index=True)
dfplayer1_nickname = mapped_column(String(25))
dfplayer1_state = mapped_column(ForeignKey("tplayer_state.id"), index=True)
dfplayer2 = mapped_column(Uuid, index=True)
dfplayer2_nickname = mapped_column(String(25))
dfplayer2_state = mapped_column(ForeignKey("tplayer_state.id"), index=True)
dfgame_type = mapped_column(ForeignKey("tgame_type.id"), index=True)
dfcreated_on = mapped_column(DateTime)
dffinished_on = mapped_column(DateTime)
class TGameType(Base):
__tablename__ = "tgame_type"
id = mapped_column(SmallInteger, primary_key=True)
dfname_en = mapped_column(String(30), unique=True)
dfname = mapped_column(String(30))
class TPlayerState(Base):
__tablename__ = "tplayer_state"
id = mapped_column(SmallInteger, primary_key=True)
dfname_en = mapped_column(String(30), unique=True)
dfname = mapped_column(String(30))
В теле сообщения клиент передает на сервер тип сообщения MessageType
и в зависимости от него выполняются те или иные действия (см. файл controller.py выше, в нем содержится основная логика).
Основная роль бэка— в «связывании» соперников друг с другом во временной таблице TRivalCouple и передаче сообщений между клиентами. Аналогично тому, как если бы два игрока играли в морской бой «на бумаге» — один называет координаты, а второй отвечает результатом (мимо/ранил/убил).
Поиск и связывание соперников
При нажатии на кнопку «Игра со случайным соперником» — фронт посылает на бэк сообщение с типом MessageType.GAME_CREATION
. Бэк пытается найти запись в таблице TRivalCouple, у которой один игрок уже присутствует, а для второго есть «свободная ячейка» (= null). Если он находит такую запись — добавляет игрока в нее и оповещает его оппонента о найденном сопернике. Если такой записи нет — создает ее и прописывает в ней игрока:
if msg_type == MessageType.GAME_CREATION.value:
if data_from_client['game_type'] == GameType.RANDOM.value:
if not available_random_couple_exists():
await create_random_couple(client_uuid, data_from_client)
else:
rc: TRivalCouple = find_available_random_couple()
await add_player_to_rival_couple(rc, client_uuid, data_from_client)
await notify_enemy_about_game_creation(rc, manager)
else: # game_type == GameType.FRIEND.value:
await process_friend_game_creation(client_uuid, data_from_client, manager)
В случае игры с другом ситуация плюс-минус аналогичная, см. тело метода process_friend_game_creation()
:
friend_game_controller.py
import uuid
from datetime import datetime
from sqlalchemy import select, func, and_
import db
from entities.model import TRivalCouple, TGameType
from helper.game_state import GameState
from helper.game_type import GameType
from websocket.common import notify_enemy_about_game_creation
from websocket.connection_manager import ConnectionManager
def friend_couple_exists(client_uuid: uuid.UUID, friend_uuid: uuid.UUID) -> bool:
with db.session_scope() as s_:
stmt = select(func.count(TRivalCouple.id)).join(TGameType, TRivalCouple.dfgame_type.__eq__(TGameType.id),
isouter=True).where(
and_(TGameType.id.__eq__(GameType.FRIEND.value), TRivalCouple.dfplayer1 == friend_uuid,
TRivalCouple.dfplayer2 == client_uuid))
count = s_.execute(stmt).scalar()
if count == 0:
return False
return True
async def create_friend_couple(client_uuid: uuid.UUID, data_from_client: dict):
with db.session_scope() as s_:
rival_couple: TRivalCouple = TRivalCouple(
id=uuid.uuid4(),
dfplayer1=client_uuid,
dfplayer1_nickname=data_from_client['nickName'],
dfplayer1_state=GameState.SEARCHING_FOR_OPPONENT.value,
dfplayer2=data_from_client['enemy_client_id'],
dfcreated_on=datetime.now(),
dfgame_type=GameType.FRIEND.value
)
s_.add(rival_couple)
def find_friend_couple(client_uuid: uuid.UUID, friend_uuid: uuid.UUID) -> TRivalCouple:
with db.session_scope() as s_:
stmt = select(TRivalCouple).join(TGameType, TRivalCouple.dfgame_type.__eq__(TGameType.id),
isouter=True).where(
and_(
TGameType.id.__eq__(GameType.FRIEND.value),
TRivalCouple.dfplayer1.__eq__(friend_uuid),
TRivalCouple.dfplayer2.__eq__(client_uuid)
)
).limit(1) # запись в БД с таким фильтром по-хорошему должна быть только одна
return s_.scalar(stmt)
async def join_friend_couple(rc, nickname: str):
with db.session_scope() as s_:
rc.dfplayer2_nickname = nickname
rc.dfplayer1_state = GameState.SHIPS_POSITIONING.value
rc.dfplayer2_state = GameState.SHIPS_POSITIONING.value
s_.add(rc) # add to s_.dirty for subsequent commit to DB
async def process_friend_game_creation(client_uuid: uuid.UUID, data_from_client: dict, manager: ConnectionManager):
friend_uuid: uuid.UUID = data_from_client['enemy_client_id']
if not friend_couple_exists(client_uuid, friend_uuid):
await create_friend_couple(client_uuid, data_from_client)
else:
rc: TRivalCouple = find_friend_couple(client_uuid, friend_uuid)
await join_friend_couple(rc, data_from_client['nickName'])
await notify_enemy_about_game_creation(rc, manager)
Frontend
Модель данных представлена тремя основными классами:
Ship.ts
import GameStore from "@/store/index";
import Location from "./Location";
import ShipOrientation from "./enums/ShipOrientation";
export default class Ship {
private _length: number;
private _type: ShipOrientation;
private _location: Location;
private _hitsNumber: number;
constructor(_length: number, _type: ShipOrientation, _location: Location) {
this._length = _length;
this._type = _type;
this._location = _location;
this._hitsNumber = 0;
}
public get length(): number {
return this._length;
}
public set length(v: number) {
this._length = v;
}
public get type(): ShipOrientation {
return this._type;
}
public set type(v: ShipOrientation) {
this._type = v;
}
public get location(): Location {
return this._location;
}
public set location(v: Location) {
this._location = v;
}
public get hitsNumber(): number {
return this._hitsNumber;
}
public set hitsNumber(v: number) {
this._hitsNumber = v;
}
/**
* Рисует корабль
*/
public draw(ctx: CanvasRenderingContext2D | null, strokeColor: string = "black") {
let rectangleWidth, rectangleHeight: number;
if (ctx) {
ctx.save();
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
let gcw: number = GameStore.getters.getGridCellWidth;
let gch: number = GameStore.getters.getGridCellHeight;
if (this._type === ShipOrientation.Horizontal) {
rectangleWidth = this._length * gcw;
rectangleHeight = gch;
} else {
rectangleWidth = gcw;
rectangleHeight = this._length * gch;
};
this.drawBulkhead(ctx);
ctx.strokeRect(this._location.x * gcw + 1, this._location.y * gch + 1, rectangleWidth - 2, rectangleHeight - 2);
ctx.restore();
}
}
/**
* Рисует перемычки корабля
*/
private drawBulkhead(ctx: CanvasRenderingContext2D | null) {
if (ctx) {
ctx.beginPath();
for (let i = 1; i < this._length; i++) {
let gcw: number = GameStore.getters.getGridCellWidth;
let gch: number = GameStore.getters.getGridCellHeight;
let x0 = this._location.x * gcw;
let y0 = this._location.y * gch;
if (this._type === ShipOrientation.Horizontal) {
ctx.moveTo(x0 + i * gcw, y0);
ctx.lineTo(x0 + i * gcw, y0 + gch);
}
else /* if (this._type === ShipType.Vertical) */ {
ctx.moveTo(x0, y0 + i * gch);
ctx.lineTo(x0 + gcw, y0 + i * gch);
}
};
ctx.stroke();
}
}
/**
* Меняет тип корабля
*/
public changeOrientation() {
if (this._type === ShipOrientation.Horizontal)
this._type = ShipOrientation.Vertical;
else
this._type = ShipOrientation.Horizontal;
}
/**
* Возвращает множество координат (локаций), принадлежащих данному кораблю
*/
public getLocations(): Location[] {
// Сразу добавляем координату первой (головной) клетки корабля
// т.к. она будет общей как для горизонтального, так и для вертикального корабля
let locations: Location[] = [this._location];
if (this._type === ShipOrientation.Horizontal)
for (let i = 1; i < this._length; i++)
locations.push(new Location(this._location.x + i, this._location.y));
else
for (let i = 1; i < this._length; i++)
locations.push(new Location(this._location.x, this._location.y + i));
return locations;
}
/**
* Возвращает торцевые локации корабля (в случае потопления корабля их необходимо подсветить)
*/
public static async getFrontAndBackLocations(length: number, loc_x: number, loc_y: number, shipType: number) {
let locs: Location[] = [];
if (length === 1) {
// если корабль однопалубный, то возвращаем смежные (недиагональные) локации
for (let i = -1; i <= 1; i++)
for (let j = -1; j <= 1; j++) {
let neighborX = loc_x + i;
let neighborY = loc_y + j;
// проверка, что локации НЕдиагональные и не выходят за рамки грида
if ((i * i + j * j !== 1) || neighborX < 0 || neighborY < 0 || neighborX > 9 || neighborY > 9)
continue;
locs.push(new Location(neighborX, neighborY));
}
}
else {
if (shipType === ShipOrientation.Horizontal) {
let leftLoc = new Location(loc_x - 1, loc_y);
let rightLoc = new Location(loc_x + length, loc_y);
if (leftLoc.x >= 0) locs.push(leftLoc);
if (rightLoc.x <= 9) locs.push(rightLoc);
}
else {
let topLoc = new Location(loc_x, loc_y - 1);
let bottomLoc = new Location(loc_x, loc_y + length);
if (topLoc.y >= 0) locs.push(topLoc);
if (bottomLoc.y <= 9) locs.push(bottomLoc);
}
}
return locs;
}
/**
* Возвращает true и координаты пересечений обоих кораблей, если данный корабль пересекается с кораблем ship, иначе возвращает false
*/
public isIntersect(ship: Ship): [boolean, Location | undefined, Location | undefined] {
for (const loc1 of this.getLocations())
for (const loc2 of ship.getLocations())
if (Math.abs(loc1.x - loc2.x) <= 1 && Math.abs(loc1.y - loc2.y) <= 1)
return [true, loc1, loc2];
return [false, undefined, undefined];
}
}
Корабль определяется на сетке однозначным образом с помощью трех параметров: _location
— локация первой клетки (например, а-2), _type
— тип корабля (горизонтальный/вертикальный) и _length
— длина;
Класс локация:
Location.ts
import { GameStore } from "@/store/modules/GameStore";
import HighlightType from "./enums/HighlightType";
import { letterDict } from "@/helpers/LetterDict";
export default class Location {
private _x: number;
private _y: number;
constructor(_x: number, _y: number) {
this._x = _x;
this._y = _y;
}
public get x(): number {
return this._x;
}
public set x(v: number) {
this._x = v;
}
public get y(): number {
return this._y;
}
public set y(v: number) {
this._y = v;
}
/**
* Возвращает локацию по координате игрового поля
*/
public static getLocationByOffsetXY(offsetX: number, offsetY: number): Location {
const st = GameStore.state;
let gcw: number = GameStore.getters.getGridCellWidth(st);
let gch: number = GameStore.getters.getGridCellHeight(st);
let currentX = Math.floor(offsetX / gcw);
let currentY = Math.floor(offsetY / gch);
return new Location(currentX, currentY);
}
/**
* Подсвечивает на канвасе расположение данной локации
*/
public async highlight(ctx: CanvasRenderingContext2D | null, highlightType: HighlightType = HighlightType.CIRCLE) {
if (ctx) {
const st = GameStore.state;
let gcw: number = GameStore.getters.getGridCellWidth(st);
let gch: number = GameStore.getters.getGridCellHeight(st);
ctx.save();
if (highlightType === HighlightType.SQUARE) {
ctx.fillStyle = "rgb(229 22 35)";
ctx.fillRect(this._x * gcw + 3, this._y * gch + 3, gcw - 6, gch - 6);
}
else if (highlightType === HighlightType.CIRCLE) {
ctx.fillStyle = "rgb(33 22 235)";
const circle = new Path2D();
let sp = GameStore.state.scaleParameter;
circle.arc(this._x * gcw + 0.5 * gcw, this._y * gch + 0.5 * gch, 5 * sp, 0, 2 * Math.PI);
ctx.fill(circle);
} else {
// Рисуем крестик
ctx.strokeStyle = "rgb(229 22 35)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(this._x * gcw + 5, this._y * gch + 5);
ctx.lineTo((this._x + 1) * gcw - 5, (this._y + 1) * gch - 5);
ctx.moveTo((this._x + 1) * gcw - 5, this._y * gch + 5);
ctx.lineTo(this._x * gcw + 5, (this._y + 1) * gch - 5);
ctx.stroke();
}
ctx.restore();
}
}
/**
* Возвращает true, если локация находится за пределами сетки, иначе false
*/
public outsideTheGrid(): boolean {
return this._x < 0 || this._x > 9 || this._y < 0 || this._y > 9
}
/**
* Возвращает true, если локация валидна, иначе false
*/
public isValid(): boolean {
// Бывают случаи, когда игрок делает выстрел по невалидной локации
// (вне границ сетки, например, по координате а-0)
return (this._x >= 0 && this._x <= 9 && this._y >= 0 && this._y <= 9)
}
/**
* Возвращает координату в ее текстовом представлении, например (к-7)
*/
public toString = () => `(${letterDict[this._x]}-${this._y + 1})`;
}
Класс Game:
Game.ts
import Ship from "./Ship";
import ShipOrientation from "./enums/ShipOrientation";
import Location from "./Location";
import { GameStore } from "@/store/modules/GameStore";
export default class Game {
private static ships: Ship[];
private static shotHistory: Location[] = [];
/**
* Возвращает корабли
*/
public static getShips(): Ship[] {
return Game.ships;
}
/**
* createDefaultShips
*/
public static createInitialShips() {
Game.ships = [
new Ship(1, ShipOrientation.Horizontal, new Location(8, 2)),
new Ship(1, ShipOrientation.Horizontal, new Location(0, 9)),
new Ship(1, ShipOrientation.Horizontal, new Location(7, 5)),
new Ship(1, ShipOrientation.Horizontal, new Location(8, 8)),
new Ship(2, ShipOrientation.Horizontal, new Location(0, 0)),
new Ship(2, ShipOrientation.Vertical, new Location(4, 3)),
new Ship(2, ShipOrientation.Vertical, new Location(1, 2)),
new Ship(3, ShipOrientation.Horizontal, new Location(5, 0)),
new Ship(3, ShipOrientation.Horizontal, new Location(4, 9)),
new Ship(4, ShipOrientation.Vertical, new Location(2, 6))
]
}
/**
* Очищает канвас, заново рисует сетку и корабли
*/
public static refreshGridAndShips() {
const st = GameStore.state;
const ctx: CanvasRenderingContext2D = GameStore.getters.getContext2D(st);
const hostileCtx: CanvasRenderingContext2D = GameStore.getters.getHostileContext2D(st);
Game.clearGrid(ctx);
Game.clearGrid(hostileCtx);
Game.makeGrid(ctx);
Game.makeGrid(hostileCtx);
Game.createInitialShips();
Game.ships.forEach(s => { s.draw(ctx); });
}
/**
* Очищает канвас
*/
private static clearGrid(ctx: CanvasRenderingContext2D) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
/**
* Возвращает true, если локация уже существует в истори выстрелов (shotHistory), иначе false
*/
public static existsInShotHistory(location: Location): boolean {
return this.containsLocation(location, Game.shotHistory);
}
/**
* Расставляет случайным образом корабли на сетке (В РАЗРАБОТКЕ)
*/
private static createInitialRandomShips() {
}
/**
* Возвращает true, если корабли расставлены корректно (ни один из них не пересекается со всеми другими),
* иначе возвращает false и массив с координатами пересечений
*/
public static isArrangementCorrect(): [boolean, Location[] | undefined] {
let intersections: Location[] = [];
for (let i = 0; i < Game.ships.length - 1; i++) {
const outerShip = Game.ships[i];
for (let j = i + 1; j < Game.ships.length; j++) {
const innerShip = Game.ships[j];
const res = outerShip.isIntersect(innerShip);
if (res[0])
Array.prototype.push.apply(intersections, [res[1], res[2]]);
};
};
if (intersections.length > 0)
return [false, intersections];
return [true, undefined];
}
/**
* Возвращает корабль по его координатам
*/
public static getShipByHeadLocation(location: Location): Ship | undefined {
return Game.ships.find(s => (s.location.x === location.x && s.location.y === location.y));
}
/**
* Возвращает корабль по данной локации
*/
public static getShipByLocation(location: Location): Ship | undefined {
let notDestroyedShips: Ship[] = Game.ships.filter(s => s.hitsNumber < s.length);
for (const ship of notDestroyedShips)
if (Game.containsLocation(location, ship.getLocations()))
return ship;
return undefined;
}
/**
* Рисует сетку на канвесе
*/
public static makeGrid(ctx: CanvasRenderingContext2D) {
ctx.save();
ctx.beginPath();
ctx.lineWidth = GameStore.state.gridLineThickness;
ctx.setLineDash([3, 3]);
const st = GameStore.state;
let cw: number = GameStore.getters.getCanvasWidth(st);
let ch: number = GameStore.getters.getCanvasHeight(st);
let gcw: number = GameStore.getters.getGridCellWidth(st);
let gch: number = GameStore.getters.getGridCellHeight(st);
for (let x = 0; x <= cw; x += gcw) {
ctx.moveTo(x, 0);
ctx.lineTo(x, ch);
};
for (let y = 0; y <= ch; y += gch) {
ctx.moveTo(0, y);
ctx.lineTo(cw, y);
};
ctx.stroke();
ctx.restore();
}
/**
* Рисует корабли на канвасе
*/
public static drawShips(ctx: CanvasRenderingContext2D): void {
ctx.save();
const st = GameStore.state;
let cw: number = GameStore.getters.getCanvasWidth(st);
let ch: number = GameStore.getters.getCanvasHeight(st);
ctx.clearRect(0, 0, cw, ch);
this.makeGrid(ctx);
Game.ships.forEach(ship => ship.draw(ctx));
ctx.restore();
}
/**
* Возвращает true, если массив locations содержит локацию loc, иначе false
*/
public static containsLocation(loc: Location, locations: Location[]): boolean {
for (const l of locations)
if (l.x === loc.x && l.y === loc.y)
return true;
return false;
}
/**
* Возвращает соседние диагональные локации по отношению к данной
*/
public static getDiagonalLocations(location: Location): Location[] {
let diagonalLocs: Location[] = [];
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
let neighborX = location.x + i;
let neighborY = location.y + j;
if (i === 0 || j === 0 || neighborX < 0 || neighborY < 0 || neighborX > 9 || neighborY > 9)
continue;
diagonalLocs.push(new Location(neighborX, neighborY));
}
}
return diagonalLocs;
}
/**
* Возвращает true, если все корабли потоплены, иначе false
*/
public static allShipsAreSunk(): boolean {
return Game.ships.every(ship => ship.length === ship.hitsNumber);
}
/**
* Подсвечивает диагональные локации и, если нужно, добавляет их в историю выстрелов
*/
public static async highlightDiagonals(
ctx: CanvasRenderingContext2D,
diagonals: Location[]
) {
for (const loc of diagonals)
await loc.highlight(ctx);
}
/**
* Добавляет локацию в историю выстрелов
*/
public static addToShotHistory(shot: Location) {
if (!Game.containsLocation(shot, Game.shotHistory))
Game.shotHistory.push(shot);
}
/**
* Очищает историю выстрелов
*/
public static async clearShotHistory() {
Game.shotHistory = [];
}
}
Для работы с WS на стороне фронта также был сделан соответствующий класс:
WebSocketManager.ts
import { GameCreationBodyType } from "@/model/WSDataTransferRoot";
import GameProcessManager from "./GameProcessManager";
export default class WebSocketManager {
private static ws: WebSocket;
private static readonly WS_SERVER_HOST: string = process.env.VUE_APP_WS_SERVER_HOST;
private static readonly WS_SERVER_PORT: number = 5000;
public static createWebSocket(clientUUID: string): WebSocket {
let host = WebSocketManager.WS_SERVER_HOST;
let port = WebSocketManager.WS_SERVER_PORT;
let socket: WebSocket = new WebSocket(`ws://${host}:${port}/client/${clientUUID}/ws`);
return socket;
}
public static setupWSAndCreateGameOnOpen(ws: WebSocket, gameCreationBody: GameCreationBodyType) {
ws.onopen = function (event) {
console.log("Opening a connection...");
WebSocketManager.ws = ws;
ws.send(JSON.stringify(gameCreationBody));
console.log("Successfully connected to the websocket server");
};
ws.onmessage = async function (event: MessageEvent<string>) {
await GameProcessManager.processData(event.data);
};
ws.onerror = function (event: Event) {
console.log("Connection error");
};
ws.onclose = function (event: CloseEvent) {
if (event.wasClean) {
console.log("Connection closed correctly");
} else {
console.error("The connection was broken");
}
};
}
public static getWebSocket(): WebSocket {
return WebSocketManager.ws;
}
}
Основная логика по общению с сервером реализована в следующем классе:
GameProcessManager.ts
import GameState from "@/model/enums/GameState";
import GameType from "@/model/enums/GameType";
import MessageType from "@/model/enums/MessageType";
import Game from "@/model/Game";
import Location from "@/model/Location";
import { FireResponseType, GameCreationBodyType, ShipType, TransferLevel2Type, UnSunkShipsType, WSDataTransferRootType } from "@/model/WSDataTransferRoot";
import GameStore from "@/store/index";
import WebSocketManager from "./WebSocketManager";
import HighlightType from "@/model/enums/HighlightType";
import ShotResult from "@/model/enums/ShotResult";
import Ship from "@/model/Ship";
import UIHandler from "@/helpers/UIHandler";
export default class GameProcessManager {
private static enemyClientUuid: string;
private static gameId: string;
public static getEnemyUUID() {
return GameProcessManager.enemyClientUuid;
}
public static getGameId() {
return GameProcessManager.gameId;
}
public static async processData(dataFromServer: string) {
// console.log('Data from server was received: ', dataFromServer);
let parsedData: WSDataTransferRootType = JSON.parse(dataFromServer);
switch (parsedData.msg_type) {
case MessageType.GAME_CREATION:
console.log('MessageType.GAME_CREATION data:', parsedData);
if (parsedData.is_status_ok)
GameProcessManager.processGameCreation(parsedData.data);
break;
case MessageType.SHIPS_ARE_ARRANGED:
console.log('MessageType.SHIPS_ARE_ARRANGED data:', parsedData);
if (parsedData.is_status_ok)
GameProcessManager.processShipArrangement(parsedData.data);
break;
case MessageType.PLAY:
console.log('MessageType.PLAY data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processStartingToPlay(parsedData.data);
break;
case MessageType.FIRE_REQUEST:
console.log('MessageType.FIRE_REQUEST data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processFireRequest(parsedData.data);
break;
case MessageType.FIRE_RESPONSE:
console.log('MessageType.FIRE_RESPONSE data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processFireResponse(parsedData.data);
break;
case MessageType.GAME_OVER:
console.log('MessageType.GAME_OVER data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processGameOver(parsedData.data);
break;
case MessageType.UNSUNK_SHIPS:
console.log('MessageType.UNSUNK_SHIPS data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processUnsunkShips(parsedData.data);
break;
case MessageType.DISCONNECTION:
console.log('MessageType.DISCONNECTION data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processDisconnection(parsedData.data);
break;
case MessageType.PLAY_AGAIN:
console.log('MessageType.PLAY_AGAIN data:', parsedData);
if (parsedData.is_status_ok)
await GameProcessManager.processPlayAgain(parsedData.data);
break;
default:
break;
}
}
public static getGameCreationBody(gameType: GameType, nickName: string, enemy_client_id: string = ""): GameCreationBodyType {
const gameCreationBody: GameCreationBodyType = {
msg_type: MessageType.GAME_CREATION,
game_type: gameType,
nickName: nickName,
};
if (gameType === GameType.FRIEND && enemy_client_id)
gameCreationBody.enemy_client_id = enemy_client_id;
return gameCreationBody;
}
public static processGameCreation(data: TransferLevel2Type) {
GameProcessManager.gameId = data.gameId;
GameStore.commit("setMyState", GameState.SHIPS_POSITIONING);
GameStore.commit("setEnemyState", GameState.SHIPS_POSITIONING);
GameStore.commit("setEnemyNickname", data.enemy_nickname);
}
private static processShipArrangement(data: TransferLevel2Type) {
GameStore.commit("setEnemyState", GameState.SHIPS_ARE_ARRANGED);
GameProcessManager.enemyClientUuid = data.enemy_client_id;
}
private static async processStartingToPlay(data: TransferLevel2Type) {
GameStore.commit("setEnemyState", GameState.PLAYING);
GameStore.commit("setMyState", GameState.PLAYING);
GameStore.commit("setMyTurnToShoot", data.turn_to_shoot);
if (data.turn_to_shoot) await GameStore.dispatch("enableShooting");
}
private static async processFireRequest(data: TransferLevel2Type) {
let shot: Location = new Location(
data.shot_location._x,
data.shot_location._y
);
let ht: HighlightType = HighlightType.CIRCLE;
let ctx = GameStore.getters.getContext2D;
let fireResponse: FireResponseType = {
msg_type: MessageType.FIRE_RESPONSE,
shot_result: ShotResult.MISS,
enemy_client_id: GameProcessManager.enemyClientUuid,
shot_location: data.shot_location,
};
let ship: Ship | undefined = Game.getShipByLocation(shot);
// если наш корабль ранили
if (ship) {
ht = HighlightType.CROSS; // меняем тип выделения на "крест"
ship.hitsNumber++; // увеличиваем счетчик ранений у подбитого корабля
// находим диагональные локации
let diags: Location[] = Game.getDiagonalLocations(shot);
// подсвечиваем диагональные локации
await Game.highlightDiagonals(ctx, diags);
// если корабль только ранен
if (ship.hitsNumber < ship.length) {
fireResponse.shot_result = ShotResult.HIT;
} else {
// если корабль потоплен
fireResponse.shot_result = ShotResult.SUNK;
// формируем подбитый корабль
let ss: ShipType = {
length: ship.length,
loc: { _x: ship.location.x, _y: ship.location.y },
type: ship.type,
};
fireResponse.sunkShip = ss;
// находим боковые локации
let edgeLocs = await Ship.getFrontAndBackLocations(
ss.length,
ss.loc._x,
ss.loc._y,
ss.type
);
// выделяем их на своем гриде
for (const loc of edgeLocs) await loc.highlight(ctx);
// Если все корабли потоплены, даем знать об этом противнику. Игра окончена!
if (Game.allShipsAreSunk()) {
GameStore.commit("setMyState", GameState.GAME_IS_OVER);
GameStore.commit("setEnemyState", GameState.GAME_IS_OVER);
// Отправляем сопернику информацию о завершение игры с типом сообщения GAME_OVER
const ws: WebSocket = WebSocketManager.getWebSocket();
ws.send(
JSON.stringify({
msg_type: MessageType.GAME_OVER,
enemy_client_id: GameProcessManager.enemyClientUuid,
})
);
}
}
await GameStore.dispatch("disableShooting");
} else await GameStore.dispatch("enableShooting");
await shot.highlight(ctx, ht);
// Отправляем сопернику информацию о попадании (мимо/ранил/потоплен)
// с типом сообщения FIRE_RESPONSE
const ws: WebSocket = WebSocketManager.getWebSocket();
ws.send(JSON.stringify(fireResponse));
GameStore.commit("setEnemyShotHint", shot.toString());
}
private static async processFireResponse(data: TransferLevel2Type) {
let hostileCtx = GameStore.getters.getHostileContext2D;
let shot: Location = new Location(data.shot_location._x, data.shot_location._y);
Game.addToShotHistory(shot);
if (data.shot_result === ShotResult.MISS) {
// Если промахнулись
await shot.highlight(hostileCtx);
GameStore.commit("setEnemyShotHint", "");
await GameStore.dispatch("disableShooting");
} else {
// если попали (ранили), то
// подсвечиваем локацию на вражеском гриде
await shot.highlight(hostileCtx, HighlightType.CROSS);
// находим диагональные локации
let diags: Location[] = Game.getDiagonalLocations(shot);
// подсвечиваем диагональные локации
await Game.highlightDiagonals(hostileCtx, diags);
// добавляем диагональные локации в историю выстрелов
for (const loc of diags) Game.addToShotHistory(loc);
// если от соперника пришла информация, что корабль потоплен
if (data.shot_result === ShotResult.SUNK) {
let sunkShip = data.sunkShip;
if (sunkShip) {
// находим боковые локации
let edgeLocs = await Ship.getFrontAndBackLocations(
sunkShip.length,
sunkShip.loc._x,
sunkShip.loc._y,
sunkShip.type
);
// выделяем их на гриде соперника
for (const loc of edgeLocs) await loc.highlight(hostileCtx);
// и добавляем в историю выстрелов
for (const loc of edgeLocs) Game.addToShotHistory(loc);
}
}
}
}
private static async processGameOver(data: TransferLevel2Type) {
GameStore.commit("setMyState", GameState.GAME_IS_OVER);
GameStore.commit("setEnemyState", GameState.GAME_IS_OVER);
GameStore.commit("setIsWinner", true);
await GameStore.dispatch("disableShooting");
// Отправим сопернику информацию о непотопленных кораблях
await this.sendUnsunkShipsToEnemy();
}
private static async processUnsunkShips(data: TransferLevel2Type) {
let unsunkShips: Ship[] = data.unSunkShips.map(
(s) =>
new Ship(s.length, s.type, new Location(s.loc._x, s.loc._y))
);
// Покажем где у соперника остались непотопленные корабли
let hostileCtx = GameStore.getters.getHostileContext2D;
unsunkShips.forEach((ship) => ship.draw(hostileCtx, "red"));
}
private static async processDisconnection(data: TransferLevel2Type) {
UIHandler.showAlert(
"К сожалению, ваш соперник разорвал соединение и вышел из игры. Обновите страницу для новой игры",
"danger",
10000
);
GameStore.commit("setMyState", GameState.NOT_CREATED);
GameStore.commit("setEnemyState", GameState.NOT_CREATED);
}
private static async processPlayAgain(data: TransferLevel2Type) {
UIHandler.showAlert(
"Соперник хочет сыграть с Вами снова",
"success",
15000
);
GameStore.commit("setEnemyState", GameState.SHIPS_POSITIONING);
}
public static handleHostileGridClick(event: MouseEvent) {
let shotLocation: Location = Location.getLocationByOffsetXY(
event.offsetX,
event.offsetY
);
if (!shotLocation.isValid()) return;
if (Game.existsInShotHistory(shotLocation)) {
UIHandler.showAlert("Вы уже стреляли сюда", "warning");
return;
}
const ws: WebSocket = WebSocketManager.getWebSocket();
ws.send(
JSON.stringify({
msg_type: MessageType.FIRE_REQUEST,
shot_location: shotLocation,
enemy_client_id: GameProcessManager.enemyClientUuid,
})
);
}
private static async sendUnsunkShipsToEnemy() {
const ws: WebSocket = WebSocketManager.getWebSocket();
let unsunkShipsResp: UnSunkShipsType = {
msg_type: MessageType.UNSUNK_SHIPS,
enemy_client_id: GameProcessManager.enemyClientUuid,
unSunkShips: [],
};
let unSunkShips: Ship[] = Game.getShips().filter(
(s) => s.hitsNumber < s.length
);
for (const ship of unSunkShips) {
unsunkShipsResp.unSunkShips.push({
loc: { _x: ship.location.x, _y: ship.location.y },
length: ship.length,
type: ship.type,
});
}
ws.send(JSON.stringify(unsunkShipsResp));
}
}
В течение игры фронт посылает на бэк тело сообщения с типом FIRE_REQUEST, выполняя тем самым выстрел по определенным координатам, бэк пересылает его сопернику. Соперник отрабатывает его и отправляет оппоненту ответ с типом FIRE_RESPONSE, в котором содержится результат выстрела (мимо/ранил/убил).
Заключение
Спасибо за прочтение!
Готов ответить на Ваши вопросы. Всем добра!
Ссылки на репы в гит:
Lavich
Хотелось бы посмотреть проект в виде гит-репозитария, в котором я если бы делал ревью, то мог бы отметить следующее:
Никогда нельзя доверять пользователю (даже в учебном или пет-проекте), и следовало бы перенести логику игры на бекенд.
Но даже если оставить как есть, то я бы предложил отделить логику игры (реализуемые правила и механики) от представления - т.е. это был бы класс/библиотека, который реализовывал бы все механики игры, и отдельно были бы компоненты, которые рисуют поле/корабли/статистику/etc
Vuex уже морально устарел и вместо него даже его разработчики рекомендуют использовать Pinia, который проще и поддерживает тайпскрипт. Но в целом стор в этом приложении и не нужен.
Не очевидно почему в статье упоминается Vue, и для чего он вообще нужен тут?
deepblack
у автора в профиле есть ссылка на профиль GitHub, там можно найти вот эти репозитории:
https://github.com/greenDev7/battleship
https://github.com/greenDev7/battleship-back
Green21 Автор
Доброго дня!
Возможно для опытного пользователя и нельзя доверять, но для среднестатистического я не думаю что это будет критично. Хотя все равно не понятно чем логика на фронте опасна.
В принципе, я и вынес логику в отдельный класс (GameProcessManager.ts)
Просто уже работал с Vuex, поэтому решил его заюзать,
Этот пункт мне совсем не понятен - что значит "для чего он [Vue] вообще тут нужен" ? )) Я выложил исходники (в конце статьи) - можете посмотреть.
Lavich
Проблема в том, что положение кораблей противника передается в клиент. Добавляй игру на деньги и сыграем с тобой - покажу в чем проблема :)
Вот, для примера, библиотека battleships-engine - она не знает ни о вебсокетах, ни о Vuex, ни о канвасе, но полностью реализует правила игры и только их.
Посмотрел репу - думаю, остальные пункты мне нет смысла обсуждать)
Green21 Автор
Нет, я не вижу чтобы положение кораблей передавалось противнику. Либо я не совсем понял ваш аргумент. На деньги можете с кем-нибудь другим поиграть.
Хорошо. Буду знать
Хорошего дня Вам)