Вы неплохо овладели не только продвинутыми возможностями JS, но и инфраструктурными инструментами. И вам поручили первый проект: разработать небольшую пошаговую игру.
Но, не всё так хорошо - UI уже написали за вас, спроектировали некоторые базовые классы, но на этом разработку забросили. Вам нужно реанимировать проект, переведя его на работу с npm, Babel, Webpack, ESLint (ну и дальше по списку - вы в курсе), а также дописать оставшуюся функциональность, потому что времени (и денег) на разработку и проектирование с нуля, как обычно, нет :).
Двухмерная игра в стиле фэнтези, где игроку предстоит выставлять своих персонажей против персонажей нечисти. После каждого раунда, восстанавливается жизнь уцелевших персонажей игрока и повышается их уровень. Максимальный уровень - 4.
Размер поля фиксирован (8x8). Направление движения аналогично ферзю в шахматах. Персонажи разного типа могут ходить на разное расстояние (в базовом варианте можно перескакивать через других персонажей - т.е. как конь в шахматах, единственно - ходим по прямым и по диагонали):
- Мечники/Скелеты - 4 клетки в любом направлении
- Лучники/Вампиры - 2 клетки в любом направлении
- Маги/Демоны - 1 клетка в любом направлении
Дальность атаки так же ограничена:
- Мечники/Скелеты - могут атаковать только соседнюю клетку
- Лучники/Вампиры - на ближайшие 2 клетки
- Маги/Демоны - на ближайшие 4 клетки
Клетки считаются "по радиусу", допустим для мечника зона поражения будет выглядеть вот так:
Для лучника(отмечено красным):
Игрок и компьютер последовательно выполняют по одному игровому действию, после чего управление передаётся противостоящей стороне. Как это выглядит:
- Выбирается собственный персонаж (для этого на него необходимо кликнуть левой кнопкой мыши)
- Далее возможен один из двух вариантов:
- Перемещение: выбирается свободное поле, на которое можно передвинуть персонажа (для этого на поле необходимо кликнуть левой кнопкой мыши)
- Атака: выбирается поле с противником, которое можно атаковать с учётом ограничений по дальности атаки (для этого на персонаже противника необходимо кликнуть левой кнопкой мыши)
Важно: в новой игре игрок всегда начинает первым (если игра загружается из сохранения, то порядок определяется в сохранении).
Игра заканчивается тогда, когда все персонажи игрока погибают, либо достигнут выигран максимальный уровень (см.ниже Уровни).
Уровень завершается выигрышем игрока тогда, когда все персонажи компьютера погибли.
Баллы, которые набирает игрок за уровень равны сумме жизней оставшихся в живых персонажей.
Персонажи генерируются рандомно в столбцах 1 и 2 для игрока и в столбцах 7 и 8 для компьютера:
У игрока генерируются два персонажа: (случайным образом - типов Bowman
и Swordsman
) с уровнем 1, характеристики соответствуют таблице характеристик (см. раздел ниже).
У компьютера генерируется произвольный набор персонажей в количестве 2 единиц.
У игрока повышается level игроков на 1 + восстанавливается здоровье выживших. Дополнительно случайным образом добавляется новый персонаж уровня 1.
У компьютера случайным образом генерируются персонажи в количестве, равным количеству персонажей игрока, с уровнем от 1 до 2.
У игрока повышается level игроков на 1 + восстанавливается здоровье выживших. Дополнительно случайным образом добавляется два новых персонаж уровня 1 или 2.
У компьютера случайным образом генерируются персонажи в количестве, равным количеству персонажей игрока, с уровнем от 1 до 3.
У игрока повышается level игроков на 1 + восстанавливается здоровье выживших. Дополнительно случайным образом добавляется два новых персонаж уровня от 1 до 3.
У компьютера случайным образом генерируются персонажи в количестве, равным количеству персонажей игрока, с уровнем от 1 до 4.
Валидные строковые идентификаторы (к которым привязаны изображения):
- swordsman
- bowman
- magician
- daemon
- undead
- vampire
UPD: 14.04.2019 у игрока могут быть только swordsman, bowman и magician, у компьютера только daemon, undead, vampire. Если вы сделали без этого, то диплом тоже принимается.
- Bowman - 25/25
- Swordsman - 40/10
- Magician - 10/40
- Vampire - 25/25
- Undead - 40/10
- Daemon - 10/40
- На 1 повышает поле level автоматически после каждого раунда
- Показатель health приводится к значению: текущий уровень + 80 (но не более 100). Т.е. если у персонажа 1 после окончания раунда уровень жизни был 10, а персонажа 2 - 80, то после levelup:
- персонаж 1 - жизнь станет 90
- персонаж 2 - жизнь станет 100
- Повышение показателей атаки/защиты также привязаны к оставшейся жизни по формуле:
attackAfter = Math.max(attackBefore, attackBefore * (1.8 - life) / 100)
, т.е. если у персонажа после окончания раунда жизни осталось 50%, то его показатели улучшаться на 30%. Если же жизни осталось 1%, то показатели никак не увеличаться.
Структура предоставленных вам классов максимально упрощённая, без лишних изысков. Почти все зависимости передаются в качестве параметров конструктора, чтобы можно было легко тестировать их.
Ключевые сущности:
- GamePlay - класс, отвечающий за взаимодействие с HTML-страницей
- GameController - класс, отвечающий за логику приложения (важно: это не контроллер в терминах MVC), там вы будете работать больше всего
- Character - базовый класс, от которого вы будете наследоваться и реализовывать специализированных персонажей
- GameState - объект, который хранит текущее состояние игры (может сам себя воссоздавать из другого объекта)
- GameStateService - объект, который взаимодействует с текущим состоянием (сохраняет его, чтобы оно не потерялось при перезагрузке страницы, может экспортировать в файл или загрузить из файла)
- PositionedCharacter - Character, привязанный к координате на поле. Обратите внимание, что несмотря на то, что поле выглядит как двумерный массив, внутри оно хранится как одномерный (считайте это своеобразным
legacy
, с которым вам придётся бороться) - Team - класс для команды (набор персонажей), представляющих компьютер и игрока
- generators - модуль, содержащий вспомогательные функции для генерации команды и персонажей
Важно: авто-тесты обязательны только к тем задачам, где это явно обозначено. В остальных задачах вы можете их реализовывать по желанию.
Необходимо подключить Webpack, Webpack DevServer, Babel, ESLint.
Обратите внимание, что в бандл не собираются картинки, прописанные в CSS, т.к. не подключен соответствующий Loader.
Используйте url-loader. Добейтесь работоспособной сборки.
Пришло время наконец-начать подключать геймплей. Для этого у вас есть класс GamePlay. Объект этого класса уже создан, привязан к HTML-странице. Вам необходимо вызвать метод drawUi
с нужной темой для отрисовки на экране (вызывайте этот метод в методе init
класса GameController
).
Названия тем фиксированы и перечислены в модуле themes.js
. Подредактируйте модуль так, чтобы можно было использовать определённый в нём объект (а не прописывать каждый раз строки руками). На данном этапе достаточно выбрать тему prairie
. В задаче про уровни необходимо будет сделать привязку к уровню:
- Level 1: prairie
- Level 2: desert
- Level 3: arctic
- Level 4: mountain
Обратите внимание, что по умолчанию поле выглядит вот так:
Необходимо, чтобы оно выглядело вот так:
Для этого в модуле utils.js
допишите реализацию так, чтобы она возвращала строки:
- top-left
- top-right
- top
- bottom-left
- bottom-right
- bottom
- right
- left
- center
Не забудьте написать авто-тест на эту функцию.
Напишите реализацию для генератора characterGenerator
и функции generateTeam
(модуль generators
) с учётом правил, описанных в разделе Генерация персонажей.
Обратите внимание: обе функции на вход должны принимать массив (или iterable) из классов (не строковых названий, а именно классов).
Достаточно значительную часть времени, как программист, вы будете заниматься исследовательскими задачами (т.е. приобретением новых навыков, а не использованием существующих). Этим-то мы и предлагаем вам заняться. Заодно вспомнить, как на самом деле работает наследование в JavaScript.
Класс Character
был спроектирован как базовый, чтобы вы могли унаследовать от него своих персонажей. Поэтому неплохо бы запретить создавать объекты этого класса через new Character(level)
, но при этом создание наследников должно работать без проблем: new Daemon(level)
, где class Daemon extends Character
. Ознакомьтесь с документацией на new.target и реализуйте подобную логику, выбрасывая ошибку в конструкторе Character
.
Не забудьте написать тесты на то, что исключение выбрасывается при создании объекта класса Character
и не выбрасывается, при создании объектов унаследованных классов.
P.S. конечно, в реальной жизни проще договориться и нигде не делать new Character
, но нам важно, чтобы вы помнили про устройство наследования.
Для отрисовки воспользуйтесь методом redrawPositions
, который принимает на вход массив их объектов PositionedCharacter
. Для упрощения при любом дальнейшем изменении игрового поля (перемещение персонажа или его смерть) мы предлагаем вам целиком перерисовать игровое поле с помощью данного метода.
GamePlay
может уведомлять вас о событиях, происходящих с игровым полем через механизм callback'ов.
Для игрового поля они предусмотрены следующие:
- Вход указателя мыши в ячейку поля (
addCellEnterListener
) - Выход указателя мыши из ячейки поля (
addCellLeaveListener
) - Клик мышью по ячейке поля (
addCellClickListener
)
Чтобы добавить "слушателя" на определённое событие, используйте методы указанные рядом с описанием событий, в качестве аргумента передавая callback. Callback принимает всего один аргумент - индекс ячейки поля, на которой происходит событие.
Вам нужно реализовать механизм отображения краткой информации о персонаже с использованием tagged templates
(см. первую лекцию, пример с formatMark
).
Как это сделать:
- Подпишитесь из
GameController
на событиеcellEnter
(в качестве коллбека передавайте методonCellEnter
изGameController
- подумайте, как правильно это сделать, вспомните про то, что такое на самом деле методы в классе и проthis
)
Как это должно выглядеть:
// GameController:
someMethodName() { // <- что это за метод и где это нужно сделать решите сами
this.gameplay.addCellEnterListener(this.onCellEnter);
}
onCellEnter(cellIndex) {
// some logic here
}
- При возникновении события
cellEnter
проверяйте, есть ли в поле персонаж, если есть используйте методshowCellTooltip
из классаGamePlay
для отображения информации - При возникновении события
cellLeave
скрывайте подсказку (методhideCellTooltip
)
Формат информации: "🎖1 ⚔10 🛡40 ❤50", где:
- 1 - level
- 10 - значение атаки
- 40 - значение защиты
- 50 - значение жизни
🎖 U+1F396 - медалька (уровень) ⚔ U+2694 - мечи (атака) 🛡 U+1F6E1 - щит (защита) ❤ U+2764 - сердце (уровень жизни)
Не забудьте написать тесты на тегированный шаблон.
Важно: подсказка показывается только если в поле есть персонаж!
Настало время научить приложение выбирать персонажа для следующего хода. Для этого нужно учесть несколько вещей:
- Нужно помнить, чей сейчас ход: игрока или компьютера
- Нужно реагировать на клик пользователя по определённой ячейке игрового поля
Для хранения состояния мы предлагаем вам воспользоваться объектами специального класса GameState
и хранить в нём информацию о том, чей шаг следующий (продумайте самостоятельно, как вы это будете делать).
Для того, чтобы реагировать на клик на ячейке поля в классе GamePlay
реализован метод addCellClickListener
, который в качестве аргумента принимает callback. Подпишитесь из GameController
на событие cellClick
(в качестве коллбека передавайте метод onCellClick
из GameController
- подумайте, как правильно это сделать, вспомните про то, что такое на самом деле методы в классе и про this
).
// GameController:
someMethodName() { // <- что это за метод и где это нужно сделать решите сами
this.gameplay.addCellClickListener(this.onCellClick);
}
onCellClick(cellIndex) {
// some logic here
}
В методе onCellClick
, проверяйте, есть ли в ячейке персонаж и, что самое важное, это играбельный персонаж (т.е. Bowman
, Swordsman
или Magician
). Если нет - выводите сообщение об ошибке с помощью метода showError
из класса GamePlay
. Если же персонаж играбельный, то необходимо выделить ячейку с помощью метода selectCell
из класса GamePlay
:
Примечание: showError
работает, конечно, по-дурацки, просто выводя alert
, но на то она и Retro Game :).
Важно: выделить можно только одного персонажа! Если вы выделяете другого (играбельного персонажа), с предыдущего выделение снимается (см. метод deselectCell
из класса GamePlay
).
Сообщения об ошибках, это конечно, неплохо. Но гораздо лучше, когда пользователь сразу получает визуальный отклик.
Если персонаж игрока выбран (в данном случае лучник), то дальнейшие возможные действия могут быть:
- Выбрать другого персонажа (не рассматриваем - см.задачу выше)
- Перейти на другую клетку (в рамках допустимых переходов)
- Атаковать противника (в рамках допустимого радиуса атаки)
- Недопустимое действие (наведение на ячейку, не подпадающую под первые три варианта)
Вам необходимо в свободной форме реализовать подобную логику. При этом:
- Если мы собираемся выбрать другого персонажа, то поле не подсвечивается, а курсор приобретает форму
pointer
(см. модульcursors
и методsetCursor
из классаGamePlay
):
- Если мы собираемся перейти на другую клетку (в рамках допустимых переходов), то поле подсвечивается зелёным, курсор приобретает форму
pointer
:
- Если мы собираемся атаковать противника (в рамках допустимого радиуса атаки), то поле подсвечивается красным, курсор приобретает форму
crosshair
:
- Если мы собираемся выполнить недопустимое действие, то курсор приобретает форму
notallowed
(в этом случае при клике так же выводится сообщение об ошибке):
Не забудьте написать авто-тесты на функции/методы, которые лежат в основе п.1-4
Вы сделали визуальное отображение, пора заняться перемещением. Реализуйте логику, связанную с перемещением в GameController
и обновите отображаемых на экране персонажей с помощью метода redrawPositions
. Не забывайте убирать выделения ячеек и делать переход хода.
Пора заняться атакой. Реализуйте логику, связанную с атакой в GameController
: для отображения урона используйте метод showDamage
из GamePlay
. Обратите внимание, что он возвращает Promise
- добейтесь того, чтобы анимация урона доходила до конца. Обратите внимание, что после атаки должна пересчитываться полоска жизни над персонажем (она автоматически пересчитывается в redrawPositions
).
UPD 14.03.19: урон рассчитывается по формуле: Math.max(attacker.attack - target.defence, attacker.attack * 0.1)
Пора бы и компьютеру научиться отвечать на атаки игрока. Реализуйте одну из стратегий атаки компьютера на персонажей игрока, рассмотренных в рамках ДЗ, либо придумайте собственную.
Осталось немного: вы ходите, компьютер отвечает. Убедитесь, что персонажи исчезают после смерти (поле освобождается), урон считается и всё продолжается до того момента, пока у одного из противников есть хотя бы один живой персонаж. По окончании уровня убедитесь, что очки начисляются пользователю и происходит переход на новый уровень с генерацией команд, levelUp'ом и восстановлением жизни в соответствии с правилами, описанными в разделе "Механика".
После завершения игры (проигрыша игрока) или завершения всех 4 уровней - игровое поле необходимо заблокировать (т.е. не реагировать на события, происходящие на нём).
При нажатии на кнопку New Game
, должна стартовать новая игра, но при этом максимальное количество баллов (очков), набранное за предыдущие игры, должно сохраняться в GameState
.
Для подписки на события клика на кнопку New Game
используйте метод addNewGameListener
из класса GamePlay
.
Спроектируйте и реализуйте класс GameState
(модуль GameState
), который позволяет хранить всю информацию об текущем состоянии игры. Хранящейся в нём информации должно быть достаточно, чтобы сохранить полное состояние игры и восстановиться из него.
Сервис GameStateService
умеет с помощью методов save
и load
загружать состояние из локального хранилища браузера при перезагрузке.
Удостоверьтесь, что игра стартует с нужной точки после перезагрузки.
Обратите внимание, что метод load
может выкидывать ошибку.
Напишите авто-тест, с моком для метода load
, который проверяет реакцию вашего приложения на успешную и не успешную загрузку (при неуспешной загрузке должно выводиться сообщение через GamePlay
- подумайте, как вы это будете тестировать).
Ваше приложение уже достаточно хорошо, если вы добрались до этого пункта. Необходимо выложить ваше творение в сеть. Воспользуйтесь для этого сервисом GitHub Pages. Если кратко, то достаточно создать ветку с названием gh-pages
в вашем репозитории и положить туда только содержимое сборки (каталог dist
, если вдруг вы забыли), после чего запушить всё на GitHub.
GitHub Pages создаст веб-сайт по адресу: https://<ваш логин>.github.io/<название репозитория>
Ваше приложение автоматически развернётся на сервере (см. вкладку Environments
):
На странице будет указана ссылка на сам сайт и история развёртываний:
Это задание не обязательно для выполнения!
Уберите ограничение в 4 уровня и реализуйте бесконечный цикл level'ов. Сохраняйте максимальное количество баллов в состоянии игры.