netology
advanced js
Вы неплохо овладели не только продвинутыми возможностями JS, но и инфраструктурными инструментами. Вам поручили первый проект: разработать небольшую пошаговую игру.
Есть нюансы – UI уже написали за вас, спроектировали некоторые базовые классы, но на этом разработку забросили.
Вам нужно реанимировать проект, переведя его на работу с npm, Babel, Webpack, ESLint (ну и дальше по списку), а также дописать оставшуюся функциональность, потому что ресурсов на разработку и проектирование с нуля, как обычно, нет :).
Двухмерная игра в стиле фэнтези, где игроку предстоит выставлять своих персонажей против персонажей нечисти. После каждого раунда восстанавливается жизнь уцелевших персонажей игрока и повышается их уровень. Максимальный уровень - 4.
Игру можно сохранять и восстанавливать из сохранения.
Что вы должны получить в итоге: https://youtu.be/3iB3AerDJ0w
Ключевые сущности:
legacy
, с которым вам придётся бороться)Важно: авто-тесты обязательны только к тем задачам, где это явно обозначено. В остальных задачах вы можете их реализовывать по желанию.
Этапы:
Подключите файл src/index.js
как точку входа
Обратите внимание, что изначально картинки, прописанные в CSS, не собираются, т.к. не подключен соответствующий loader:
Размер поля фиксирован (8x8)
Пришло время наконец начать подключать геймплей. Для этого у вас есть класс GamePlay.
Объект этого класса уже создан и привязан к HTML-странице.
Вам необходимо вызвать метод drawUi
с нужной темой для отрисовки на экране
(вызывайте этот метод в методе init
класса GameController
).
Названия тем фиксированы и перечислены в модуле themes.js
.
Отредактируйте модуль так, чтобы можно было использовать определённый в нём объект
(а не прописывать каждый раз строки руками). На данном этапе достаточно выбрать тему prairie
.
Если настройка Webpack и Webpack Dev Server выполнена корректно,
вы увидите поле вида:
В задаче про уровни необходимо будет сделать привязку к уровню:
Необходимо, чтобы поле выглядело так:
Для этого в модуле src/js/utils.js
допишите реализацию функции calcTileType
так, чтобы она возвращала строки:
Особенности:
Например, для поля 8x8:
Напишите авто-тест на эту функцию.
Пример результата:
Для этого:
У игрока и соперника в команде могут быть только определённые классы персонажей
Создайте папку src/js/characters
и создайте в ней одноимённые файлы с классами персонажей,
которые наследуются от базового класса src/js/Character.js
Описание свойств персонажа:
type - строка с одним из допустимых значений: ‘swordsman’, ‘bowman’, ‘magician’, ‘daemon’, ‘undead’, ‘vampire’.
К этим значениям привязаны изображения персонажей на поле.
Укажите начальные характеристики каждого класса:
Класс | attack | defence |
---|---|---|
Bowman | 25 | 25 |
Swordsman | 40 | 10 |
Magician | 10 | 40 |
Vampire | 25 | 25 |
Undead | 40 | 10 |
Daemon | 10 | 10 |
Конструктор каждого класса принимает
Пример:
const character = new Swordsman(3);
character.level // 3
В игре с каждым новым уровнем у персонажей увеличиваются показатели здоровья, атаки и защиты. На данном этапе необходимо только сохранять текущий уровень персонажа. Улучшением характеристик вы займётесь в следующем блоке
Класс Character
был спроектирован как базовый, чтобы вы могли унаследовать от него своих
персонажей. Поэтому неплохо бы запретить создавать объекты этого класса
через new Character(level)
, но при этом создание наследников должно работать
без проблем: new Daemon
, где class Daemon extends Character
.
Ознакомьтесь с документацией на new.target
и реализуйте подобную логику, выбрасывая ошибку в конструкторе Character
.
Для этого допишите:
src/js/generators.js
src/js/Team.js
для хранения команд персонажей игрока и соперникаcharacterGenerator - функция генератор, которая формирует случайного персонажа из списка переданных классов.
Пример работы:
const playerTypes = [Bowman, Swordsman, Magician]; // доступные классы игрока
const playerGenerator = characterGenerator(playerTypes, 2); // в данном примере персонажи игрока могут быть 1 или 2-ого уровней
const character1 = playerGenerator.next().value; // случайный персонаж из списка playerTypes с уровнем 1 или 2
character1.type; // magician
character1.attack; // 10
character1.level; // 2
const character2 = playerGenerator.next().value; // ещё один случайный персонаж
character2.level; // 1
character2.type; // swordsman
playerGenerator.next().value; // можно вызывать бесконечно
playerGenerator.next().value;
playerGenerator.next().value;
playerGenerator.next().value;
playerGenerator.next().value; // всегда получим нового случайного персонажа со случайным уровнем
Класс Team хранит команду игрока или соперника. Реализуйте хранение персонажей по вашему усмотрению.
Например:
const characters = [new Swordsman(2), new Bowman(3)]; // Обратите внимание на new в отличие от playerTypes в прошлом примере
const team = new Team(characters);
team.characters // [swordsman, bowman]
generateTeam - формирует команду на основе characterGenerator
. Для формирования всех персонажей
на поле потребуется вызвать generateTeam дважды: для команд игрока и соперника.
Функция возвращает экземпляр класса Team
const playerTypes = [Bowman, Swordsman, Magician]; // доступные классы игрока
const team = generateTeam(playerTypes, 3, 4); // массив из 4 случайных персонажей playerTypes с уровнем 1, 2 или 3
team.characters[0].level // 3
team.characters[1].level // 3
team.characters[2].level // 1
Персонажи генерируются случайным образом в столбцах 1 и 2 для игрока и в столбцах 7 и 8 для соперника:
Чтобы привязать персонаж к ячейке, воспользуйтесь классом PositionedCharacter в src/js/PositionedCharacter.js
Например:
const character = new Bowman(2);
const position = 8; // для поля 8x8 лучник будет находиться слева на второй строке
const positionedCharacter = new PositionedCharacter(character, position);
Для отрисовки воспользуйтесь методом redrawPositions
класса src/js/GamePlay.js
,
Метод принимает на вход массив объектов PositionedCharacter
. Для упрощения при любом дальнейшем изменении игрового поля
(перемещение персонажа или его смерть) мы предлагаем вам целиком перерисовать игровое поле с помощью данного метода.
Логику формирования и отрисовки команд игрока и соперника, как и логику всей игры рекомендуется выполнять в
классе src/js/GameController.js
Важно! На поле в одной клетке не могут одновременно находиться два персонажа!
characterGenerator
бесконечно новые персонажи из списка (учёт аргумента allowedTypes)generateTeam
После загрузки страницы на поле случайным образом находятся команды игрока и соперника.
Каждая перезагрузка страницы начинает игру заново, а следовательно, формирует новые составы команд на поле.
Вам нужно реализовать отображение краткой информации о персонаже с использованием tagged templates
.
GamePlay
может уведомлять вас о событиях, происходящих с игровым полем через механизм callback’ов.
Для игрового поля предусмотрены:
addCellEnterListener
)addCellLeaveListener
)addCellClickListener
)Чтобы добавить “слушателя” на определённое событие, используйте методы, указанные рядом с описанием событий, в качестве аргумента передавая callback. Callback принимает всего один аргумент - индекс ячейки поля, на которой происходит событие.
Как это сделать:
Подпишитесь из 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”, где:
🎖 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
(см. модуль src/js/cursors.js
и метод setCursor
из класса GamePlay
):
Если мы собираемся перейти на другую клетку (в рамках допустимых переходов), то поле подсвечивается зелёным, курсор приобретает форму pointer
:
Если мы собираемся атаковать противника (в рамках допустимого радиуса атаки), то поле подсвечивается красным, курсор приобретает форму crosshair
:
Если мы собираемся выполнить недопустимое действие, то курсор приобретает форму notallowed
(в этом случае при клике так же выводится сообщение об ошибке):
Смену хода, бой и перемещение персонажей вы рассмотрите в следующих блоках
Направление движения аналогично ферзю в шахматах. Персонажи разного типа могут ходить на разное расстояние (в базовом варианте можно перескакивать через других персонажей, т.е. как конь в шахматах, единственное правило - ходим по прямым и по диагонали):
Дальность атаки тоже ограничена:
Клетки считаются “по радиусу”, допустим для мечника зона поражения будет выглядеть вот так:
Для лучника(отмечено красным):
Перемещение: Выбирается свободное поле, на которое можно передвинуть персонажа (для этого на поле необходимо кликнуть левой кнопкой мыши)
Вы сделали визуальное отображение, пора заняться перемещением. Реализуйте логику,
связанную с перемещением в GameController
и обновите отображаемых на экране персонажей с
помощью метода redrawPositions
. Не забывайте убирать выделения ячеек и делать переход хода.
Пора заняться атакой. Реализуйте логику, связанную с атакой в GameController
:
для отображения урона используйте метод showDamage
из GamePlay
.
Обратите внимание, что он возвращает Promise
- добейтесь того, чтобы анимация урона доходила
до конца. Обратите внимание, что после атаки должна пересчитываться полоска жизни над персонажем
(она автоматически пересчитывается в redrawPositions
).
Урон рассчитывается по формуле: Math.max(attacker.attack - target.defence, attacker.attack * 0.1)
,
где attacker
- атакующий персонаж, target
- атакованный персонаж
При совершении атаки вы должны уменьшить здоровье атакованного персонажа на размер урона.
Пора и компьютеру научиться отвечать на атаки игрока. Реализуйте стратегию атаки компьютера на персонажей игрока (например, атакуем первого доступного, самого слабого/сильного из всех доступных, либо придумайте собственную тактику).
Игрок и компьютер последовательно выполняют по одному игровому действию, после чего управление передаётся противостоящей стороне.
Реализуйте логику:
Показатель health приводится к значению: текущий уровень + 80 (но не более 100).
Т.е. если у персонажа 1 после окончания раунда уровень жизни был 10, а персонажа 2 - 80, то после levelup:
Повышение показателей атаки/защиты привязаны к оставшейся жизни по формуле:
attackAfter = Math.max(attackBefore, attackBefore * (80 + life) / 100)
, т.е. если у персонажа после окончания раунда жизни осталось 50%, то его показатели улучшатся на 30%. Если жизни осталось 1%, то показатели никак не увеличатся.
Внесите учёт логики при создании персонажа выше 1 уровня:
const character = new Daemon(3); // Создаёт персонажа 1-уровня и 2 раза повышает его уровень и характеристики
После перехода на новый уровень смените тему игры (prairie -> desert -> arctic -> mountain)
После завершения игры (проигрыша игрока) или завершения всех 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
):
На странице будет указана ссылка на сам сайт и история развёртываний: