Chapter 15
Проект: Платформа игра

Цялата реалност е една игра.”

Iain Banks, The Player of Games

Моят първи интерес към компютрите, както и на много деца, възникна с компютърните игри. Бях очарован от мъничките компютърно симулирани светове, които мога да манипулирам и чиито истории се развиваха, предполагам повече поради начина , по който въображението ми можеше да проектира, отколкото заради възможностите, които те действително предлагаха.

Не бих препоръчал кариера в програмиране на игри на никого. Много прилича на музикалната индустрия, несъответствието между нетърпеливи млади хора, които искат да работят в нея и реалното търсене на такива хора създава доста нездравословна среда. Но написването на игри за забавление е забавно.

В тази глава ще направим една проста игра платформа. Платформа игрите (или “jump and run” игри) са, както очаква играча, движение на една фигура през свят, който често е двуизмерен и се гледа от страни, и прави скокове върху или над неща.

Играта

Нашата игра ще бъде приблизително базирана на Dark Blue от Thomas Palef. Аз избрах тази игра, защото тя е едновременно забавна и малка, и може да бъде построена без прекалено много код. Тя изглежда така:

The game Dark Blue

Тъмната кутия представлява играчът, чиято задача е да събира жълти кутии (монети), като избягва червените неща (лава). Нивото е минато, когато всички монети са събрани.

Играчът може да се разхожда с левия и десния клавиш със стрелки и скача със стрелката на горе. Скока е специалитет на тази игра. Той може да достигне няколко пъти собствената си височина и е в състояние да промени посоката си във въздуха. Това може да не е напълно реалистично, но помага на играча за усещането, че е в пряк контрол с аватара на екрана.

Играта се състои от фиксиран фон, изложен под формата на решетка, с движещи се елементи върху него. Всяко поле на решетка е празно, твърдо или лава. За разлика от симулацията на изкуствения живот в Глава 7, позициите на тези елементи на са ограничени на мрежата, координатите им могат да бъдат дробни числа позволявайки плавно движение.

Технология

Ние ще използваме DOM на браузъра за да изведем играта на екрана и ще отчетем приноса на потребителите чрез манипулиране на клавишни събития.

Екран и клавиатура свързани с кода е само една малка част от работата, която трябва да направим за изграждането на играта. Тъй като всичко изглежда, като цветни кутии рисунката не е сложна: ще създадем DOM елементи и използвайки стилизация ще им дадем фонов цвят, размер и позиция.

Можем да представим фона във вид на таблица, тъй като е неизменна мрежа от квадрати. Свободно движещите се елементи могат да бъдат наслагвани върху нея, използвайки абсолютно позиционирани елементи.

В игри и други програми, които имат анимиране на графики и взаимодействие с потребители без да се забелязва забавяне, ефективността е много важна. Въпреки, че DOM не бил първоначално проектиран за графики с висока производителност, той всъщност е по-добър в това, отколкото очакваме. Ние видяхме няколко анимации в Глава 13. На една модерна машина, проста игра, като тази се представя добре дори и ако не се задълбочим в оптимизирането много.

В следващата глава ще разгледаме друга браузър технология - <canvas> таг, който осигурява по-традиционен начин за чертане на графики, които работят по отношение на форми и пиксели, а не с DOM елементи.

Нива

В Глава 7 използвахме масиви от strings, за да опишем двуизмерна решетка. Ние можем да направим същото тук. Това ще ни позволи да проектираме нива без да изграждаме редактор за ниво.

Едно просто ниво ще изглежда така:

var simpleLevelPlan = [
  "                      ",
  "                      ",
  "  x              = x  ",
  "  x         o o    x  ",
  "  x @      xxxxx   x  ",
  "  xxxxx            x  ",
  "      x!!!!!!!!!!!!x  ",
  "      xxxxxxxxxxxxxx  ",
  "                      "
];

И двете: фиксираната мрежа и движещите се елементи ще бъдат включени в плана. Характера x е за стени, характера space е за празно пространство, удивителните знаци ! (nonmoving) представляват лава.

Знакът @ определя мястото, където играчът започва. Всяко o е монета, а знака за равенство (=) означава блок от лава, която се движи напред-назад хризонтално. Имайте в предвид, че мрежата за тези позиции ще бъде настроена да съдържа празно пространство, а друга структура от данни ще се използва за проследяване на положението на движещите се елементи.

Ще поддържаме още два вида движение на лава: характера (|), който ще представлява тръба за вертикално изригване на лава и v също за вертикално капене на лава, която не се мести напред-назад, а се движи само на долу и удряйки пода скача обратно в стартовата си позиция.

Цялата игра се състои от няколко нива, които играчът трябва да мине. Нивото е минато, когато всички монети са събрани. Ако играчът докосне лавата той умира и сегашното ниво се възстановява до началното си положение, където играчът може да опита отново.

Четене на ниво

Следната конструкция изражда обекта на нивото. Нейният аргумент трябва да бъде масив от strings, който определя нивото.

