« Какие сложности при создании первой настольной игры на JavaScript (интерфейс, мультиплеер, бот)
May 13, 2019 • ☕️☕️ 11 min read
Какие сложности при создании первой настольной игры на JavaScript (интерфейс, мультиплеер, бот)
Статья может быть интересна разработчикам, у которых есть опыт с JavaScript и React, но нет опыта в разработке игр.
Идея написать игру у меня уже давно возникла, но вот какую — идеи не было. И вот однажды мой друг скинул мне пост на Пикабу с правилами игры. Что-то меня в ней зацепило. Наверное, простота и желание похвастаться своей игрой. И вот хвастаюсь — решил я запилить свою игру.
Вкратце (а может и не вкратце), суть игры следующая. Есть большая доска (15*40 ячеек) с кучей пустых ячеек, 2 игрока и 2 кубика. Игрок бросает 2 кубика. Значения, которые выпали на кубиках (например, 3 и 4) являются длиной и шириной прямоугольника игрока. Игрок должен поставить этот прямоугольник на доску так, чтобы хотя бы одна его сторона соприкасалась с его ранее поставленными прямоугольниками. Если некуда поставить — пропускает ход. И так по очереди. Самые первые прямоугольники игроков ставятся в противоположные углы доски. Выигрывает тот, кто занял больше половины ячеек доски своими прямоугольниками.
Благодаря случайной фразе супруги о том, что было бы круто еще и иметь онлайн-версию, я нашел одну прекрасную библиотеку— boardgame.io, чуть-чуть связанной с Google, которая помогает неопытным разработчикам игр создавать простые пошаговые игры. Так что отчасти данная статья — это описание этой библиотеки.
Библиотека описывает набор правил, общих для всех пошаговых игр — ходы, события, случайность и т.д., а также умело хранит состояние, под капотом используя Redux. Она также помогает реализовать серверную часть для игр онлайн. И еще есть раздел создания ботов. Жаль, что многое в ней еще не реализовано или не дореализовано.
Немного поигравшись с библиотекой, и реализовав крестики-нолики с ботом по руководству и онлайн крестики-нолики по другому руководству, я начал пробовать реализовать свою игру. Дизайн приложения был только в голове и немного на листике, так что моя первая относительно рабочая попытка выглядела как-то так:
Самое главное на этом этапе, что нужно было сделать — это описать логику, нарисовать интерфейс и связать их. В итоге оказалось, что для логики игры самое важное — это объект Game, который был описан примерно следующим образом:
import { Game } from "boardgame.io/core";const Territories = Game({name: "territories",// Инициализация первичного состояния игры// (доска с пустыми ячейками, кубики без значений)setup: () => ({board: [...Array(15).fill([...Array(40).fill('EMPTY')])],dices: null,...}),// Возможные действия с состоянием игроки (как actions в redux)moves: {// Бросок костей (2 кубика D6)rollDices: (G, ctx) => ({...G,dices: ctx.random.D6(2)}),// Поставить прямоугольник на доскуdropRectangle: (G,ctx,rowIndex,columnIndex,rectangleHeight,rectangleWidth) => {// Сложная функция пересчета состояния ячеек доски// После того, как игрок поставил свой прямоугольник на доскуreturn { ...G, board: G.board.map(row => ...) }},},flow: {// Заканчивать игру, когда один из игроков занял половину доскиendGameIf: (G, ctx) => {...},}});
Объект Game состоит из начального состояния игры, из действий, изменяющих это состояние, и из проверки завершения игры (вызывается каждый раз при любом действии). Состояние приложения состоит из двух главных объектов — G и ctx. G — это состояние игры, описанное разработчиком, относящееся к особенностям игры (например, доска и кубики в моей игре). ctx — это общие для всех пошаговых игр сущности (например, текущий игрок ctx.currentPlayer или счетчик хода ctx.turn).
Главный класс интерфейса выглядел следующим образом:
class UI extends React.Component {handleRollDices = () => {this.props.moves.rollDices();}handleDropRectangle = ({rowIndex,columnIndex,rectangleHeight,rectangleWidth}) => {this.props.moves.dropRectangle(rowIndex,columnIndex,rectangleHeight,rectangleWidth);this.handleEndTurn();}handleEndTurn = () => {this.props.events.endTurn();}render() {const {G: { board, dices },ctx: { currentPlayer, gameover }} = this.props;return (<React.Fragment><PlayersControlsdices={dices}currentPlayer={currentPlayer}onRollDices={this.handleRollDices}onSkipTurn={this.handleEndTurn}/><Boardrows={board}onDropRectangle={this.handleDropRectangle}/><Congratulationsgameover={gameover}/></React.Fragment>);}}
Из интерфейса можно вызывать действия this.props.moves, которые описаны в объекте Game и изменяют состояние G. Можно также вызывать события this.props.events — это те же действия, только изменяющие состояние ctx (одинаковая для всех игр модель состояния).
Чтобы связать интерфейс UI и состояние Game, нужно написать что-то похожее:
import { Client } from "boardgame.io/react";import Game from "./Game";import UI from "./UI"const Territories = Client({game: Game,board: UI,debug: false});export default Territories;
Но, хватит про библиотеку (пусть, она еще не раз будет встречаться). В целом, реализацию игры можно разбить на 3 последовательных этапа:
- Реализация логики игры и пользовательского интерфейса (игра один на один оффлайн)
- Реализация мультиплеера (игра один на один онлайн)
- Реализация бота (игра против искусственного интеллекта)
Далее будут расписаны основные сложности, которые встречались на моем пути.
Сложности при реализации пользовательского интерфейса
Drag-and-Drop vs Mouse-Enter
При первом дизайне казалось, что воспользоваться drag-and-drop для перемещения прямоугольника на доску — это отличная идея. Поэтому я решил попробовать популярную библиотеку react-dnd. Оказалось, что подход, используемый в библиотеке, не так уж и прост в случае, когда цель дропа — это изменяющийся набор ячеек. Так что я потратил неделю на его реализацию (с параллельными попытками использования других drag-and-drop библиотек). Однако после реализации перетаскивание прямоугольника визуально тормозило приложение. Я решил попробовать реализовать тот же перенос прямоугольника через простой mouse-enter + click, и сделал это за полдня без проблем с производительностью. Выглядело даже очевиднее (ну, кроме мобилки, там touch-move помог). На нем и остановился.
Вывод. Для перетаскивания компонентов в другие компоненты есть 2 подхода — drag-and-drop и mouse-enter + click. Если он простой и захотите воспользоваться react-dnd — все получится. Если сложный — стоит рассмотреть mouse-enter подход, как более простой в реализации.
Игральные кости
Какая настольная игра без игральных костей?! (Ticket to ride, Saboteur, да куча). В этой игре они нужны. И, мне очень понравился дизайн игры http://monopoly-club.org, так что я начал искать красивые кости, как у них. Оказывается, не так уж и просто найти красивые, готовые и бесплатные 3d-модели для веб! Сначала я просто воспользовался этим проектом, так как там было достаточно функциональности для логики. Но, красота требует жертв. Так что, через недели полторы скитаний по просторам интернета и неудачных попыток готовых решений я нашел прекрасную реализацию красивых 3d кубиков— http://www.teall.info/2014/01/online-3d-dice-roller.html. После этого немного изучений three.js, немного настроек — и кубики 3 и 6 отлично катятся в 3d на WebGL со следующим кодом:
<Dices3ddices={[{type: "d6",backColor: "red",fontColor: "white",value: 3}, {type: "d6",backColor: "blue",fontColor: "white",value: 6}]}/>
Вывод. Довольно непросто найти готовые, красивые и бесплатные 3d модели для специфичных компонентов. Так что или учите three.js (или аналоги, как babylon.js), или рассчитывайте на небыстрое и негарантированное нахождение готового.
Сложности при реализации логики игры
Производительность при подсчете победы
Игра должна заканчиваться, когда один из игроков занял больше, чем половину доски. Фреймворк boardgame.io в конце каждого хода вызывает метод endGameIf. И, если метод что-то возвращает, то игра заканчивается. Первой реализацией этого метода было прохождение по всей доске, которое считает количество занятых игроком ячеек и сравнивает его с половиной числа всех ячеек. В итоге, визуально казалось, что в конце хода игра немного тормозит. Поэтому было решено добавить в объект состояния G счетчики для каждого игрока G.occupiedCells.PLAYER_1 = 0
. Эти счетчики увеличивались, как только игрок ставил прямоугольник на доску. Это сделало функцию победы быстрее, так как оставалось только сравнивать счетчики с половиной числа всех ячеек. Однако, когда были какие-то нововведения в игру (например, автоматический захват территорий, уже не доступных для другого игрока), нужно было не забывать обновлять эти счетчики.
Вывод. Если производительность функции победы плохо влияет на приложение, можно считать важные для победы параметры в ходе игры и сохранять их в состоянии, как кэш. Но, после внедрения их нужно не забывать про их существование и инвалидацию, особенно если появляется новая функциональность.
Алгоритм нахождения всех ячеек для автозаполнения
Когда игра была в какой-то мере реализована и были сыграны несколько тестовых игр, было замечено, что в процессе игры появляются ячейки, которые могут быть захвачены только одним игроком. Например, ячейки, которые с трех сторон окружены ячейками первого игрока, а с четвертой — границей доски. В итоге первый игрок очень долго выкидывал кости, чтобы рано или поздно занять эти ячейки. Игра сильно растягивалась и пропадал интерес в ее играть.
Поэтому было решено отдавать ячейки игроку, если только он может ими завладеть. Такие ячейки могли появляться только когда игрок ставит прямоугольник. Однако как в двумерном массиве (доска — это двумерный массив) найти множество ячеек, которые замкнуты в контуре первого игрока или границы? Решение пришло не сразу. В основном я пытался искать статьи, которые описывают решения задач поиска замкнутого контура. Например, вот эта или вот эта.
В итоге, после тщетных попыток быстро понять, в чем смысл сложных алгоритмов, я пришел к рекурсивной реализации заливки в Paint (flood-fill). Алгоритм просто рекурсивно проходит по всем соседям ячейки и закрашивает до тех пор, пока не дойдет до граничной. Как всегда, есть даже готовые библиотеки в npm. Довольно быстро я реализовал алгоритм, который рекурсивно проходит по всем свободным ячейкам в стиле заливки (на самом деле, в стиле поиска в глубину), собирая ячейки в контур и информацию о том, кто соседи контура. Если соседи — первый игрок и граница, то эти ячейки можно отдавать первому игроку. Если второй игрок и граница — то второму. Если первый и второй игрок — то никому. Алгоритм считается очень простым и далеко не оптимизированным, но отлично справился с задачей, не имея визуальных проблем с производительностью.
Вывод. Не стоит бояться использовать простые алгоритмы, если их можно быстро реализовать. Возможно этого будет достаточно для решения задачи. И сэкономит кучу времени.
Сложности при реализации мультиплеера
Развертывание
Фреймворк boardgame.io предоставляет довольно широкие возможности мультиплеера. Например, сервер, который хранит информацию про игры, работает с пользователями и предоставляет API для комнат и для игры онлайн, а также клиент, который общается с сервером при помощи http или websocket. В ней все еще не хватает множества фич (хорошей аутентификации, удаления комнат и т.д.), но для простых игр достаточно. Например, так выглядит код для запуска сервера игры:
import { Server } from "boardgame.io/server";import Game from "../Game";const port = process.env.PORT || 8000;const server = Server({ games: [Game] });server.run(port, () => {console.log("App is running on port " + port);});
Для развертывания были выбраны heroku для сервера, github-pages для клиента и travis в качестве Continuous integration и Continuous deployment. Причина? Все бесплатно, не требует особой поддержки и легко интегрируется с github.
...deploy:provider: herokuapp: territories-backendskip_cleanup: trueapi_key:secure: ...
Изначально все проекты (клиент и сервер) находились в одном репозитории. Однако, из-за того, что travis heroku provider не имел поддержки работы с подпапками, были выделены отдельные github репозитории для клиента и сервера. Что привело к еще нескольким концептуально правильным сложностям. Например, чтобы не дублировать код, я создал еще один репозиторий territories-core, в котором находились общие для клиента и сервера функции — много функций логики игры и объект Game.
territories| - core| - frontend| - backend
Репозиторий territories-core подключался, как модуль из github, на сервер territories-backend и на клиент territories-frontend. Однако я бы все-таки рекомендовал публиковать модуль через npm publish
в npmjs.com, чем использовать npm install github.com/user/repo
, так как появляются сложности со сборкой во время установки, с форматом версий и др.
{"name": "territories-backend",..."dependencies": {..."territories-core": "lehaSVV2009/territories-core#abdf08d"}}
Подход с выделением отдельного репозитория для общих функций оказался красивым, но не всегда удобным для разработки, поэтому первое время я просто копировал код логики на клиент и сервер.
И тут настало время думать, что все пойдет хорошо. А нет! Оказалось, что сервер для boardgame.io запускал HTTP API на порту 8080
, а Websocket API на порту 8081
, поэтому, когда сервер был успешно закинут на heroku, он падал с ошибкой, что только один API с одним портом может быть запущен. Изначально я ставил на то, что это проблема heroku, и что стоит использовать что-то более функциональное и недорогое (AWS EC2, Google Cloud Platform, Openshift, Digital Ocean, Azure, и т.д.). Однако, со временем я заметил, что многим людям не нравилось 2 порта, так как было открыто около пяти issues, поэтому за несколько дней изучения проекта и реализации фичи “один порт для http и websocket”, это было исправлено и новая версия сервера нормально работала на heroku.
Вывод. У фреймворка boardgame.io есть неплохая поддержка мультиплеера в виде клиента и сервера, общающихся по http и websocket. Для простоты развертывания, например, на heroku или github-pages, можно создать отдельные репозитории для клиента и сервера. Тогда будет проще настраивать CI и CD. Чтобы не дублировать код, который одинаковый для клиента и сервера, стоит вынести общую логику в отдельную модуль/репозиторий. Для небольших нагрузок все можно сделать бесплатно.
Комнаты
Обычно, наличие мультиплеера предполагает возможность каким-нибудь образом подключиться к игре. Есть разные способы. Например, в agar.io все, что нужно, это ввести имя и нажать на кнопку “играть”, чтобы игра автоматически тебя подключила к какой-либо из комнат с множеством других игроков. В игре haxball.com игрок после ввода имени переходит на страницу с множеством комнат, в которые можно присоединиться, откуда можно выйти и где можно создавать свои комнаты. Так что, сначала стоит выбрать, какой способ больше подходит для игры.
И тут тоже на помощь может прийти boardgame.io. В документации об этом ни слова я не нашел, однако, если скачать их репозиторий и локально запустить npm install && npm start
, то по http://localhost:3000 можно увидеть несколько интересных примеров, в том числе пример комнат (Lobby). Он тоже еще в процессе разработки, но многое уже готово. Тем более, что с недавних пор весь интерфейс можно полностью переопределять при помощи поля renderer
.
import { Lobby } from "boardgame.io/react";import Game from "../Game";import UI from "../UI";<LobbygameServer="http://localhost:8080"lobbyServer="http://localhost:8080"gameComponents={[{game: Game,board: UI,loading: () => <div>Custom Loading...</div>,}]}renderer={({errorMsg,gameComponents,gameInstances,phase,playerName,runningGame,handleEnterLobby,handleExitLobby,handleCreateRoom,handleJoinRoom,handleLeaveRoom,handleExitRoom,handleRefreshRooms,handleStartGame}) => {if (phase === "enter") {return <button onClick={handleEnterLobby}>Login</button>;}if (phase === "list") {return (<div>{gameInstances.map(game => (<div key={game.gameID}>{game.players.map(player => player.name)}</div>)}<button onClick={handleCreateRoom} /></div>);}if (phase === "play") {return (<runningGame.appgameID={runningGame.gameID}playerID={runningGame.playerID}credentials={runningGame.credentials}/>);}return "Unknown phase";}}/>
В данный момент Lobby при входе сохраняет имя пользователя в cookies и использует boardgame.io API для управления комнатами. При входе в комнату он рендерит компонент Client
, который, в свою очередь, подключается по WebSocket к серверу, отправляя ему название игры, идентификатор игры и индекс игрока. Как уже говорилось ранее, многое еще не реализовано или реализовано необычно (выход из игры, удаление комнаты, аутентификация, имена игроков), но достаточно для простых собственных проектов.
А вот еще несколько сложностей, связанных с реализацией мультиплеера после простой игры один на один:
- Состояние конца игры не приходило с сервера для победителя, поэтому пришлось реализовать его самому. Все еще есть баги..
- Бросок кубика не отображался у других игроков, так как с сервера приходило лишь новое состояние кубиков и все. Поэтому пришлось разбивать бросание кубиков на “начать бросок” и “закончить бросок”, чтобы при начале броска всем другим игрокам приходили данные о том, что бросок начался и чтобы у них отображались крутящиеся 3d кубики.
- Блокировка кнопок для неактивного игрока. Если сейчас ход второго игрока, то первый игрок не может ничего делать, кроме как наблюдать. Так что пришлось блокировать кнопки для пользователей, когда был не их ход.
- Большое отличие интерфейса онлайн игры от интерфейса простой игры. Например, наличие кнопок “выйти из игры”, “выйти из комнаты”, обновление имен с сервера, блокировка кнопок для неактивного игрока и т.д. В компоненте оказалось слишком много
if (multiplayer)
кода. Я думаю, что стоило бы рассмотреть вариант создания отдельного layout компонента только для мультиплеера.
Вывод. И снова у фреймворка boardgame.io есть что-то неплохое, но недоделанное для онлайн комнат, а именно API и Lobby. Для реализации простых игр и изучения логики комнат вполне достаточен, но для сложных проектов лучше создавать что-то свое или использовать готовое другое. Также скорее всего при реализации мультиплеера придется немного переделать состояние приложения и его интерфейс.
Сложности при реализации бота
Бот на основе нейронной сети
В документации фреймворка boardgame.io есть запись про добавление AI. Фреймворк реализует MCTS алгоритм и предоставляет функцию для реализации, которая, на сколько я заметил, будет вызываться 1000 раз перед каждым ходом бота, чтобы понять, какой ход лучше сделать.
// https://boardgame.io/#/tutorial?id=adding-ai{enumerate: (G, ctx) => {const moves = [];for (let i = 0; i < 9; i++) {if (G.cells[i] === null) {moves.push({ move: 'clickCell', args: [i] });}}return moves;}}
Однако, во-первых, поддержка искусственного интеллекта на данный момент еще в процессе разработки, во-вторых, пока что фреймворк позволяет работать только с одним типом ходов (например, поставить крестик в случайную ячейку в крестики-нолики). В моей игре несколько типов ходов (бросить кубики, повернуть прямоугольник, поставить прямоугольник), поэтому, после нескольких неудачных попыток использовать готовый функционал, я начал изучать то, как можно реализовать своего бота.
И тут столько всего можно изучать! Нейронные сети, генетические алгоритмы, сложные собственные алгоритмы и т.д. Я остановился на нейронных сетях, так как всегда хотел попробовать их на реальном примере.
Что такое нейронная сеть? На самом деле, это просто функция, которая принимает несколько чисел и, в зависимости от типа сети, весов и слоев, возвращает какие-то значения. Например, примерно так может выглядеть самая простая линейная однослойная нейронная сеть с весами 0.12 и 0.45:
(arg1, arg2) => arg1 * 0.12 + arg2 * 0.45
Есть очень много разных видов и категорий нейронных сетей. Наверное, самое популярная библиотека для работы с ними — это TensorFlow (она написана на Python, как и большая часть библиотек для искусственного интеллекта, но есть Tensorflow.js). Немного поигравшись с ней, я понял, что мне это было рано. Поэтому, в очередной из десятков статей про лучшие библиотеки для нейронных сетей я нашел brain.js. Библиотека предоставляет множество простых методов для создания нейронных сетей, а также трансформацию нейронной сети в простую функцию на JS.
Например, моя нейронная сеть создавалась примерно следующим образом:
import brain from "brain.js"import myTrainingData from "./trainingData"// Создание нейронной сетиconst net = new brain.NeuralNetwork()// Тренировка весов для нейронной сетиnet.train(myTrainingData)// Запуск нейронной сети для расчета вероятности правильности хода// Вернет, например, число 0.87const probability = net.run(...).chosenByWife;
Есть несколько способов тренировки нейронной сети. Я использовал скучный и рабочий способ — тренировку с учителем (моей супругой), т.е. каждый раз, когда я проигрывал своей жене, все ее возможные ходы и те из них, которые она выбирала, записывались в формате JSON для последующей тренировки сети:
[{"input": {"turn": 0.0001,"columnIndex": 0.875,"rowIndex": 0.7333333333333333,},"output": { "chosenByWife": true }},{"input": {"turn": 0.0001,"columnIndex": 0.9,"rowIndex": 0.6666666666666666,},"output": { "chosenByWife": false }},...];
Сложность была с тем, чтобы угадать хорошие признаки для нейронной сети (номер хода, номер ячейки, размеры прямоугольника, количество свободных ячеек) и собрать тренировочный набор данных (5 игр и 2000 объектов input/output хватило). Также нужно было трансформировать все свои признаки в числа от 0 до 1, так как это требовал выбранный по умолчанию вид нейронной сети.
В итоге, каждый раз, когда бот выкидывал кубики, он находил все возможные для хода ячейки и размеры прямоугольника, и для каждого возможного хода вызывал функцию нейронной сети, чтобы узнать, какова вероятность того, что моя жена походила бы также. Ход с наибольшей вероятностью выбирался ботом и игра продолжалась.
Сейчас раз в 4 игры бот меня выигрывает.
Вывод. Встроенная функциональность boardgame.io AI еще сыра (май 2019). Однако, можно воспользоваться множеством различных подходов и библиотек для реализации своего собственного бота. Например, прекрасную для начинающих (по моему мнению) библиотеку brain.js с простой нейронной сетью, обученной учителем.
Заключение
На реализацию игры было потрачено около 2 месяцев безудержного веселья, примерно по 2–3 часа в день. Из этого примерно месяц ушел на реализацию оффлайн игры один на один (логика, пользовательский интерфейс, инфраструктура), чуть больше половины месяца на мультиплеер и чуть меньше половины месяца на бота. Уверен, что совершил бы значительно больше ошибок, если бы не фреймворк для игр boardgame.io. Самыми сложными, по моему мнению, были реализация логики игры и бота. Неожиданно много времени заняли 3d игральные кости, попытки drag-and-drop, развертывание сервера на heroku. Неожиданно быстрым в реализации оказались обучение бота с помощью brain.js, комнаты (т.к. нашел почти готовый Lobby) и некоторые алгоритмы логики игры.
Много еще не сделано и предстоит сделать. Например, удобные комнаты, нормальная аутентификация, сохранение игры, чат… Так что, если вдруг у кого-нибудь будет желание что-нибудь сделать в проекте или поискать баги — прошу в github. Или пробуйте свое. Мне кажется, это принесет отличный и интересный опыт, который может пригодится в будущем!
P.S. Я далеко не профессионал в разработке игр и, скорее всего, совершил массу ошибок. Однако, это мой опыт. И, если он пригодится хотя бы кому-нибудь, это уже будет здорово.
Ссылки
- Игра — https://lehasvv2009.github.io/territories
- Репозиторий фронтенда — https://github.com/lehaSVV2009/territories
- Репозиторий бэкенда— https://github.com/lehaSVV2009/territories-backend
- Репозиторий общих компонентов фронтенда и бэкенда — https://github.com/lehaSVV2009/territories-core.
- Документация фреймворка для игр — https://boardgame.io
- 3D-графика — https://threejs.org
- Material UI — https://material-ui.com
- Хостинг сервера — https://heroku.com/
- Хостинг клиента — https://pages.github.com/
- Сервис для сборки, тестирования и развертывания — https://travis-ci.org/
- Нейронные сети на JS — https://github.com/BrainJS/brain.js
- Монополия (дизайн 3D кубиков)—http://monopoly-club.org/
- Мини-футбол (дизайн комнат) — https://www.haxball.com/
- Я — https://lehasvv2009.github.io/resume/