Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Игровой дизайн: карточный бой JS: Программирование, управляемое данными

Вероятно для вас это будет большой сюрприз, но на текущий момент вы уже знаете всё, что нужно для того, чтобы написать эту игру. Предыдущие уроки нам дали достаточно материала для того, чтобы мы написали то, о чем говорили в интро текущего курса, поэтому этот урок посвящен геймдизайну. Мы будем прорабатывать именно момент того, как работает игра и как она выглядит на программном уровне. И практикой к этому уроку будет уже создание непосредственно самой игры.

Правила

Давайте еще раз посмотрим на то, как устроена наша игра.

Начинаем бой!

Игрок 'John' применил 'Прохладный чыонг-бонг рыка'
против 'Ada' и нанес урон '3'

Игрок 'Ada' применил 'Воздушный змей клеветы'
против 'John' и нанес урон '1'

Игрок 'John' применил 'Проказливый рубитель крови'
против 'Ada' и нанес урон '2'

Ada был убит

Итак, у нас есть начало боя, у нас есть конец боя в котором кто-то умирает и 2 игрока по очереди наносят урон друг другу. При этом игра включает в себя какой-то набор карт, который кстати говоря бесконечен (карты никогда не заканчиваются). Фактически мы просто создаем некие карты в которых описываем какой будет урон и после этого они рандомно применяются к конкретным игрокам для того, чтобы нанести этот урон. Карты – это просто список из которого рандомно выбирается какая-то карта и применяется. Это означает что в следующий раз может быть выбрана та же самая карта. Все зависит от того, как работает алгоритм выбора.

Test Driven Development

Давайте начнём с тестов и подумаем о том, а как игра может вообще выглядеть и что нужно реализовать в коде для того, чтобы она заработала.

import { cons } from 'hexlet-pairs';
import { l, length } from 'hexlet-pairs-data';
import { make } from 'hexlet-card-game';

const cards = l(
  cons('Костяная кочерга гробницы', () => 6)
);
const game = make(cards);
const log = game('John', 'Ada');

assert.equal(length(log), 5);

Первое, что нам нужно – это список карт. Выше видно, что список карт cards – это действительно список с точки зрения структур, которые у нас есть. Каждый элемент этого списка является парой, которая состоит из 2х элементов: первый элемент пары – это имя карты, второй элемент – это функция, которая внутри себя содержит урон, который будет нанесён. Обратите внимание, что этот урон не содержит знака, то есть это не какая-то отрицательная величина, это именно то число, которое будет вычтено из жизни игрока к которому этот урон применяется.

Почему здесь функция, но при этом просто конкретное число? Дело в том, что в будущем мы обязательно расширим нашу функциональность и урон будет не статический, как сейчас (когда карта наносит какую-то конкретную цифру). Например, текущая карта наносит урон 6 очков.

cons('Костяная кочерга гробницы', () => 6);

Но карта может быть более хитрая. Например, она может брать текущее здоровье и на основе него вычислять какой урон нанести. Именно поэтому мы сразу это делаем функцией, потому что это достаточно очевидная вещь, которую в этой игре стоит вводить (тем более так было задумано изначально). Конечно, мы могли бы просто обойтись числом, но нам в конце концов пришлось бы вводить тут функцию.

После того, как мы создали список этих карт (карта может быть и одна, это не имеет никакого значения), мы вызываем функцию make из нашего пакета hexlet-card-game, который мы и будем разрабатывать. make – это некая функция, которую сейчас мы будем использовать. Она принимает на вход список карт и создаёт игру. После этого мы вызываем game и передаем туда имена наших игроков и на этом все, больше ничего делать не надо. Игра идёт автоматически, то есть мы сами ими не управляем.

Естественно в реальной жизни у нас вызывались бы ходы по очереди, а после этого ожидалось бы какое-то действие от пользователя и мы бы его применяли. В нашем случае это не нужно, поэтому функция game проводит автоматическую игру и в конце концов возвращает log игры.

Здесь есть очень важный момент, который к тому же еще и очень интересный. То, что вы видели до этого выглядело, как будто мы просто печатаем на экран то, что происходит внутри игры и game мог бы так работать (внутри после каждого хода мы бы печатали что-то на экран). Но если вспомнить наш курс Основы программирования, то там был очень важный урок посвящённый чистым функциям и важности отсутствия побочных эффектов. Печать на экран – это побочный эффект. Это автоматически означает, что вы не можете безопасно запустить 2 раза подряд (как минимум) эту функцию game. Во-вторых, вы никак не контролируете этот вывод и вообще не можете, например, банально протестировать функцию и узнать что происходит. Конечно, можно технически перехватывать вывод, который идет на экран, но это совершенно неправильный способ тестировать софт. Он очень не надёжный. Это связано с тем, что вам придётся парсить строчки и проверять конкретное содержимое внутри них, что совершенно не хорошо. Гораздо более правильный подход и способ работы здесь – это не печатать на экран результаты каждого хода, а фактически формировать некий log, который представляет из себя список, внутри которого содержится вся необходимая информация связанная с текущим ходом.

Во-первых, это позволяет нам, как минимум, гораздо легче и удобнее анализировать, то что происходит. Во-вторых, у нас отсутствуют побочные эффекты и функция всегда возвращает некий чистый результат, то есть она сама по себе является чистой, что очень хорошо. Мы можем её повторно запускать, безопасно анализировать и такой код проще всего тестируется.