function Level(plan) {
  this.width = plan[0].length;
  this.height = plan.length;
  this.grid = [];
  this.actors = [];

  for (var y = 0; y < this.height; y++) {
    var line = plan[y], gridLine = [];
    for (var x = 0; x < this.width; x++) {
      var ch = line[x], fieldType = null;
      var Actor = actorChars[ch];
      if (Actor)
        this.actors.push(new Actor(new Vector(x, y), ch));
      else if (ch == "x")
        fieldType = "wall";
      else if (ch == "!")
        fieldType = "lava";
      gridLine.push(fieldType);
    }
    this.grid.push(gridLine);
  }

  this.player = this.actors.filter(function(actor) {
    return actor.type == "player";
  })[0];
  this.status = this.finishDelay = null;
}

За краткост, кода не се проверява за неправилен вход. Това предполага, че сме му дали завършен подходящ план за ниво със стартова позиция на играча и други важни неща.

Нивото съхранява своята ширина и висичина, както и два масива - един за мрежата и един за актьорите, които са динамични елементи. Решетката е представена, като масив от масиви, където всеки от вътрешните масиви представлява хоризонтална линия и всеки квадрат съдържа или нула за празен квадрат или string за вида на пълния квадрат "wall" или "lava".

Масивът за актьорите съдържа обекти, които следят текущата позиция и състояние на динамичните елементи в нивото. Всеки от тях очаква да има: pos свойство, което дава позиция (координатите на неговата позиция спрямо горния ляв ъгъл), size свойството, което дава неговия размер и type свойство, което притежава string идентифициращ елементите ("lava", "coin" и "player").

След изграждането на мрежата, ние използваме filter метод за намиране на обекта на играча, който съхраняваме в свойството на нивото. А status свойството следи дали играчът е спечелил или загубил. Когато това се случи, finishDelay се използва да подържа нивото активно за кратък период от време, така да може да се покаже една проста анимация. (Незабавно възстановяване или излизане от нивото ще изглежда евтино.) Този метод може да се използва за да се разбере дали нивото е минато.

Level.prototype.isFinished = function() {
  return this.status != null && this.finishDelay < 0;
};

Актьори

За съхраняване на положението и размерите на един актьор, ние ще се върнем към нашия надежден Vector тип, с неговите групи от х и у координати в обекта.

function Vector(x, y) {
  this.x = x; this.y = y;
}
Vector.prototype.plus = function(other) {
  return new Vector(this.x + other.x, this.y + other.y);
};
Vector.prototype.times = function(factor) {
  return new Vector(this.x * factor, this.y * factor);
};

Метода times скалира вектора по дадената сума. Това ще бъде полезно, когато трябва да се умножи скоростта на вектора по интервала от време, за да получим изминатото разстояние през това време

В предишния раздел, обекта actorChars се използва от Level конструктора да асоциира характери с конструктор функции. Обекта изглежда по следния начин:

