Платформа игры
Оригинал:
Project: A Platform Game
Author: Iain Banks, «The Player of Games»
Все в мире — игра.
Я с детства увлекался компьютерными играми. Я погружался в этот маленький мир, созданный компьютером, и мог управлять им. Меня также привлекали истории, которые ещё не были раскрыты. Но меня привлекала не сама история, а возможность использовать своё воображение для создания собственных сюжетов.
Не стоит рассматривать создание игр как профессию. В музыкальной индустрии существует огромный разрыв между энтузиазмом молодых людей, желающих присоединиться к этой отрасли, и реальными потребностями в талантах, что приводит к нездоровой рабочей среде. Однако создание игр может быть интересным хобби.
В этой главе мы рассмотрим, как создать небольшую платформенную игру. Платформенные игры (или «прыгающие» игры) требуют от игрока управления персонажем, который перемещается по миру. Эти игры обычно двумерны и используют одну сторону в качестве точки обзора, позволяя игроку прыгать вперёд и назад.
Наша игра основана на игре Dark Blue, разработанной Томасом Палефом (Thomas Palef). Я выбрал эту игру, потому что она интересная и простая, и не требует написания большого количества кода. Игра выглядит следующим образом:
Рисунок 16-1. Игра Dark Blue | Чёрные блоки представляют игрока, задача которого — собрать жёлтые блоки (монеты), избегая столкновения с красными блоками (лава). Когда игрок собирает все монеты, он может перейти на следующий уровень. |
Игрок может перемещаться влево и вправо с помощью клавиш со стрелками и прыгать вверх с помощью клавиши вверх. Прыжки — это особенность персонажа в этой игре. Игрок может прыгать в несколько раз выше своего роста и менять направление в воздухе. Хотя это не реалистично, это помогает игроку почувствовать, что он непосредственно контролирует своего персонажа на экране.
Игра включает в себя фиксированный фон, организованный в виде сетки, с подвижными элементами, наложенными поверх фона. Элементы в сетке могут быть воздухом, твёрдым телом или лавой. Подвижные элементы включают игрока, монеты или кусок лавы. Позиция этих элементов не ограничена сеткой, их координаты могут быть дробными, что позволяет им плавно двигаться.
Мы будем использовать DOM браузера для отображения интерфейса игры и обрабатывать события нажатия клавиш для чтения ввода пользователя.
Код, связанный с экраном и клавиатурой, составляет лишь небольшую часть кода игры. Поскольку все элементы являются цветными блоками, процесс рисования довольно прост. Мы создаём соответствующий DOM-элемент для каждого элемента и используем стили для определения цвета фона, размера и положения.
Поскольку фон состоит из неподвижных блоков, образующих сетку, мы можем использовать таблицу для его отображения. Свободно перемещаемые элементы можно покрыть с помощью абсолютно позиционированных элементов.
Игры и некоторые программы должны отображать анимацию и реагировать на ввод данных без заметной задержки, производительность имеет решающее значение. Несмотря на то, что DOM изначально не был предназначен для высокопроизводительного рисования, на самом деле производительность DOM намного лучше, чем мы ожидаем. Читатели уже видели примеры анимации в главе 13, и даже такие простые игры могут работать плавно на современных компьютерах, если мы не уделяем особого внимания оптимизации производительности.
В следующей главе мы изучим другую технологию браузера — тег . Этот тег предоставляет более традиционный способ рисования изображений, работая непосредственно с формами и пикселями, а не с элементами DOM.
Нам нужен читаемый и редактируемый человеком метод для определения уровней. Поскольку всё начинается с сетки, мы можем использовать большие строки, где каждый символ представляет элемент, будь то часть фоновой сетки или подвижный элемент.
Пример небольшого уровня может выглядеть так:
var simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;
Точки представляют пустые позиции, решётка (#) представляет стену, плюс (+) представляет лаву. Начальная позиция игрока обозначается символом @. Каждый символ O представляет монету, знак равенства (=) представляет блок лавы, который движется горизонтально вперёд и назад, а символ + представляет блок падающей лавы — этот тип лавы также движется вертикально вперёд и назад, но не подпрыгивает, а просто падает вниз, пока не достигнет земли, после чего возвращается в исходное положение.
У нас есть два дополнительных типа подвижной лавы: символ | представляет вертикальную лаву, которая движется вперёд и назад, и символ v представляет падающую лаву — этот тип лавы движется вертикально вниз и не отскакивает, а только падает, пока не достигает земли, после чего возвращается в исходное положение.
Вся игра содержит множество уровней, и игрок должен пройти все уровни. Условием прохождения каждого уровня является сбор всех монет. Если игрок сталкивается с лавой, текущий уровень сбрасывается, и игрок может попробовать снова.
Следующий класс хранит объекты уровня. Его параметр должен быть строкой, определяющей уровень.
class Level {
constructor(plan) {
let rows = plan.trim().split("\n").map(l => [...l]);
this.height = rows.length;
this.width = rows[0].length;
this.startActors = [];
this.rows = rows.map((row, y) => {
return row.map((ch, x) => {
let type = levelChars[ch];
if (typeof type == "string") return type;
this.startActors.push(
type.create(new Vec(x, y), ch));
return "empty";
});
});
}
}
Метод trim используется для удаления пробелов в начале и конце строки плана. Это позволяет нашему примеру плана начинаться с новой строки, чтобы все строки располагались прямо под предыдущей. Остальная часть строки разбивается на новые строки, создавая массив строк, образуя массив символов.
Таким образом, rows содержит массив массивов символов, представляющих строки плана уровня. Мы можем получить горизонтальную ширину и высоту из этого. Однако нам всё ещё нужно отделить подвижные элементы от фоновой сетки. Мы называем их актёрами. Они будут храниться в массиве объектов. Фон будет представлен массивом массивов строк, содержащих информацию о типе, такую как «пусто», «стена» или «лава».
Чтобы создать эти массивы, мы сопоставляем строки и затем сопоставляем их содержимое. Помните, что map передаст индекс массива в функцию сопоставления в качестве второго параметра, сообщая нам координаты x и y данного символа. Положение в игре будет представлено парой координат, где верхний левый угол равен (0, 0), и каждый блок фона равен 1 единице в высоту и ширину.
Для объяснения символов в плане уровня конструктор класса Level использует объект levelChars, который сопоставляет элементы фона со строками, а символы актёров — с классами. Когда type является классом актёра, его статический метод create используется для создания объекта, который добавляется в startActors, а функция сопоставления возвращает «пусто» для этого блока фона.
Положение актёров хранится в объекте Vec, который является двумерным вектором, объектом с атрибутами x и y, подобно тому, как это было сделано в главе 6.
Во время игры актёры будут находиться в разных местах или даже полностью исчезать (например, когда монета собирается). Мы будем использовать класс State для отслеживания текущего состояния игры.
class State {
constructor(level, actors, status) {
this.level = level;
this.actors = actors;
this.status = status;
}
static start(level) {
return new State(level, level.startActors, "playing");
}
get player() {
return this.actors.find(a => a.type == "player");
}
}
Когда игра закончится, атрибут status изменится на «потеряно» или «выиграно».
Это ещё одна структура данных, сохраняющая состояние, обновление игрового состояния создаст новое состояние, сохранив старое состояние нетронутым.
Объект актёра представляет текущее положение и состояние данного подвижного элемента в игре. Все объекты актёров следуют одному и тому же интерфейсу. Их атрибут pos сохраняет координаты верхнего левого угла элемента, а атрибут size сохраняет его размер.
Затем у них есть метод update, который вычисляет новое состояние и положение элемента после заданного временного шага. Он имитирует действия актёра: реагирует на клавиши со стрелками и перемещается, отскакивает назад и вперёд при столкновении с лавой и возвращает новый обновлённый объект актёра.
Атрибут type содержит строку, указывающую тип актёра: «игрок», «монета» или «лава». Это полезно при рисовании игры, поскольку внешний вид актёра основан на его типе.
Класс актёра имеет статический метод создания, используемый конструктором класса Level для создания актёра из символа в плане уровня. Он принимает сам символ и его координаты, что необходимо, поскольку класс Lava обрабатывает несколько различных символов. Это класс Vec, который мы будем использовать для двумерных значений, например, позиции и размера роли.
class Vec {
constructor(x, y) {
this.x = x; this.y = y;
}
plus(other) {
return new Vec(this.x + other.x, this.y + other.y);
}
times(factor) {
return new Vec(this.x * factor, this.y * factor);
}
}
Метод times используется для масштабирования вектора с помощью заданного числа. Это полезно, когда нам нужно умножить скорость вектора на временной интервал, чтобы получить пройденное расстояние за это время.
Разные типы ролей имеют свои собственные классы, потому что их поведение сильно различается. Давайте определим эти классы. Позже мы посмотрим на их метод update.
Класс Player имеет атрибут speed, который хранит текущую скорость, чтобы имитировать импульс и гравитацию.
class Player {
constructor(pos, speed) {
this.pos = pos;
this.speed = speed;
}
get type() { return "player"; }
static create(pos) {
return new Player(pos.plus(new Vec(0, -0.5)),
new Vec(0, 0));
}
}
Player.prototype.size = new Vec(0.8, 1.5);
Поскольку высота игрока составляет половину клетки, его начальная позиция будет выше на половину клетки по сравнению с появлением символа @. Таким образом, нижняя часть игрока может быть выровнена с нижней частью блока, в котором он появляется.
Атрибут size одинаков для всех экземпляров Player, поэтому мы сохраняем его в прототипе, а не в самом экземпляре. Мы можем использовать считыватель, подобный типу, но каждый раз при чтении атрибута будет создаваться и возвращаться новый объект Vec, что было бы расточительно (строки неизменяемы, нет необходимости создавать их заново при каждом вычислении).
При создании объекта Lava нам необходимо инициализировать объект на основе символа, на котором он основан. Динамическая лава движется со своей текущей скоростью, пока не столкнётся с препятствием. Если у него есть атрибут reset, он вернётся к своему начальному положению (капнет вниз). В противном случае он изменит направление своей скорости и продолжит движение в другом направлении (отскочит).
Метод create класса Lava проверяет символ, переданный конструктором Level, и создаёт соответствующий объект лавы.
class Lava {
constructor(pos, speed, reset) {
this.pos = pos;
this.speed = speed;
this.reset = reset;
}
get type() { return "lava"; }
static create(pos, ch) {
if (ch == "=") {
return new Lava(pos, new Vec(2, 0));
} else if (ch == "|") {
return new Lava(pos, new Vec(0, 2));
} else if (ch == "v") {
return new Lava(pos, new Vec(0, 3), pos);
}
}
}
Lava.prototype.size = new Vec(1, 1);
Объект Coin относительно прост, большую часть времени ему просто нужно оставаться на месте. Но чтобы сделать игру более интересной, мы позволяем монете слегка колебаться, то есть она будет немного перемещаться вверх и вниз по вертикали. Каждый объект монеты хранит свою базовую позицию и использует атрибут wobble для отслеживания амплитуды колебаний. Эти два атрибута вместе определяют фактическое положение монеты (хранится в атрибуте pos).
class Coin {
constructor(pos, basePos, wobble) {
this.pos = pos;
this.basePos = basePos;
this.wobble = wobble;
}
get type() { return "coin"; }
static create(pos) {
let basePos = pos.plus(new Vec(0.2, 0.1));
return new Coin(basePos, basePos,
Math.random() * Math.PI * 2);
}
}
Coin.prototype.size = new Vec(0.6, 0.6);
В главе 14 мы узнали, что Math.sin можно использовать для вычисления координаты y круга. Поскольку мы движемся вдоль окружности, координата y будет двигаться вперёд и назад плавным волнообразным движением, и синусоидальная функция полезна для реализации волнообразного движения.
Чтобы избежать одновременного перемещения всех монет вверх и вниз, начальная фаза каждой монеты случайна. Волновая длина, генерируемая Math.sin, равна 2π. Мы можем умножить возвращаемое значение Math.random на 2π, чтобы вычислить начальное положение траектории волны монеты.
Теперь мы можем определить объект levelChars, который сопоставляет символы плоскости с фоновыми сетками или ролями.
const levelChars = {
".": "empty", "#": "wall", "+": "lava",
"@": Player, "o": Coin,
"=": Lava, "|": Lava, "v": Lava
};
Это даёт нам все необходимые компоненты для создания экземпляра Level.
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9
Задача этого фрагмента кода — отобразить определённый уровень на экране и построить время и действия в этом уровне. Перевод текста на русский язык:
Ранее упоминалось, что мы используем элемент <table>
для рисования фона. Это полностью соответствует структуре свойства grid
в уровне. Каждая строка в сетке соответствует строке в таблице (<tr>
элемент). Каждый элемент строки в сетке соответствует типу элемента ячейки таблицы (<td>
). Оператор расширения (три точки) используется для передачи дочерних узлов в виде отдельного параметра функции elt
.
Следующий CSS делает таблицу похожей на желаемый фон:
.background { background: rgb(52, 166, 251);
table-layout: fixed;
border-spacing: 0; }
.background td { padding: 0; }
.lava { background: rgb(255, 100, 100); }
.wall { background: white; }
Некоторые атрибуты (border-spacing и padding) используются для отмены некоторых нежелательных действий таблицы по умолчанию. Мы не хотим, чтобы между ячейками или внутри них было дополнительное пустое пространство.
Правило background
используется для установки цвета фона. В CSS можно использовать два способа указания цвета: один — с использованием слова (например, white
), другой — с использованием формата типа rgb(R,G,B)
, где R представляет красную составляющую цвета, G представляет зелёную составляющую, а B представляет синюю составляющую, каждое число находится в диапазоне от 0 до 255. Таким образом, в rgb(52,166,251)
красная составляющая равна 52, зелёная — 166, а синяя — 251. Поскольку значение синей составляющей является наибольшим, окончательный цвет будет склоняться к синему. Вы можете видеть, что в правиле .lava
первое число (красное) является самым большим.
Нам нужно создать соответствующий элемент DOM при рисовании каждого персонажа и установить координаты и размер элемента в соответствии со свойствами персонажа. Все эти значения должны быть умножены на scale
, чтобы преобразовать единицы измерения игры в пиксели.
function drawActors(actors) {
return elt("div", {}, ...actors.map(actor => {
let rect = elt("div", {class: `actor ${actor.type}`});
rect.style.width = `${actor.size.x * scale}px`;
rect.style.height = `${actor.size.y * scale}px`;
rect.style.left = `${actor.pos.x * scale}px`;
rect.style.top = `${actor.pos.y * scale}px`;
return rect;
}));
}
Чтобы присвоить элементу несколько классов, мы используем пробелы для разделения имён классов. В следующем коде CSS класс actor
присваивает персонажу абсолютные координаты. Мы используем тип персонажа в качестве дополнительного класса CSS для настройки цвета этих элементов. Мы не определяем класс lava
снова, потому что можем напрямую повторно использовать правило, определённое ранее для ячеек лавы.
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
Метод setState
используется для отображения заданного состояния на экране. Сначала он удаляет графику старого персонажа, если она есть, затем перерисовывает персонажа на их новом месте. Попытка повторно использовать элемент DOM для персонажа может показаться привлекательной, но для того, чтобы это работало эффективно, нам нужно много дополнительной информации для сопоставления персонажей и элементов DOM и обеспечения удаления элементов, когда персонаж исчезает. Поскольку в игре обычно присутствует небольшое количество персонажей, перерисовка их не требует больших затрат.
DOMDisplay.prototype.setState = function(state) {
if (this.actorLayer) this.actorLayer.remove();
this.actorLayer = drawActors(state.actors);
this.dom.appendChild(this.actorLayer);
this.dom.className = `game ${state.status}`;
this.scrollPlayerIntoView(state);
};
Мы можем добавить текущее состояние уровня в качестве имени класса к обёртке, чтобы изменить стиль роли игрока в зависимости от результата игры. Нам нужно только добавить правила CSS, которые определяют стиль элементов player
, содержащихся в предке с определённым классом.
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
После столкновения с лавой цвет игрока должен стать тёмно-красным, указывая на то, что персонаж был обожжён. Когда игрок собирает последнюю монету, мы добавляем две размытые белые тени для создания эффекта белого кольца, одна в верхнем левом углу, другая в верхнем правом углу.
Мы не можем предположить, что уровень всегда соответствует размеру окна просмотра, которое мы используем для рисования элементов игры. Поэтому нам нужно вызвать scrollPlayerIntoView
, чтобы убедиться, что если уровень выходит за пределы окна просмотра, мы можем прокрутить окно просмотра так, чтобы игрок находился близко к центру окна просмотра. Следующий CSS устанавливает максимальный размер для элемента оболочки, чтобы гарантировать, что любой элемент, выходящий за пределы окна просмотра, невидим. Мы можем установить положение внешних элементов на relative
, поэтому элементы в этом элементе всегда позиционируются относительно верхнего левого угла уровня.
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
В методе scrollPlayerIntoView
мы находим позицию игрока и обновляем координаты прокрутки его элемента-обёртки. Мы можем изменить координаты прокрутки, манипулируя свойствами scrollLeft
и scrollTop
элемента, когда игрок приближается к границе окна просмотра.
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// The viewport
let left = this.dom.scrollLeft, right = left + width;
let top = this.dom.scrollTop, bottom = top + height;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5))
.times(scale);
if (center.x < left + margin) {
this.dom.scrollLeft = center.x - margin;
} else if (center.x > right - margin) {
this.dom.scrollLeft = center.x + margin - width;
}
if (center.y < top + margin) {
this.dom.scrollTop = center.y - margin;
} else if (center.y > bottom - margin) {
this.dom.scrollTop = center.y + margin - height;
}
};
Код, показывающий, как найти центр позиции игрока, демонстрирует, как мы можем использовать тип Vec
для написания относительно читаемого кода вычислений. Чтобы найти центр игрока, нам нужно добавить половину его размера к координатам левого верхнего угла. Результатом вычисления является центральная позиция координат уровня. Однако нам нужно умножить результат вектора на коэффициент отображения, чтобы преобразовать координаты в пиксельные.
Далее мы проводим серию проверок позиции игрока, чтобы убедиться, что его позиция не выходит за допустимые пределы. Здесь следует отметить, что этот код иногда всё равно будет устанавливать бессмысленные координаты прокрутки, такие как значения меньше 0 или значения, превышающие область прокрутки элемента. Это нормально. DOM изменит их на приемлемые значения. Если мы установим scrollLeft
на -10
, DOM изменит его на 0. Простейший способ — это каждый раз при перерисовке прокручивать экран, чтобы игрок всегда находился в центре экрана. Но такой подход может привести к сильному дрожанию изображения, а при прыжке игрока изображение будет постоянно перемещаться вверх и вниз.
Более разумный подход заключается в том, чтобы установить в центре экрана «центральную область», и при перемещении игрока внутри этой области не прокручивать экран.
Мы можем отображать небольшие уровни.
<link rel="stylesheet" href="css/game.css">
<script>
let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.setState(State.start(simpleLevel));
</script>
Теперь мы можем использовать rel="stylesheet"
в теге link
, чтобы загрузить файл CSS на страницу. Файл game.css
содержит стили, необходимые для нашей игры.
Пришло время добавить действия. Это самая захватывающая часть игры. Основной метод реализации действий (который используется в большинстве игр) состоит в разделении времени на отдельные временные интервалы, в зависимости от скорости персонажа и продолжительности времени, элементы перемещаются на определённое расстояние. Мы будем измерять время в секундах, поэтому скорость будет выражаться в единицах измерения за секунду.
Перемещение объектов очень просто. Более сложная часть — обработка взаимодействия между элементами. Когда игрок сталкивается со стеной или полом, он не может просто пройти сквозь них. Игра должна учитывать конкретные действия, которые могут вызвать столкновение двух объектов, и предпринимать соответствующие меры. Если игрок сталкивается с препятствием, он должен остановиться, если с монетой — собрать её.
Решение типичных проблем столкновения может быть сложной задачей. Вы можете найти библиотеки, которые мы называем физическими движками, они моделируют взаимодействие физических объектов в двумерном или трёхмерном пространстве. В этой главе мы используем более подходящий подход: обрабатываем только столкновения прямоугольных объектов и используем самый простой метод обработки.
При перемещении персонажа или лавового блока нам нужно проверить, не переместится ли элемент внутрь стены. Если это произойдёт, мы просто отменяем действие. Реакция на действие зависит от типа перемещаемого элемента. Если это игрок, то он останавливается, если лавовый блок — отскакивает назад.
Этот метод требует, чтобы интервал времени между шагами был достаточно коротким, чтобы можно было отменить действие до фактического столкновения объектов. Если интервал слишком велик, игрок в конечном итоге будет парить высоко над землёй. Другой метод явно лучше, но сложнее: найти точную точку столкновения и переместить элемент в эту позицию. Мы выберем самый простой вариант и обеспечим сокращение интервала между анимациями, чтобы скрыть проблему.
Метод используется для определения того, столкнётся ли определённый прямоугольник (ограниченный положением и размером) с заданным типом сетки.
Level.prototype.touches = function(pos, size, type) {
var xStart = Math.floor(pos.x);
var xEnd = Math.ceil(pos.x + size.x);
var yStart = Math.floor(pos.y);
var yEnd = Math.ceil(pos.y + size.y);
for (var y = yStart; y < yEnd; y++) {
for (var x = xStart; x < xEnd; x++) {
let isOutside = x < 0 || x >= this.width ||
y < 0 || y >= this.height;
let here = isOutside ? "wall" : this.rows[y][x];
if (here == type) return true;
}
}
return false;
};
В этом методе используются Math.floor
и Math.ceil
для вычисления набора перекрывающихся сеток блоков с телом. Помните, что размер сетки равен 1x1
единицам. Переворачивая коробку вверх дном, мы получаем диапазон блоков фона, с которыми коробка соприкасается.
Мы просматриваем координаты, чтобы найти совпадающие блоки, и возвращаем true
, когда находим совпадение. Блоки за пределами уровня всегда считаются "wall"
, чтобы гарантировать, что игрок не сможет покинуть этот мир, и мы случайно не попытаемся прочитать за пределами границ нашего массива rows
.
Метод update
состояния использует touches
, чтобы определить, касается ли игрок лавы.
State.prototype.update = function(time, keys) {
let actors = this.actors
.map(actor => actor.update(time, this, keys));
let newState = new State(this.level, actors, this.status);
if (newState.status != "playing") return newState;
let player = newState.player;
if (this.level.touches(player.pos, player.size, "lava")) {
return new State(this.level, actors, "lost");
}
for (let actor of actors) {
if (actor != player && overlap(actor, player)) {
newState = actor.collide(newState);
}
}
return newState;
};
Он принимает временной шаг и структуру данных, сообщающую ему, какие клавиши были нажаты. Первое, что он делает, это вызывает метод update
всех персонажей, создавая набор обновлённых персонажей. Персонажи также получают временной шаг, клавиши и состояние, чтобы они могли обновлять себя на основе этих данных. Только игрок будет читать клавиши, потому что это единственный персонаж, управляемый клавиатурой.
Если игра уже закончилась, нет необходимости в дальнейшей обработке (игра не может выиграть после проигрыша, и наоборот). В противном случае этот метод проверяет, касается ли игрок фона лавой. Если да, игра проиграна, и всё кончено. Наконец, если игра фактически продолжается, она проверяет, сталкиваются ли другие игроки с игроком.
Функция overlap
определяет, сталкиваются ли персонажи. Ему нужны два объекта персонажей, и он возвращает true
при столкновении, когда они перекрываются по осям X
и Y
.
function overlap(actor1, actor2) {
return actor1.pos.x + actor1.size.x > actor2.pos.x &&
actor1.pos.x < actor2.pos.x + actor2.size.x &&
actor1.pos.y + actor1.size.y > actor2.pos.y &&
actor1.pos.y < actor2.pos.y + actor2.size.y;
}
Если какой-либо персонаж сталкивается, его метод collide
имеет возможность обновить состояние. Столкновение с лавой устанавливает состояние игры на "lost"
, а столкновение с монетой приводит к исчезновению монеты, и когда это последняя монета, состояние становится "won"
.
Lava.prototype.collide = function(state) {
return new State(state.level, state.actors, "lost");
};
Coin.prototype.collide = function(state) {
let filtered = state.actors.filter(a => a != this);
let status = state.status;
if (!filtered.some(a => a.type == "coin")) status = "won";
return new State(state.level, filtered, status);
};
``` **Потому что мы возвращаем `runLevel` как `Promise`, `runGame` можно написать с использованием функции `async`, как видно в главе 11.** Она возвращает другое `Promise`, которое разрешается, когда игрок завершает игру.
В привязке `GAME_LEVELS` для песочницы в [этой главе](https://eloquentjavascript.net/code#16) есть набор доступных уровней игры. Эта страница предоставляет их `runGame`, запуская фактическую игру:
```html
<link rel="stylesheet" href="css/game.css">
<body>
<script>
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
Как правило, в платформенных играх у игрока есть ограниченное количество жизней, и каждая смерть отнимает одну жизнь. Когда жизни игрока заканчиваются, игра начинается заново.
Измените runGame
, чтобы реализовать систему жизней. Игрок начинает с 3 жизней. При каждом запуске выводите текущее количество жизней (используя console.log
).
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// Старая функция runGame. Измените её...
async function runGame(plans, Display) {
for (let level = 0; level < plans.length;) {
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
}
console.log("Вы выиграли!");
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
Теперь реализуйте функцию — когда пользователь нажимает клавишу ESC, игру можно приостановить или продолжить.
Мы можем изменить функцию runLevel
, используя другой обработчик событий клавиатуры, чтобы прерывать или возобновлять анимацию, когда игрок нажимает ESC.
На первый взгляд, runAnimation
не может выполнить эту задачу, но если мы используем runLevel
для реорганизации стратегии планирования, это также возможно.
После завершения этой функции вы можете попробовать добавить ещё одну функцию. В настоящее время метод регистрации обработчиков событий клавиатуры имеет некоторые проблемы. Сейчас объект arrows
является глобальной привязкой, и обработчики событий остаются активными, даже если игра не запущена. Мы называем это утечкой системы. Расширьте tracKeys
, предоставив способ отмены обработчика событий, затем измените runLevel
, чтобы зарегистрировать обработчик событий при запуске игры и отменить его после завершения игры.
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// Старую функцию runLevel нужно изменить...
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
return new Promise(resolve => {
runAnimation(time => {
state = state.update(time, arrowKeys);
display.setState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
Это традиционная платформенная игра, в которой есть враги, которых можно перепрыгнуть. Это упражнение требует от вас добавить этот тип персонажа в игру.
Мы называем их монстрами. Монстры могут двигаться только горизонтально. Вы можете заставить их двигаться в направлении игрока или прыгать вперёд-назад, как горизонтальный лавовый поток, или иметь любой режим движения, который вы хотите. Этот класс не должен обрабатывать падение, но он должен гарантировать, что монстры не пройдут сквозь стены.
Когда монстр сталкивается с игроком, эффект зависит от того, прыгает ли игрок на монстра. Вы можете приблизительно определить это, проверив, приближается ли нижняя часть игрока к верхней части монстра. Если это так, монстр исчезает. Если нет, игра проиграна.
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>
<body>
<script>
// Завершите конструктор, методы обновления и столкновения
class Monster {
constructor(pos, /* ... */) {}
get type() { return "monster"; }
static create(pos) {
return new Monster(pos.plus(new Vec(0, -1)));
}
update(time, state) {}
collide(state) {}
}
Monster.prototype.size = new Vec(1.2, 2);
levelChars["M"] = Monster;
runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................`), DOMDisplay);
</script>
</body>
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )