написать впереди
Эта статья поможет вам пройти игру Super Mario другого типа.онлайн-адрес, в игре всего 5 уровней, пожалуйста, сообщите общее время, которое вы потратили на прохождение первых двух уровней, в области комментариев, ха-ха~
представлять
Интерфейс игры примерно такой:
Маленькие черные квадраты представляют игрока, и его задача — собрать все золотые монеты на уровне, избегая при этом лавы. Когда будет собрана последняя золотая монета, уровень будет пройден.
Как это сделать?
Игроки перемещаются с помощью клавиш вверх, вниз, влево и вправо на клавиатуре. Итак, давайте начнем наше игровое путешествие, не так ли?
моделирование
Определите уровень
Прежде всего, нам нужен способ представления нашей карты, и этот способ должен быть как можно более простым и понятным.В этой статье мы используем большую строку для ее представления.Например, относительно простая карта может выглядеть так это:
let simplePlan =
`......................
..#................#..
..#..............=.#..
..#.........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";
});
});
}
}
Сначала нам нужно удалить начальные и конечные пробелы. Поскольку наша карта представляет собой двухмерную модель, при ее разборе мы можем анализировать ее построчно и, конечно же, столбец за столбцом. Каждый раз, когда встречается новая строка, мы помещаем текущую строку в массив и, наконец, генерируем массив строк. Прочитав этот массив строк, мы можем узнать высоту и ширину уровня.
Нам также необходимо различать статические и динамические элементы. Потому что у динамических элементов есть дополнительные дополнительные свойства, такие как: скорость движения, начальное положение, текущее положение и так далее. Итак, нам нужно определить класс для каждого динамического элемента (определяемого как Мэри), и класс должен иметь статический метод для инициализации элемента для добавления вstartActors
середина.
При этом нам нужно абстрагировать каждый элемент на карте в код:
const levelChars = {
".": "empty",
"#": "wall",
"+": "lava",
"@": Player,
"o": Coin,
"=": Lava,
"|": Lava,
"v": Lava
}
По ходу игры Мэри окажется в разных местах и даже исчезнет (как золотые монеты). Итак, нам нужны постоянные данные.
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');
}
}
После окончания игры значение статуса изменится на выигрыш или проигрыш.
Дайте определение Мэри
Мэри представляет текущую позицию и состояние подвижных элементов на уровне. Все Мэри будут иметь одинаковый интерфейс.pos
Атрибут представляет точку координат относительно левого верхнего угла (0,0),size
Представляет его размер (ширину и высоту). В то же время у них естьupdate
метод используется для вычисления нового состояния и положения с учетом текущего временного шага. Например, после того, как игрок нажмет клавиши вверх, вниз, влево и вправо, ему нужно вернуться в новую позицию и состояние.
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);
}
}
Разные Марии будут иметь разные проявления, поэтому в дополнение к координатам должны быть другие способы указать на их различное поведение.
Кроме того, нам также необходимо иметьtype
атрибут, который используется для идентификации того, что Мэриcoin
,player
илиlava
. Их соответствующие размеры устанавливаются через атрибут type.
-
Игрок
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);
В нашей игре квадрат равен 1*1, а высота игрока установлена равной 1 квадрату половинной высоты, поэтому координата y начальной позиции должна быть вычтена на 0,5.
-
Лава Лава
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);
-
Золотая монета
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)
карта
Теперь мы можем начать рисовать карту, чтобы элементы уровня отображались статично.
Мы определяем масштаб элементов на карте как 1:20. Поэтому мы устанавливаемscale
Для 20. Нам нужно хранить статические элементы и динамические элементы отдельно, потому что статические элементы будут выполняться только один раз, а динамические элементы будут всегда изменяться, поэтому мы определяемactorLayer
хранить Мэри.
function elt(name, attrs, ...children) {
let dom = document.createElement(name);
for (let attr of Object.keys(attrs)) {
dom.setAttribute(attr, attrs[attr]);
}
for (let child of children) {
dom.appendChild(child);
}
return dom;
}
class DOMDisplay {
constructor(parent, level) {
this.dom = elt("div", {class: "game"}, drawGrid(level));
this.actorLayer = null;
parent.appendChild(this.dom);
}
clear() { this.dom.remove(); }
}
const scale = 20;
function drawGrid(level) {
return elt("table", {
class: "background",
style: `width: ${level.width * scale}px`
}, ...level.rows.map(row =>
elt("tr", {style: `height: ${scale}px`},
...row.map(type => elt("td", {class: type})))
));
}
Теперь нам просто нужно добавить стили для каждого статического элемента в таблицу стилей.
.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; }
Увидев, что наша карта вышла, мы начнем рисовать Мэри.
рисовать Мэри
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;
}));
}
Мы убрали этих Мэри с пути с абсолютным позиционированием束缚
, и установить разные стили для разных Мэри.
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
Поскольку Мэри движется, нам нужно удалять старое состояние каждый раз, когда мы рисуем новое состояние.
DOMDisplay.prototype.syncState = function(state) {
if (this.actorLayer) this.actorLayer.remove();
this.actorLayer = drawActors(state.actors);
this.dom.appendChild(this.actorLayer);
this.dom.className = `game ${state.status}`;
};
Хорошо, на данный момент все элементы на нашей карте нарисованы. Запустим демо:
let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;
let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.syncState(State.start(simpleLevel));
движение и столкновение
Когда мы рисуем статические элементы, нам нужно позволить им работать, и нужно быть интерактивным. Поскольку наш текущий уровень не всегда может быть полон текущих видов экрана, вид на мобильную текущую карту игрока необходимо изменить. Когда игрок слишком близко к краю вида,
В это время нам нужно настроить элементы.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;
}
};
DOMDisplay.prototype.syncState = function(state) {
// ...
this.scrollPlayerIntoView(state);
};
Самое хлопотное здесь — взаимодействие между Марией. Например: игрок сталкивается со стеной, он не может пройти через нее и должен перестать двигаться. После того, как золотые монеты съедены, они должны исчезнуть. Если вы столкнулись с лавой, объявите игру проигранной и начните заново. Однако в Интернете есть похожие физические движки, которые могут помочь нам решить проблемы столкновений в двухмерном или трехмерном пространстве.
Мы можем сделать простой механизм обнаружения столкновений: когда мы двигаем игрока или движется лава, если следующий шаг пройдет сквозь стену, то мы отменяем его действие.
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;
};
Он используется для определения того, коснулся ли игрок лавы, путем добавления метода обновления к состоянию.
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;
};
Если обнаружено прикосновение к лаве или съеденное золото, то нам нужно вернуть результат поражения и победы.
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);
};
Обновление Мэри
С помощью теста на столкновение мы можем нарисовать каждую операцию обновления Мэри. Например: золотая монета будет прыгать вверх-вниз, а лава будет течь. Игроки могут свободно перемещаться.
-
Обновление монет
const wobbleSpeed = 8, wobbleDist = 0.07; Coin.prototype.update = function(time) { let wobble = this.wobble + time * wobbleSpeed; let wobblePos = Math.sin(wobble) * wobbleDist; return new Coin(this.basePos.plus(new Vec(0, wobblePos)), this.basePos, wobble); };
-
Обновление лавы
Положение лавы в методе обновления определяется временным шагом и скоростью. Если в следующей локации нет препятствий (стен), то лава перемещается в новую локацию. Если в следующей позиции есть препятствие. Тогда его следующее состояние зависит от типа лавы. Например, капающая лава начнет сначала, когда встретит препятствие, а лава, движущаяся вверх и вниз, умножит свою скорость на -1 и будет двигаться в противоположном направлении.
Lava.prototype.update = function(time, state) { let newPos = this.pos.plus(this.speed.times(time)); if (!state.level.touches(newPos, this.size, "wall")) { return new Lava(newPos, this.speed, this.reset); } else if (this.reset) { return new Lava(this.reset, this.speed, this.reset); } else { return new Lava(this.pos, this.speed.times(-1)); } };
-
обновление игрока
const playerXSpeed = 7; // 速度 const gravity = 30; // 重力系数 const jumpSpeed = 17; // 跳的高度 Player.prototype.update = function(time, state, keys) { let xSpeed = 0; if (keys.ArrowLeft) xSpeed -= playerXSpeed; if (keys.ArrowRight) xSpeed += playerXSpeed; let pos = this.pos; let movedX = pos.plus(new Vec(xSpeed * time, 0)); if (!state.level.touches(movedX, this.size, "wall")) { pos = movedX; } let ySpeed = this.speed.y + time * gravity; let movedY = pos.plus(new Vec(0, ySpeed * time)); if (!state.level.touches(movedY, this.size, "wall")) { pos = movedY; } else if (keys.ArrowUp && ySpeed > 0) { ySpeed = -jumpSpeed; } else { ySpeed = 0; } return new Player(pos, new Vec(xSpeed, ySpeed)); };
отслеживать нажатия клавиш
Когда мы нарисовали на карте всех Марий, нам нужны события для их запуска. Поэтому нам нужно следить за клавишами со стрелками на клавиатуре, чтобы управлять движением игрока.
function trackKeys(keys) {
let down = Object.create(null);
function track(event) {
if (keys.includes(event.key)) {
down[event.key] = event.type == "keydown";
event.preventDefault();
}
}
window.addEventListener("keydown", track);
window.addEventListener("keyup", track);
return down;
}
const arrowKeys =
trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
Пусть игра работает
Определите функцию выполнения входа в игру
Предположим, что в игре много уровней. Поэтому определите планы как массив уровней. Если текущий уровень пройден, то переходим на следующий уровень.
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("You've won!");
}
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.syncState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
function runAnimation(frameFunc) {
let lastTime = null;
function frame(time) {
if (lastTime != null) {
let timeStep = Math.min(time - lastTime, 100) / 1000;
if (frameFunc(timeStep) === false) return;
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
Ok! Пока наша игра окончена! иди и беги
конец
В будущем я планирую превратить эти абстрактные объекты в настоящие объекты на холсте, чтобы улучшить впечатления! Продолжение следует....