var actorChars = {
  "@": Player,
  "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

Последните три характера са карта на лавата. The Level конструктора подава характера на актьора, като втори аргумент към конструктора и Lava конструктора използва това, за да коригира своето поведение (вертикално или хоризонтално подскачане или капене).

Типът на играча е изграден със следната конструкция. Той разполага със свойството speed, където съхранява сегашната скорост, която ще ни помогне да симулираме инерция и гравитация.

function Player(pos) {
  this.pos = pos.plus(new Vector(0, -0.5));
  this.size = new Vector(0.8, 1.5);
  this.speed = new Vector(0, 0);
}
Player.prototype.type = "player";

Играча е висок един и половина квадрата и първоначалната му позиция е половин квадрат над позицията, където се появява характера @. По този начин, долната му част се подравнява с дъното на квадрата, в който се появява.

При изграждането на динамичен Lava обект, ние трябва да инициализираме обекта по различен начин в зависимост от характера, на който се основава. Динамичната лава се движи сама в зависимост от дадената и скорост, докато не срещне препятствие. В този момент, ако има repeatPos свойство, тя ще скочи обратно към своята начална позиция (при капене). Ако това не стане, тя ще обърне скоростта и ще продължи в друга посока (изригване). Конструктора само определя необходимите свойства. Методът, който прави действителното преместване ще бъде написан по-късно.

function Lava(pos, ch) {
  this.pos = pos;
  this.size = new Vector(1, 1);
  if (ch == "=") {
    this.speed = new Vector(2, 0);
  } else if (ch == "|") {
    this.speed = new Vector(0, 2);
  } else if (ch == "v") {
    this.speed = new Vector(0, 3);
    this.repeatPos = pos;
  }
}
Lava.prototype.type = "lava";

Coin актьорите са прости. Те просто стоят на мястото си. Но за да оживим малко играта ще им е дадем леко потрепване, леко движение напред и назад. За да проследим това, обекта на монетата съхранява базова позиция, както и wobble свойство, което следи фазата на трептящото движение. Заедно те определят действителното положение на монетата (съхранено в pos свойството).

function Coin(pos) {
  this.basePos = this.pos = pos.plus(new Vector(0.2, 0.1));
  this.size = new Vector(0.6, 0.6);
  this.wobble = Math.random() * Math.PI * 2;
}
Coin.prototype.type = "coin";

В Глава 13 видяхме, че Math.sin ни два у-координата на точка от окръжност. С нея можем да координираме местенето назад и на пред в плавната форма на вълна и тъй като ще се движи в кръг, прави задължително функцията да моделира вълнообразно движение.

За да се избегне ситуация, в която всички монети се движат нагоре и надолу синхронизирано, началният етап за всяка монета ще бъде рандомизиран. Фазата на Math.sin вълната (ширината на една вълна) е 2π. Ние умножаваме стойността върната от Math.random по това число, за да даде на монетата случайна стартова позиция на вълната.

Сега сме написали всички необходими части за да представим размерите на нивото.

var simpleLevel = new Level(simpleLevelPlan);
console.log(simpleLevel.width, "by", simpleLevel.height);
// → 22 by 9

Задачата напред е да покажем тези нива на екрана и да моделираме движението и времето вътре в тях.

Капслулирането като бреме

Повечето от кода в тази глава не се тревожи за капсулирането по две причини. Първо, капсулирането изисква допълнително усилие. Това прави програмите по-големи и изисква допълнителни концепции и интерфейси, които трябва да бъдат въведени. Понеже може да се създаде толкова много код, който един читател трябва да прочете, докато му се изцъклят очите, съм направил опит да запазя програмата малка.

Второ различните елементи в тази игра са толкова тясно свързани заедно, че ако поведението на един от тях се промени, това е малко вероятно, то другите едва ли ще останат същите. Интерфейсите между елементите ще се окажат пред кодиране на много предположения за начина, по който играта работи. Това ги прави много по-малко ефективни, когато се промени една част от системата и все още трябва да се притесняваме за начина, по който тя влияе на другите части, защото техните интерфейси не биха обхванали новата ситуация.

Някои отрязани точки в една система се поддават добре на разделяне чрез строги интерфейси, но други не. Опитвайки се да капсулирате нещо, което не е с подходяща граница е сигурен начин да изгубите много енергия. Когато направите тази грешка, обикновено ще забележите, че вашите интерфейси стават опасно големи и подробни и че те трябва да бъдат променяни често, тъй като програмата се развива.

Има едно нещо, което ние ще капсулираме в тази глава и това е подсистемата за рисуване. Причината за това е, че ще покаже същата игра по различен начин в следващата глава. С пускането на рисунката зад интерфейса, ние просто ще заредим една и съща програма игра, включена в нов дисплей модул.

Чертеж

Капсулирането на кода за рисуване се извършва чрез определяне на обекта на дисплея, в който се показва дадено ниво. Типа на дисплея, който определяме в тази глава ще се казва DOMDisplay, защото използва DOM елементи за да покаже нивото.

Ние ще използваме style за да определим действителните цветове и други фиксирани свойства на елементите, които съставят играта. Също така, би било възможно директно да задаваме на елементите style свойства, когато ги създаваме, но това ще произведе повече многословни програми.

Следната помощна функция осигурява кратък начин за създаване на един елемент и даването му на клас.

function elt(name, className) {
  var elt = document.createElement(name);
  if (className) elt.className = className;
  return elt;
}

Дисплея е създаден, като родителски елемент, към който трябва да добави себе си и обект за ниво.

function DOMDisplay(parent, level) {
  this.wrap = parent.appendChild(elt("div", "game"));
  this.level = level;

  this.wrap.appendChild(this.drawBackground());
  this.actorLayer = null;
  this.drawFrame();
}

Ние използваме факта, че appendChild връща приложения елемент за да създаде обвивка на елемент и го съхранява във wrap свойството в едно изявление.

Фона на нивото никога не се променя и се изготвя веднъж. Актьорите се преначертават всеки път, когато дисплея се актуализира. Свойството actorLayer ще бъде използвано от drawFrame за проследяване на елемента, който държи актьорите, така че те лесно да бъдат премахнати или заменени.

Нашите координати и размери се проследяват в единици по отношение на размера на мрежата, където размер или разстояние от 1 означава една решетка-единица. При определяне на размера на пикселите, ние ще трябва да мащабираме тези координати нагоре- иначе всичко в играта ще бъде абсурдно малко от едни пиксел на квадрат. Променливата scale дава броят на пикселите, който един единствен елемент заема на екрана.

var scale = 20;

DOMDisplay.prototype.drawBackground = function() {
  var table = elt("table", "background");
  table.style.width = this.level.width * scale + "px";
  this.level.grid.forEach(function(row) {
    var rowElt = table.appendChild(elt("tr"));
    rowElt.style.height = scale + "px";
    row.forEach(function(type) {
      rowElt.appendChild(elt("td", type));
    });
  });
  return table;
};

Както бе споменато по-рано задният план се съставя, като <table> елемент. Това добре съответства на структурата на grid свойството в нивото - всеки ред на решетката се превръща в табличен ред (<tr> елемент). Strings в мрежата се използват, като имена на класове за клетките от таблицата (<td> елементи). Следния 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;              }

Някои от тях, като (table-layout, border-spacing и padding) са използвани за подтискане на нежелано поведение по подразбиране. Ние не искаме оформлението на таблицата да зависи от съдържанието на неговите клетки, не искаме също пространство между клетките на таблицата или подплънка вътре в тях.