Ниже мы можем посмотреть первый тест. Самый очевидный и простой – это проверка длины лога, то что он равен 5.

assert.equal(length(log), 5);

Наверно проще данного теста не написать. Ну может быть только проверить, что log является списком, но это не нужно, потому что это по интерфейсу и так должно быть очевидно. В любом случае здесь бы упало с ошибкой, если бы log не был списком. Итак, у нас получилось 5 ходов, давайте же теперь посмотрим какие это ходы.

Ходы

// step ((health1, health2), message)

const step1 = get(0, log);
assert.equal(toString(car(step1)), '(10, 10)');
const step2 = get(1, log);
assert.equal(toString(car(step2)), '(10, 4)');
const step3 = get(2, log);
assert.equal(toString(car(step3)), '(4, 4)');
const step4 = get(3, log);
assert.equal(toString(car(step4)), '(4, -2)');
const step5 = get(4, log);
assert.equal(toString(car(step5)), '(4, -2)');

Здесь мы видим следующий набор тестов, который тоже должен присутствовать (и он присутствует) в наших тестах, в котором мы извлекаем из лога конкретные шаги (элементы) и проверяем, что происходит на каждом шаге.

Перед тем, как мы посмотрим, непосредственно, как происходит сравнение и что внутри, давайте оценим из чего состоит каждый шаг.

log – это набор шагов, который приводит к завершению игры. Каждый шаг – это пара, внутри которой 2 элемента.

// step ((health1, health2), message)

Первый элемент – тоже пара, потому что у нас пока только пары для того, чтобы делать какие-то сложно-составные структуры. Второй – это message. Message – это как раз то самое сообщение, которое мы видели в самом начале, что кто-то кого-то убил, то есть это просто текстовое сообщение. Первый элемент (который пара) содержит здоровье первого игрока и здоровье второго игрока. Это нам нужно как раз для того, чтобы мы могли оценивать “а правильно ли работает логика нашей программы?” и мы это используем в тестах.

Теперь давайте посмотрим, как мы это используем. У нас есть функция get, которая определена в пакете для работы со списками. Она извлекает элемент по индексу и здесь мы её просим дать нам первый элемент.

const step1 = get(0, log);

Ну и соответственно второй, третий, четвёртый, пятый, то есть как раз все 5 элементов.

А почему их 5? Если мы вспомним был тест, который как раз проверяет, что их 5.

assert.equal(length(log), 5);

Это связано с тем, что текущий уровень жизни у всех мы выставили равным 10, а damage (так называемый урон) у нас равен цифре 6. И давайте теперь посмотрим, что происходит в таком случае.

В начале, когда происходит старт игры, мы получаем message, который называется начало игры и первый элемент в паре, который является парой со здоровьем по умолчанию, то есть это 10 10. В log мы складываем не сам ход, а начальное состояние игры.

Как мы извлекаем здоровье?

assert.equal(toString(car(step1)), '(10, 10)');

Из step берем car (это первый элемент) и просто превращаем его в строчку, для того, чтобы сравнить и используем assert.equal, который проверяет, что левое значение должно совпадать с правым. То есть здесь слева actual – то, что пришло на самом деле, а справа expected (10, 10) – то, что мы ожидаем. И таким вот образом мы сравниваем каждый элемент.

Теперь мы можем сосредоточиться только на части expected (10, 10) и посмотреть, как изменяются жизни в нашей текущей игре.

assert.equal(toString(car(step1)), '(10, 10)');
assert.equal(toString(car(step2)), '(10, 4)');
assert.equal(toString(car(step3)), '(4, 4)');
assert.equal(toString(car(step4)), '(4, -2)');
assert.equal(toString(car(step5)), '(4, -2)');

Сначала у обоих игроков по 10, после этого применяется урон, который был равен 6 ко второму игроку и соответственно мы получаем 10, 4. После этого, поскольку карта у нас одна и её урон 6, она применяется к первому игроку – мы получаем 4, 4. Затем снова применяется ко второму и у нас получается 4, -2, это 4-й шаг. На 5 шаге видно, что здесь абсолютно тот же самый вывод, но почему? Потому что 5-й шаг, так же как и 1-й – это специальный шаг, в котором пишется сообщение, что кто-то был убит и здоровье показывается ровно такое какое было на предыдущем шаге, потому что здесь уже никому не наносится урон и мы просто фиксируем некое состояние, в котором закончилась наша игра.

Свойства

Какими свойствами обладает наш дизайн, то что мы сделали и написали и какими свойствами обладает наш тестовый пакет?

  • Интерфейс – одна функция (make)
  • Автоматическая игра
  • Логика покрыта тестами

Во-первых, весь интерфейс – это по сути одна функция make, то есть публичный интерфейс, в котором происходит работа, который в свою очередь генерирует функцию game и она уже проигрывает нам какую-то игру.

Ранее также говорилось, что игра автоматическая, у нас нет никакой возможности манипулировать ходом игры просто потому, что это не нужно. На текущий момент это отвлечёт нас от самой задачи и слишком сильно её усложнит.

Ну и как мы заметили логика покрыта тестами. То есть после того, как мы пишем эти тесты мы уже знаем какой у нас будет дизайн, мы легко можем запускать, перезапускать и дописывать наш код и самое главное, что в будущем эти тесты будут нам постоянно помогать. После любого рефакторинга, после любого изменения – мы уже всегда гарантированно сможем проверить, что результат такой, как мы ожидаем.


Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Об обучении на Хекслете

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»