Правилото background задава цвета на фона. CSS позволява цветовете да бъдат определени, като думи (white, red и т.н.) и с формат, като rgb(R, G, B), където червеното, зеленото и синьото са компонентите на цвета разделени в три цифри от 0 до 255. Така че, в rgb(52, 166, 251) червеният компонент е 52, зеленият е 166, а синьото е 251. Синия компонент е най-големият и полученият цвят ще е синкав. Можете да видите, че в .lava правилото, първата цифра (червено) е най-голяма.

Ние чертаем всеки участник чрез създаване на DOM елемент за него и определяме позиция и размер за този елемент въз основа на свойствата на актьора. Стойностите трябва да бъдат умножени по scale за да преминем от единици към пиксели.

DOMDisplay.prototype.drawActors = function() {
  var wrap = elt("div");
  this.level.actors.forEach(function(actor) {
    var rect = wrap.appendChild(elt("div",
                                    "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 wrap;
};

За да дадем на един елемент повече от един клас, ние разделяме името на класа с интервали. В кода на CSS показан след малко, actor класа дава на актьорите абсолютно положение. Техният тип име се използва, като допълнителен клас за да им даде цвят. Ние не трябва да дефинираме отново lava класа, защото повторно ще използваме класа за лава квадратите в мрежата, които дефинирахме по-рано.

.actor  { position: absolute;            }
.coin   { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64);   }

Когато се актуализира дисплея, drawFrame метода първо премахва старите графики на актьорите, ако има такива и след това начертава нови в новите им позиции. Можем да се изкушим да използваме повторно DOM елементи за актьори, но за да направим тази работа ще ни трябва поток от много допълнителна информация между кода на дисплея и кода на симулацията. Също ще трябва да обединим актьорите с DOM елементи, а кода на чертежа трябва да отстрани елементите, когато техните участници изчезнат. Тъй като, ще има само една шепа участници в играта, преначертаването на всички тях не е скъпо.

DOMDisplay.prototype.drawFrame = function() {
  if (this.actorLayer)
    this.wrap.removeChild(this.actorLayer);
  this.actorLayer = this.wrap.appendChild(this.drawActors());
  this.wrap.className = "game " + (this.level.status || "");
  this.scrollPlayerIntoView();
};

Чрез добавяне на текущото състояние на нивото, като име на класа на обвивката, можем да оформим играчите малко по-различно, когато играча печели или губи чрез добавяне на CSS правило, което влиза в сила само, когато играча има родител елемент с дадения клас.

.lost .player {
  background: rgb(160, 64, 64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}

След докосване на лава, цвета на играча се променя в тъмно червено, което предполага изгаряне. Когато и последната монета е събрана, ще използваме две сенки на замъглени бели кутии, една горе в ляво и една горе в дясно, за да създадем ефект на бял ореол.

Ние не можем да приемем, че нивата винаги се побират в демонстрационния прозорец. Ето защо ни е необходим scrollPlayerIntoView, който гарантира, че ако едно ниво излезе извън рамката, скрола на рамката ще се грижи играча да бъде винаги в нейният център. Следният CSS дава на обвивката на играта в DOM елемента, максимален размер и гарантира, че всичко, което е извън тази кутия не се вижда. Ние също така даваме на външния елемент относителна позиция, така че актьорите в него да са позиционирани спрямо горния ляв ъгъл на нивото.

.game {
  overflow: hidden;
  max-width: 600px;
  max-height: 450px;
  position: relative;
}

В scrollPlayerIntoView метода, откриваме позицията на играча и актуализираме позицията на скрола на елемента на обвивката. Сменяме позицията на скрола чрез манипулиране на този елемент с scrollLeft и scrollTop свойствата, когато играча е твърде близо до края.

DOMDisplay.prototype.scrollPlayerIntoView = function() {
  var width = this.wrap.clientWidth;
  var height = this.wrap.clientHeight;
  var margin = width / 3;

  // The viewport
  var left = this.wrap.scrollLeft, right = left + width;
  var top = this.wrap.scrollTop, bottom = top + height;

  var player = this.level.player;
  var center = player.pos.plus(player.size.times(0.5))
                 .times(scale);

  if (center.x < left + margin)
    this.wrap.scrollLeft = center.x - margin;
  else if (center.x > right - margin)
    this.wrap.scrollLeft = center.x + margin - width;
  if (center.y < top + margin)
    this.wrap.scrollTop = center.y - margin;
  else if (center.y > bottom - margin)
    this.wrap.scrollTop = center.y + margin - height;
};

Начинът, по който центърът на играча е показан, демонстрира как методите на нашия тип Vector позволяват изчисленията с обекти да бъдат писани по четим начин. За да намерим центъра на актьора, ние добавяме неговата позиция (горния ляв ъгъл) и половината му размер. Това е центърът на координатите на нивото, но имаме нужда от него в пиксели-координати, за да можем след това да умножим резултата от вектора по нашия мащаб на дисплея.

После, поредица проверки проверяват позицията на играча да не е извън допустимия диапазон. Имайте в предвид, че понякога това прави маса глупости с координатите на скрола, под нула или извън скролната зона на елемента. Добре, че DOM ги ограничава до нормални стойности. Настройване на scrollLeft към -10 ще го превърне в 0.

Щеше да е малко по-лесно да опитаме да местим играча винаги в центъра на демонстрационния прозорец. Но това създава шокиращ ефект. Когато скача, гледката непрекъснато се мести нагоре и надолу. Ще е по-приятно да има “неутрална” зона в средата на екрана, където можем да се движим без да причиняваме скролване.

И накрая, се нуждаем от начин за изчистване на показаното ниво, за да се използва, когато играта се движи към следващото ниво или нулира нивото.

DOMDisplay.prototype.clear = function() {
  this.wrap.parentNode.removeChild(this.wrap);
};

В момента сме в състояние да покажем нашето малко ниво.

<link rel="stylesheet" href="css/game.css">

<script>
  var simpleLevel = new Level(simpleLevelPlan);
  var display = new DOMDisplay(document.body, simpleLevel);
</script>

The <link> тага, когато се използва с rel="stylesheet" е начин да се зареди CSS файл в страницата. Файла game.css съдържа стиловете необходими за нашата игра.

Движение и сблъсък

Сега сме на мястото, където можем да започнем да добавяме движение, най-интересния аспект от играта. Основният подход, използван в повечето игри, е да се раздели времето на малки стъпки и всяка стъпка премества актьорите на разстояние съответстващо на тяхната скорост (преместване в секунда) умножено по размера на времето за стъпка (в секунди).

Това е лесно. Трудната част е взаимодействието между елементите. Когато играчът удари стена или под, той не може просто да се движи през тях. Играта трябва да забележи, когато дадено движение причинява удар на един обект с друг и да реагира по съответния начин. За стени движението трябва да бъде спряно. За монети, монетата трябва да бъде взета и т.н.

Решаването на тези общи случаи е голяма задача. Можете да намерите библиотеки, обикновено се наричат физични двигатели, които симулират взаимодействие между физически обекти в две или три измерения. Ще използваме по-скромен подход в тази глава, манипулиране единствено сблъсъците между правоъгълни предмети и обработката им по един доста опростен начин.

Преди да преместим играч или блок от лава, ние тестваме дали движението не идва от вътрешността на не-празна част на фона. Ако е така, просто спираме движението напълно. Отговорът на такъв сблъсък зависи от вида на актьора: играча ще спре, докато блока на лавата се съвземе.

Този подход изисква стъпките от време да бъдат по-скоро малки, тъй като това ще доведе до движение, което да спре преди обектите да се докоснат. Ако стъпките от време (и по този начин етапите на движение) са твърде големи, играчът ще се окаже в положение да лети на забележимо разстояние над земята. Друг подход, може би по-добър но по-сложен, е да се намери точното място на сблъсъка и да се премести там. Ние ще използваме простия подход и ще скрием проблемите чрез осигуряване на анимация в малки стъпки.

Този метод ни казва дали един правоъгълник (с посочена позиция и размер) се припокрива с всяко не-празно пространство на фона на мрежата.

Level.prototype.obstacleAt = function(pos, size) {
  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);

  if (xStart < 0 || xEnd > this.width || yStart < 0)
    return "wall";
  if (yEnd > this.height)
    return "lava";
  for (var y = yStart; y < yEnd; y++) {
    for (var x = xStart; x < xEnd; x++) {
      var fieldType = this.grid[y][x];
      if (fieldType) return fieldType;
    }
  }
};

Този метод изчислява набор от мрежови квадрати, с които тялото се препокрива, като използва Math.floor и Math.ceil за координатите на тялото. Не забравяйте, че мрежата от квадрати е с размер 1х1 единици. Чрез закръгляне страните на кутията на горе и на долу, ние получаваме обхвата на фоновите квадрати, които кутията докосва.

Finding collisions on a grid

Ако тялото стърчи извън нивото, ние винаги връщаме "wall" за страните и тавана и "lava" за дъното. Това гарантира, че играчът винаги умира, когато падне извън света. Когато тялото е точно в рамката на мрежата, ние циклим над блока от мрежови квадрати определени със закръгляне на координатите и връщаме съдържанието на първия не-празен квадрат, който открием

Сблъсъка между играча и други динамични актьори (монети, лава) се изпълнява, след като играча се премести. Когато движението на играча продължава навътре в друг актьор, се активира подходящ ефект - събиране на монета или умиране.

Този метод сканира масива от участници, като търси актьор, който се припокрива с този, който е даден, като аргумент.

Level.prototype.actorAt = function(actor) {
  for (var i = 0; i < this.actors.length; i++) {
    var other = this.actors[i];
    if (other != actor &&
        actor.pos.x + actor.size.x > other.pos.x &&
        actor.pos.x < other.pos.x + other.size.x &&
        actor.pos.y + actor.size.y > other.pos.y &&
        actor.pos.y < other.pos.y + other.size.y)
      return other;
  }
};

Актьори и действия

Метода animate на типа Level дава на всички участници в нивото шанс да се движат. Неговия аргумент step е стъпката на времето в секунди. Обекта keys съдържа информация за клавишите със стрелки за играча, когато са натиснати.

var maxStep = 0.05;

Level.prototype.animate = function(step, keys) {
  if (this.status != null)
    this.finishDelay -= step;

  while (step > 0) {
    var thisStep = Math.min(step, maxStep);
    this.actors.forEach(function(actor) {
      actor.act(thisStep, this, keys);
    }, this);
    step -= thisStep;
  }
};

Когато свойството status на нивото не е празна стойност (това е случаят, когато играчът е спечелил или загубил) трябва да отчетем резултата със свойството finishDelay, което е точно момента между точката на преминаване към мястото на победа или загуба и на мястото, където искаме да спре да се показва нивото.

While цикъла обхожда стъпките от време, които анимираме в подходящи малки парчета. Той гарантира, че няма взета стъпка по-голяма от maxStep. Например, една стъпка от 0.12 секунди ще бъде нарязана на две стъпки от по 0.05 и една стъпка от 0.02 секунди.

Обекта на актьорите има метод act, който приема за аргументи стъпка на времето, обекта ниво и keys обекта. Това е обекта на типа Lava, който игнорира keys обекта.

Lava.prototype.act = function(step, level) {
  var newPos = this.pos.plus(this.speed.times(step));
  if (!level.obstacleAt(newPos, this.size))
    this.pos = newPos;
  else if (this.repeatPos)
    this.pos = this.repeatPos;
  else
    this.speed = this.speed.times(-1);
};

Той изчислява нова позиция чрез добавяне на продукта на стъпка от времето и сегашната скорост от старата позиция. Ако няма пречка, която да блокира тази нова позиция, тя се движи на там. Ако има препятствие поведението зависи от вида на лава блока - капеща лава, която има свойство repeatPos, с което се връща обратно, когато удари нещо. Изригващата лава просто обръща скоростта (като се умножи по -1), за да започне да се движи в обратна посока.

Монетите използват своя act метод за да потрепват. Те пренебрегват сблъсъци, тъй като се клатушкат във вътрешноста на собствения си квадрат и сблъсъците ще се обработват от метода act на играча.

var wobbleSpeed = 8, wobbleDist = 0.07;

Coin.prototype.act = function(step) {
  this.wobble += step * wobbleSpeed;
  var wobblePos = Math.sin(this.wobble) * wobbleDist;
  this.pos = this.basePos.plus(new Vector(0, wobblePos));
};

Свойството wobble се актуализира за да следим времето и след това се използва, като аргумент за Math.sin за да създаде вълна, която се използва за изчисляване на нова позиция.

Остана само играча. Движението на играча се обработва отделно за всяка ос, защото когато удря пода, това не трябва да пречи на хоризонталното движение и когато удря стена, това не трябва спира движението на падане или скачане. следващия метод изпълнява хоризонталната част:

var playerXSpeed = 7;

Player.prototype.moveX = function(step, level, keys) {
  this.speed.x = 0;
  if (keys.left) this.speed.x -= playerXSpeed;
  if (keys.right) this.speed.x += playerXSpeed;

  var motion = new Vector(this.speed.x * step, 0);
  var newPos = this.pos.plus(motion);
  var obstacle = level.obstacleAt(newPos, this.size);
  if (obstacle)
    level.playerTouched(obstacle);
  else
    this.pos = newPos;
};

Хоризонталното движение се изчислява на базата на състоянието на левия и десния клавиш със стрелка. Когато едно движение е причина играча да се удари в нещо в нивото, метода playerTouched който се занимава с неща, като умиране в лава и събиране на монети се извиква. В противен случай обекта актуализира своята позиция.

Вертикалното движение работи по подобен начин, но трябва да симулира скок и гравитация.

var gravity = 30;
var jumpSpeed = 17;

Player.prototype.moveY = function(step, level, keys) {
  this.speed.y += step * gravity;
  var motion = new Vector(0, this.speed.y * step);
  var newPos = this.pos.plus(motion);
  var obstacle = level.obstacleAt(newPos, this.size);
  if (obstacle) {
    level.playerTouched(obstacle);
    if (keys.up && this.speed.y > 0)
      this.speed.y = -jumpSpeed;
    else
      this.speed.y = 0;
  } else {
    this.pos = newPos;
  }
};

В началото на метода, играча ускорява вертикално за отчитане на гравитацията. Гравитацията, скоростта на скока и почти всички останали константи в тази игра са определени с метода опит - грешка. Тествах различни стойности, докато открих комбинация, която да ми хареса.

След това проверяваме за пречки отново. Ако се ударим в нещо има два възможни изхода. Когато стрелката за нагоре е натисната и ние се движим надолу (което, означава, че нещото в което сме се ударили е под нас), скоростта е настроена към сравнително по-голяма отрицателна стойност. Това причинява скок на играча. Ако това не е така, той просто се е блъснал в нещо и скоростта се нулира.

Действителния act метод изглежда така:

Player.prototype.act = function(step, level, keys) {
  this.moveX(step, level, keys);
  this.moveY(step, level, keys);

  var otherActor = level.actorAt(this);
  if (otherActor)
    level.playerTouched(otherActor.type, otherActor);

  // Losing animation
  if (level.status == "lost") {
    this.pos.y += step;
    this.size.y -= step;
  }
};

След като се премести, следват проверките на метода за други участници, с които играча се сблъсква и отново извиква playerTouched, ако намери един. Този път той приема обекта на актьора, като втори аргумент, защото ако другия участник е монета, playerTouched трябва да знае, че монетите се събират.

И накрая, когато играчът умира (докосва лава), ще създадем малко анимация, която го кара да се “свие” или ”потъне”, като намалим височината на обекта на играча.

Тук е метода, който обработва сблъсъци между играча и други обекти:

Level.prototype.playerTouched = function(type, actor) {
  if (type == "lava" && this.status == null) {
    this.status = "lost";
    this.finishDelay = 1;
  } else if (type == "coin") {
    this.actors = this.actors.filter(function(other) {
      return other != actor;
    });
    if (!this.actors.some(function(actor) {
      return actor.type == "coin";
    })) {
      this.status = "won";
      this.finishDelay = 1;
    }
  }
};

Когато се докосне лава, статуса на играта е настроен на “загуба”. Когато се докосне до монета, тя се отстранява от масива с участници и ако тя е последна, статуса на играта е настроен на “победа”.

Това ни дава ниво, което действително може да бъде анимирано. Всичко, което липсва в момента е кодът, който задвижва анимацията.

Клавиши за проследяване

За игра, като тази ние не искаме действие на клавиши с еднократно натискане. Напротив, техният ефект (преместване на фигурата на играча) да продължи да се случва толкова дълго, докато те са натиснати.

Трябва да създадем основен манипулатор, който съхранява текущото състояние на стрелката за на ляво, на дясно и на горе. Също ще трябва да извикаме preventDefault за тези клавиши, така че да не останат натиснати при скролване на страницата.

Следната функция и е даден обект с клавишни кодове, като свойство с имена и клавишни имена, като стойности, въръща обект, който следи текущото положение на тези клавиши. Тя регистрира манипулатори на събития за събитията "keydown" и "keyup", когато клавишния код присъства в набора от кодове, които се проследяват и актуализира обекта.

var arrowCodes = {37: "left", 38: "up", 39: "right"};

function trackKeys(codes) {
  var pressed = Object.create(null);
  function handler(event) {
    if (codes.hasOwnProperty(event.keyCode)) {
      var down = event.type == "keydown";
      pressed[codes[event.keyCode]] = down;
      event.preventDefault();
    }
  }
  addEventListener("keydown", handler);
  addEventListener("keyup", handler);
  return pressed;
}

Забележете, че една и съща функция манипулатор се използва и за двата вида събития. Тя преглежда type свойството на обекта на събитието, за да определи дали клавишното състояние трябва да се актуализира с true за ("keydown") или с false за ("keyup").

Стартиране на играта

Функцията requestAnimationFrame, която видяхме в Глава 13, осигурява един добър начин за анимиране на играта. Но нейният интерфейс е доста примитивен - ако го използваме трябва да следим времето, в което нашата функция е била извикана последно и да я извикваме отново requestAnimationFrame след всеки кадър.

Нека да дефинираме помощна функция, която обвива тези скучни части в удобен интерфейс и ни позволява да я извикаме просто, като runAnimation, като и дадем функция, която очаква времевата разлика, като аргумент и връща един кадър. Когато функцията frame върне false, анимацията спира.

function runAnimation(frameFunc) {
  var lastTime = null;
  function frame(time) {
    var stop = false;
    if (lastTime != null) {
      var timeStep = Math.min(time - lastTime, 100) / 1000;
      stop = frameFunc(timeStep) === false;
    }
    lastTime = time;
    if (!stop)
      requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

Аз поставих максимална стъпка на кадъра от 100 милисекунди(една десета от секундата). Когато раздела на браузъра или прозореца с нашата страница е скрит, извикванията на requestAnimationFrame ще бъдат спрени, докато раздела или прозореца се появят отново. В този случай разликата между lastTime и timeще бъде цялото време, през което страницата е била скрита. Напредъка на играта с много единични стъпки ще изглежда глупаво и много натоварващо (не забравяйте, времевите отрязъци в метода на анимацията).

Функцията също превръща стъпките на времето в секунди, които са по-лесни, като величина, отколкото милисекунди.

Функцията runLevel взема от Level обекта, конструктора за дисплей и евентуално функция. Тя показва нивото(в document.body) и позволява на потребителя да играе в него. Когато нивото е завършено (с победа или загуба), runLevel изчиства дисплея, спира анимацията и ако andThen е дадената функция, извиква тази функция със статус на ниво.

var arrows = trackKeys(arrowCodes);

function runLevel(level, Display, andThen) {
  var display = new Display(document.body, level);
  runAnimation(function(step) {
    level.animate(step, arrows);
    display.drawFrame(step);
    if (level.isFinished()) {
      display.clear();
      if (andThen)
        andThen(level.status);
      return false;
    }
  });
}

Една игра е поредица от нива. Всеки път, когато играча умре сегашното ниво се рестартира. Когато нивото е завършено с победа, преминава към следващото ниво. Това може да се изрази със следната функция, която взема масив от планове на нива (масив от strings) и дисплей конструктор.

function runGame(plans, Display) {
  function startLevel(n) {
    runLevel(new Level(plans[n]), Display, function(status) {
      if (status == "lost")
        startLevel(n);
      else if (n < plans.length - 1)
        startLevel(n + 1);
      else
        console.log("You win!");
    });
  }
  startLevel(0);
}

Тази функция показва особен стил на програмиране. И двете runAnimation и runLevel са по-високо ниво функции, но не са в стила, който разгледахме в Глава 5. Функцията - аргумент се използва за да организира нещата да се случат някъде в бъдещето и нито една от функциите не връща нещо полезно. Тяхната задача е, в известен смисъл, да планират действията. Опаковането на тези действия във функции ни дава начин да ги съхраним, като стойност, така че да можем да ги извикаме в най-подходящия момент.

Този стил на програмиране обикновено се нарича асинхронно програмиране. Обработката на събития също е модел на този стил и ще видим много повече от това, когато работим със задачи, които могат да вземат произволно количество време, както например мрежови заявки в Глава 17 и вход и изход-генерално в Глава 20.

Има набор от планове за нива на разположение в GAME_LEVELS променливата. Тази страница подържа runGame, действителния старт на играта.

<link rel="stylesheet" href="css/game.css">

<body>
  <script>
    runGame(GAME_LEVELS, DOMDisplay);
  </script>
</body>

Вижте дали можете да победите. Беше ми доста забавно да я изградя.

Упражнения

Game over

Традиционна платформа за игри е играча да започне с ограничен брой животи и те се намалят всеки път, когато той умира. Когато му свършат животите играта се рестартира от начало.

Регулирайте runGame да приложи животите, като стартирате играча с три живота.

<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runGame function. Modify it...
  function runGame(plans, Display) {
    function startLevel(n) {
      runLevel(new Level(plans[n]), Display, function(status) {
        if (status == "lost")
          startLevel(n);
        else if (n < plans.length - 1)
          startLevel(n + 1);
        else
          console.log("You win!");
      });
    }
    startLevel(0);
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

Най-очевидното решение би било да се направи lives променлива, която живее в runGame и по този начин е видима за startLevel закриването.

Друг подход, който се вписва добре в духа на останалата част от функцията, ще бъде да се добави втори параметър, така че startLevel, който дава брой животи. Когато цялото състояние на системата се съхранява в аргументите на функцията, извикването на тази функция осигурява елегантен начин за преход към ново състояние.

Във всеки случай, когато нивото е загубено трябва да има два възможни изхода. Ако това е последният живот, трябва да се върнем към ниво нула с първоначален размер на живота. Ако не е, повтаряме сегашното ниво с един живот по-малко.

Пауза на играта

Направете възможно да има пауза (спиране) и отменяне на паузата на играта, чрез натискане на клавиш Esc.

Това може да стане чрез промяна на функцията runLevel да използва манипулатор за друго клавишно събитие и да прекъсва и възобновява анимацията, когато клавишът Esc е натиснат.

Интерфейса runAnimation не изглежда подходящ на пръв поглед за тази задача, но може да стане ако се промени начина му на извикване от runLevel.

Когато имате работещ начин, има нещо друго, което може да се опита. Начинът, по който са регистрирани манипулаторите на клавишните събития е малко проблемен. Обекта arrows в момента е глобална променлива и неговите манипулатори на събития се съхраняват дори, когато играта не се изпълнява. Може да се каже, че има изтичане от нашата система. Разширете trackKeys за да осигури начин да отпише своите манипулатори и след това променете runLevel да регистрира своите манипулатори, когато се стартира и да ги отписва отново, когато е завършена.

<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runLevel function. Modify this...
  function runLevel(level, Display, andThen) {
    var display = new Display(document.body, level);
    runAnimation(function(step) {
      level.animate(step, arrows);
      display.drawFrame(step);
      if (level.isFinished()) {
        display.clear();
        if (andThen)
          andThen(level.status);
        return false;
      }
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

Една анимация може да се прекъсне чрез връщане на false от функция дадена към runAnimation. И може да бъде продължена, като се извика runAnimation отново.

За да комуникират, че анимацията трябва да се прекъсне към функцията подадена към runAnimation, така че тя да може да върне false, можете да използвате променлива, като манипулатор на събитие и тази функция, която има достъп.

При търсенето на начин за отписване на манипулатори регистрирани от trackKeys, не забравяйте, че точно същата стойност на функция, която се подава на addEventListener трябва се подаде на removeEventListener за да премахне успешно манипулатор. По този начин handler стойността на функцията, създадена в trackKeys трябва да бъде на разположение на кода, който отписва манипулаторите.

Можете да добавите свойство към обекта, върнат от trackKeys съдържащ тази стойност на функцията или метод, който обработва отписването директно.