Глава 16
Рисуване върху платно

Drawing е измама.”

M.C. Escher, цитиран от Bruno Ernst в The Magic Mirror на Ешер

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

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

Има две алтернативи. Първата е DOM базирана, като използва Scalable Vector Graphics (SVG) вместо HTML елементи. Мислете за SVG, като диалект за описване на документи, който се фокусира върху форми, а не върху текст. Можете да вградите SVG документ в HTML документ или да го включите с <img> таг.

Втората алтернатива се нарича canvas (платно). Платното е единичен DOM елемент, който капсулира картина. То осигурява програмен интерфейс за изготвяне на форми в пространство взето от разклонение. Основната разлика между canvas и SVG, е че в SVG оригиналното описание на фигурите се запазва, така че те да могат да бъдат местени или преоразмерени по всяко време. Докато платното от друга страна преобразува фигурите в пиксели (цветни точки на изображението) и веднага след, като те са създадени не помни, какво представляват тези пиксели. Единственият начин за преместване на фигура върху платното е да изчистите платното (или част от него около формата) и да го прехвърлите с фигурата в нова позиция.

SVG

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

Това е HTML документ с проста SVG картина в него:

<p>Normal HTML here.</p>
<svg xmlns="http://www.w3.org/2000/svg">
  <circle r="50" cx="50" cy="50" fill="red"/>
  <rect x="120" y="5" width="90" height="90"
        stroke="blue" fill="none"/>
</svg>

Атрибута xmlns променя елемента (и неговите деца) в различно XML namespace (пространство от имена). Това пространство от имена идентифицирано от URL уточнява диалекта, на който в момента говорим. <circle> и <rect> таговете, които не съществуват в HTML, имат значение за изготвянето на формите в SVG - те изготвят форми, използвайки стила и положението, определено от техните атрибути.

Тези маркери създават DOM елементи, точно като HTML тагове. Например, това променя <circle> елемента да бъде оцветен в циан вместо червено.

var circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");

Canvas елемент

Canvas графики могат да се правят върху <canvas> елемент. Можете да дадете на такъв елемент width и height атрибути за определяне на размера му в пиксели.

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

Тага <canvas> е предназначен за подкрепа на различни стилове на рисуване. За да получим достъп до действителния интерфейс на една рисунка, първо трябва да създадем контекст, който е обект чиито методи предоставят интерфейс за чертане. В момента има два широко подкрепяни стилове: "2d" - за двуизмерни графики и "webgl" - за триизмерна графика през интерфейса на OpenGL.

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

Контекста се създава през getContext метода на <canvas> елемента.

<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
  var canvas = document.querySelector("canvas");
  var context = canvas.getContext("2d");
  context.fillStyle = "red";
  context.fillRect(10, 10, 100, 50);
</script>

След създаване на контекст обект, примерът чертае червен правоъгълник с 100 пиксела ширина на 50 пиксела височина, в своя горен ляв ъгъл с координати (10, 10).

Точно както в HTML (и SVG), координатната система поставя платното в точка (0, 0) в гония ляв ъгъл и положителната ос у слиза от там. Така че, (10, 10) е 10 пиксела по-надолу и по в дясно от горния ляв ъгъл.

Запълване и очертания

В интерфейса на платното, формата може да бъде filled (запълнена), което означава, че на площта и се дадава определен цвят или шарка, или може да бъде stroked (очертана), което означава, че линия очертава нейната периферия. Същата терминология се използва и от SVG.

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

Метода neither взема някои допълнителни параметри. Цвят за запълване, дебелина на очертанията и т.н. той не се определя от аргумент на метода (както може да се очаква), а по-скоро от свойствата на контекста на обекта.

Свойството fillStyle променя начина на запълване на формата. Може да се настрои със string, който определя цвят и може да се използва всеки цвят, който се разбира от CSS.

Свойството strokeStyle работи по подобен начин, но определя цвета използван за очертаващата линия. Ширината на тази линия се определя от свойството lineWidth, което може да съдържа всяко положително число.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.strokeStyle = "blue";
  cx.strokeRect(5, 5, 50, 50);
  cx.lineWidth = 5;
  cx.strokeRect(135, 5, 50, 50);
</script>

Когато няма определен атрибут width или height, както в предишния пример, елемента на платното получава ширина по-подразбиране от 300 пиксела и височина от 150 пиксела.

Paths (пътища)

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

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  for (var y = 10; y < 100; y += 10) {
    cx.moveTo(10, y);
    cx.lineTo(90, y);
  }
  cx.stroke();
</script>

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

При запълване на пътя (с помощта на fill метода) всяка форма се запълва отделно. Пътят може да съдържа множество форми - всяка от които започва с moveTo. Но пътят трябва да бъде затворен (което означава, че неговото начало и край са в едно и също местоположение), преди да може да бъде запълнен. Ако пътя не е затворен, се добавя допълнителна линия от края до началото на пътя и формата се запълва.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(50, 10);
  cx.lineTo(10, 70);
  cx.lineTo(90, 70);
  cx.fill();
</script>

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

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

Curves (криви линии)

Пътя може да съдържа криви линии. Те са за съжаление, малко по-ангажиращи, отколкото изготвянето на прави линии.

Метода quadraticCurveTo чертае крива до дадена точка. За определяне на кривината на линията, е дадена една контролна точка, както и точка дестинация. Представете си, че тази контролна точка привлича линията и по този начин я изкривява. Линията не минава през контролната точка. По-скоро, посоката на линията от нейната начална и крайна точка, ще бъде такава, че да се подравни с линията до контролната точка. Следващият пример илюстрира това.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 90);
  // control=(60,10) goal=(90,90)
  cx.quadraticCurveTo(60, 10, 90, 90);
  cx.lineTo(60, 10);
  cx.closePath();
  cx.stroke();
</script>

Ние чертаем квадратична крива от ляво на дясно с (60,10), като контролна точка, а след това правим две отсечки, които минават през контролната точка и обратно до началото на линията. Резултата донякъде прилича на емблема на Star Trek. Можете да видите ефекта на контролната точка: лиите идващи от долните ъгли стартират в посока на контролната точка и след това изкривяват към целата си.

Метода bezierCurveTo чертае подобни криви. Но вместо една контролна точка, той използва 2 за всяка от крайните точки на линията. Това е скица, която илюстрира поведението на подобна крива.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 90);
  // control1=(10,10) control2=(90,10) goal=(50,90)
  cx.bezierCurveTo(10, 10, 90, 10, 50, 90);
  cx.lineTo(90, 10);
  cx.lineTo(10, 10);
  cx.closePath();
  cx.stroke();
</script>

Двете контролни точки определят посоката на двата края на кривата. Колкото са по далеч от своята кореспондираща точка, толкова повече кривата ще е “изпъкнала” в тази посока.

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

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

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 10);
  // control=(90,10) goal=(90,90) radius=20
  cx.arcTo(90, 10, 90, 90, 20);
  cx.moveTo(10, 10);
  // control=(90,10) goal=(90,90) radius=80
  cx.arcTo(90, 10, 90, 90, 80);
  cx.stroke();
</script>

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

За да начертаем кръг можем да използваме четири извиквания на arcTo (всяко по 90 градуса). Но метода arc осигурява по-лесен начин. Той взема чифт координати - център на дъгата, радиус и след това начало и край на ъгъла.

Тези последните два периметъра позволяват да се изготви само част от кръг. Ъглите се измерват в радиани, а не в градуси. Това означава, че пълен кръг има ъгъл 2π или 2 * Math.PI, което е около 6.28. Ъгълът започва да се измерва от точка в дясно от центъра на кръга в посока по часовниковата стрелка. Можем да използваме за старт 0 и край по-голям 2π (да речем 7) за да направим пълен кръг.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  // center=(50,50) radius=40 angle=0 to 7
  cx.arc(50, 50, 40, 0, 7);
  // center=(150,50) radius=40 angle=0 to ½π
  cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
  cx.stroke();
</script>

Получената рисунка съдържа линия в дясно от пълния кръг (първото извикване на arc) и после в дясно четвърт кръг(второто извикване на arc). Подобно на метода за пътя, линията прекарана с arc е свързана с пътя на предишния сегмент по подразбиране. Ще трябва да извикате moveTo или да започнете нов път, ако искате да избегнете това.

Рисуване на кръгова диаграма

Представете си, че току-що сте почнали работа в EconomiCorp Inc и вашата първа задача е да направите кръгова диаграма на резултатите от техните проувания за удовлетвореността на клиентите.

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

var results = [
  {name: "Satisfied", count: 1043, color: "lightblue"},
  {name: "Neutral", count: 563, color: "lightgreen"},
  {name: "Unsatisfied", count: 510, color: "pink"},
  {name: "No comment", count: 175, color: "silver"}
];

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

<canvas width="200" height="200"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var total = results.reduce(function(sum, choice) {
    return sum + choice.count;
  }, 0);
  // Start at the top
  var currentAngle = -0.5 * Math.PI;
  results.forEach(function(result) {
    var sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.beginPath();
    // center=100,100, radius=100
    // from current angle, clockwise by slice's angle
    cx.arc(100, 100, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(100, 100);
    cx.fillStyle = result.color;
    cx.fill();
  });
</script>

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

Текст

2D контекста на платното за рисуване предоставя методите fillText и strokeText. Последния е полезен за очертаване на букви, но обикновено fillText е този, който ни трябва. Той запълва даден текст с текущия fillColor.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.font = "28px Georgia";
  cx.fillStyle = "fuchsia";
  cx.fillText("I can draw text, too!", 10, 50);
</script>

Можете да определите размера, стила и шрифта със свойството font. Този пример само дава размера и името на шрифта. Можете да добавите italic или bold в началото на string-а за да изберете стил.

Последните два аргумента на fillTextstrokeText) осигуряват позицията, където се изготвя шрифта. По подразбиране, те показват позицията на началото на азбучната базова линия, което е линията, на която стоят буквите, без да броим висящи части в текста, като j или p. Можем да променим хоризонталната позиция, като настроим textAlign свойството към "end" или "center" и вертикалната позиция, като настроим textBaseline към "top", "middle" или "bottom".

Ще се върнем към нашата кръгова диаграма и проблемът с етикетиране на резените в упражненията на края на главата.

Снимки (images)

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

Метода drawImage ни позволява да изготвим пикселни данни върху платното. Тези пикселни данни могат да произхождат от един <img> елемент или от друго платно, които не трябва да се виждат в действителния документ. Следващият пример създава самостоятелен <img> елемент и зарежда файл с изображение в него. Но не можем веднага за започнем да ползваме тази снимка, понеже браузърът не я е донесел все още. За да направим това, трябва да регистрираме манипулатор за събитие "load" и да направим чертежа, след като изображението е заредено.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var img = document.createElement("img");
  img.src = "img/hat.png";
  img.addEventListener("load", function() {
    for (var x = 10; x < 200; x += 30)
      cx.drawImage(img, x, 10);
  });
</script>

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

Когато на drawImage са дадени девет аргумента, той може да се използва за да се направи само един фрагмент от изображението. От втория до петия аргумент посочват правоъгълника (Х , У , широчина и височина) в изображението източник, което трябва да бъде копирано, а от шести до девети аргумент посочват правоъгълника (на платното), в който трябва да се копира изображението.

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

Various poses of a game character

С редуване на различни пози, можем да покажем анимация, която прилича на ходене на героя.

За да анимираме тази снимка на платното, ще ни бъде полезен clearRect метода. Той прилича на fillRect, но вместо да оцветява правоъгълника той го прави прозрачен, чрез премахване на предварително изготвените пиксели.

Ние знаем, че всеки спрайт (всяка под-картинка), е 24 пиксела широка и 30 пиксела висока. Следният код зарежда изображението и след това създава интервал (повтарящ се таймер) за да изготви следващия кадър.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var img = document.createElement("img");
  img.src = "img/player.png";
  var spriteW = 24, spriteH = 30;
  img.addEventListener("load", function() {
    var cycle = 0;
    setInterval(function() {
      cx.clearRect(0, 0, spriteW, spriteH);
      cx.drawImage(img,
                   // source rectangle
                   cycle * spriteW, 0, spriteW, spriteH,
                   // destination rectangle
                   0,               0, spriteW, spriteH);
      cycle = (cycle + 1) % 8;
    }, 120);
  });
</script>

Променливата cycle следи нашата позиция в анимацията. С всяка рамка тя се увеличава се връща обратно към 0-7 диапазона с помощта на оператора за остатък. Тази променлива след това се използва за изчисляване на х-координатите на спрайта за текущата поза в снимката.

Трансформация

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

Извиквайки scale метода ще доведе до нещо изготвено след това да бъде намалено. Този метод взема два параметъра, един да създаде хоризонтален мащаб и един за задаване на вертикална скала.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.scale(3, .5);
  cx.beginPath();
  cx.arc(50, 50, 40, 0, 7);
  cx.lineWidth = 3;
  cx.stroke();
</script>

Мащабирането ще вземе всичко за състава на изображението, включително ширината на линията, която трябва да бъде разтегната или стеснена, както е посочено. Мащабиране с отрицателна сума ще обърне картинката. Огледалното обръщане се случва в точка (0, 0), което означава, че също ще обърне посоката на координатната система. Когато се прилага хоризонтално мащабиране с използване на -1, фигурата начертана в позиция х 100 , ще се прехвърли в позиция -100.

Така че, за да обърнем една картина, не може просто да добавим cx.scale(-1, 1) преди извикването да drawImage, тъй като това би преместило нашата картина извън платното, където тя няма да се вижда. Можете да регулирате координатите дадени на drawImage, за да компенсира това чрез изтегляне на образа в позиция х -50 вместо 0. Друго решение, което не изисква код, който да променя мащаба на рисунката, е да се коригира оста, около която мащабирането се случва.

Има няколко други метода освен scale, които влияят на координатната система на платното. Можете да завъртите впоследствие начертаните форми с rotate метода и да ги преместите с translate метода. Заинтригуващо и объркващо е, че тези трансформации се натупват, което означава, че всяка следваща се случва в сравнение с предишната трансформация.

Така че, ако се транслира с 10 хоризонтални пиксела два пъти, всичко ще бъде изтеглено на 20 пиксела надясно. Ние първо трябва да преместим центъра на координатната система на (50, 50) и след това да го завъртим с 20 градуса (0.1π в радиани), така че въртенето да се случи в точка (50, 50).

Stacking transformations

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

За да обърнем картината около вертикалната линия в дадено х- положение можем да направим следното:

function flipHorizontally(context, around) {
  context.translate(around, 0);
  context.scale(-1, 1);
  context.translate(-around, 0);
}

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

Mirroring around a vertical line

Това показва координатната система преди и след отразяването през централната линия. Ако се направи триъгълник в положителна х-позиция, по подразбиране това е мястото, където е триъгълник 1. Извикването на flipHorizontally първо прави транслация на дясно, което ни отвежда до триъгълник 2. След мащабирането, обръща триъгълника обратно в позиция 3. Но това не е мястото, където трябва да бъде отразено от дадената линия. Второто извикване на translate поправя това - то анулира първоначалната транслация и прави триъгълник 4, който да се появи точно там, където трябва.

Сега можем да нарисуваме огледален характер на позиция (100, 0) с обръщане на картината, около вертикалния център на характера.

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var img = document.createElement("img");
  img.src = "img/player.png";
  var spriteW = 24, spriteH = 30;
  img.addEventListener("load", function() {
    flipHorizontally(cx, 100 + spriteW / 2);
    cx.drawImage(img, 0, 0, spriteW, spriteH,
                 100, 0, spriteW, spriteH);
  });
</script>

Съхраняване и изчистване на трансформации

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

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

Методите save и restore в контекста на 2D върху платното, извършват този вид управление на трансформация. Те концептуално запазват натрупванията на трансформации. Когато извикаме save текущото състояние се вкарва в стека, а когато се извика restore състоянието на върха на стека се изважда и се използва, като контекст на настоящата трансформация.

Функцията branch в следващия пример илюстрира, какво може да направим с функция, която променя трансформацията и след това призовава друга функция (в този случай себе си), която продължава рисуването с дадената трансформация.

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

<canvas width="600" height="300"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  function branch(length, angle, scale) {
    cx.fillRect(0, 0, 1, length);
    if (length < 8) return;
    cx.save();
    cx.translate(0, length);
    cx.rotate(-angle);
    branch(length * scale, angle, scale);
    cx.rotate(2 * angle);
    branch(length * scale, angle, scale);
    cx.restore();
  }
  cx.translate(300, 0);
  branch(60, 0.5, 0.8);
</script>

Ако извикванията на save и restore не бяха направени, второто рекурсивно извикване към branch ще се окаже в позицията и въртенето на първото извикване. То няма да бъде свързано към текущия клон, а по-скоро към най-вътрешния, най-десния клон, съставен от първото извикване. Получената форма може също да бъде интересна, но това определено не е дърво.

Обратно в играта

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

Първо ще определим типа на обекта на CanvasDisplay подкрепящ същия интерфейс, както DOMDisplay от Глава 15, а именно методите drawFrame и clear.

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

function CanvasDisplay(parent, level) {
  this.canvas = document.createElement("canvas");
  this.canvas.width = Math.min(600, level.width * scale);
  this.canvas.height = Math.min(450, level.height * scale);
  parent.appendChild(this.canvas);
  this.cx = this.canvas.getContext("2d");

  this.level = level;
  this.animationTime = 0;
  this.flipPlayer = false;

  this.viewport = {
    left: 0,
    top: 0,
    width: this.canvas.width / scale,
    height: this.canvas.height / scale
  };

  this.drawFrame(0);
}

CanvasDisplay.prototype.clear = function() {
  this.canvas.parentNode.removeChild(this.canvas);
};

Брояча animationTime е причината, поради която подадохме размерът на стъпката на drawFrame в Глава 15, въпреки че DOMDisplay не го използва. Нашата нова drawFrame функция използва брояча за проследяване на времето, така че да може да превключваме между анимационните рамки основани на текущото време.

CanvasDisplay.prototype.drawFrame = function(step) {
  this.animationTime += step;

  this.updateViewport();
  this.clearDisplay();
  this.drawBackground();
  this.drawActors();
};

Освен проследяването на времето, метода актуализира изгледа за текущата позиция на играча, запълва цялото платно с фонов цвят, чертае задния план и актьорите върху него. Имайте в предвид, че това е различен подход от този в Глава 15, където ние чертаем фона веднъж и скролваме опакования DOM елемент, за да го преместим.

Защото формите на платното са само пиксели и след като ги начертаете няма начин да ги преместите (или отстраните). Единствения начин да се актуализира дисплея на платното е да го изчистите и преначертаете сцената отново.

Методът updateViewport е подобен на DOMDisplay-скрол метода scrollPlayerIntoView. Той проверява дали играчът е твърде близо до ръба на екрана и движи изгледа, когато случаят е такъв.

CanvasDisplay.prototype.updateViewport = function() {
  var view = this.viewport, margin = view.width / 3;
  var player = this.level.player;
  var center = player.pos.plus(player.size.times(0.5));

  if (center.x < view.left + margin)
    view.left = Math.max(center.x - margin, 0);
  else if (center.x > view.left + view.width - margin)
    view.left = Math.min(center.x + margin - view.width,
                         this.level.width - view.width);
  if (center.y < view.top + margin)
    view.top = Math.max(center.y - margin, 0);
  else if (center.y > view.top + view.height - margin)
    view.top = Math.min(center.y + margin - view.height,
                        this.level.height - view.height);
};

Извикванията на Math.max и Math.min гарантират, че демонстрационния прозорец не показва пространство извън нивото. Math.max(x, 0) гарантира, че полученият номер не е по-малък то нула. Math.min по същия начин, осигурява стойността да стои под определената граница.

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

CanvasDisplay.prototype.clearDisplay = function() {
  if (this.level.status == "won")
    this.cx.fillStyle = "rgb(68, 191, 255)";
  else if (this.level.status == "lost")
    this.cx.fillStyle = "rgb(44, 136, 214)";
  else
    this.cx.fillStyle = "rgb(52, 166, 251)";
  this.cx.fillRect(0, 0,
                   this.canvas.width, this.canvas.height);
};

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

var otherSprites = document.createElement("img");
otherSprites.src = "img/sprites.png";

CanvasDisplay.prototype.drawBackground = function() {
  var view = this.viewport;
  var xStart = Math.floor(view.left);
  var xEnd = Math.ceil(view.left + view.width);
  var yStart = Math.floor(view.top);
  var yEnd = Math.ceil(view.top + view.height);

  for (var y = yStart; y < yEnd; y++) {
    for (var x = xStart; x < xEnd; x++) {
      var tile = this.level.grid[y][x];
      if (tile == null) continue;
      var screenX = (x - view.left) * scale;
      var screenY = (y - view.top) * scale;
      var tileX = tile == "lava" ? scale : 0;
      this.cx.drawImage(otherSprites,
                        tileX,         0, scale, scale,
                        screenX, screenY, scale, scale);
    }
  }
};

Плочите, които не са празни (нула) са съставени с drawImage. Изображението otherSprites съдържа всички изображения, използвани за другите елементи, които са различни от играча. То съдържа от ляво на дясно плочите на стената, на лавата и спрайт за монетата.

Sprites for our game

Основата на плочите е 20 на 20 пиксела, тъй като ще използваме същия мащаб, както в DOMDisplay. По този начин се компенсира отместването на плочите за лавата с 20 (стойността на scale променливата) и отместването на стените с 0.

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

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

Тъй като спрайтовете са малко по-широки от обекта на играча - 24 вместо 16 пиксела, за да се позволи известно пространство за ръцете и краката, метода трябва да коригира х-координата и ширината от дадените размери (playerXOverlap).

var playerSprites = document.createElement("img");
playerSprites.src = "img/player.png";
var playerXOverlap = 4;

CanvasDisplay.prototype.drawPlayer = function(x, y, width,
                                              height) {
  var sprite = 8, player = this.level.player;
  width += playerXOverlap * 2;
  x -= playerXOverlap;
  if (player.speed.x != 0)
    this.flipPlayer = player.speed.x < 0;

  if (player.speed.y != 0)
    sprite = 9;
  else if (player.speed.x != 0)
    sprite = Math.floor(this.animationTime * 12) % 8;

  this.cx.save();
  if (this.flipPlayer)
    flipHorizontally(this.cx, x + width / 2);

  this.cx.drawImage(playerSprites,
                    sprite * width, 0, width, height,
                    x,              y, width, height);

  this.cx.restore();
};

Метода drawPlayer се извиква с drawActors, който изготвя всички участници в играта.

CanvasDisplay.prototype.drawActors = function() {
  this.level.actors.forEach(function(actor) {
    var width = actor.size.x * scale;
    var height = actor.size.y * scale;
    var x = (actor.pos.x - this.viewport.left) * scale;
    var y = (actor.pos.y - this.viewport.top) * scale;
    if (actor.type == "player") {
      this.drawPlayer(x, y, width, height);
    } else {
      var tileX = (actor.type == "coin" ? 2 : 1) * scale;
      this.cx.drawImage(otherSprites,
                        tileX, 0, width, height,
                        x,     y, width, height);
    }
  }, this);
};

При изготвянето на нещо, което не е играч, ние гледаме неговият вид, за да намерим отместването на правилния спрайт. Плочите за лава се намират при офсет 20, а спрайта на монетата се намира при офсет 40 (два пъти скалата).

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

Показания следващ малък документ, свързва новия дисплей в runGame:

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

Избор на графични интерфейси

Винаги, когато имате нужда от генериране на графики в браузъра, можете да избирате между обикновен HTML, SVG и canvas (платно). Няма най-добър вариант, който да работи във всички ситуации. Всеки вариант има силни и слаби страни.

Обикновен HTML има предимството, че е прост. Той също така се интегрира добре с текст. И двата- SVG и canvas ви позволяват да изготвите текст, но те няма да ви помогнат да позиционирате този текст или да го увиете, когато заеме повече от един ред. В базирана на HTML картина е лесно да се включат блокове текст.

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

И двата - SVG и HTML изграждат структура от данни (DOM), които представляват картината. Това прави възможно да се променят елементи, след като са изготвени. Ако трябва да се промени многократно малка част от голяма картина в отговор на това, което потребителя прави или като част от анимация, правенето на това в платното може да бъде ненужно скъпо. DOM също ни позволява да регистрираме манипулатори за събития на мишката за всеки елемент от картината (дори фигури, изготвени с SVG). Но не можем да направим това с canvas.

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

Има ефекти, като например рендерирането на един пиксел в даден момент (например използвайки ray tracer) или последваща обработка на изображение с JavaScript (замъгляване или изкривяване), което може да бъде обработено реалистично само с пиксел базирана техника.

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

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

Резюме

В тази глава обсъдихме техники за рисуване на графики в браузъра, като се фокусирахме върху <canvas> елементите.

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

Интерфейса на 2D чертането ни позволява да запълним или очертаем различни форми. Контекста на свойството fillStyle определя, как да запълним тези форми. Свойствата strokeStyle и lineWidth контролират начина на изготвяне на линии.

Правоъгълници и части от текст могат да се направят с едно извикване на метод. Методите fillRect и strokeRect изготвят правоъгълници, а методите fillText и strokeText изготвят текст. За да създадем определени форми първо трябва да изградим път (path).

С извикването на beginPath започва нов път. Редица други методи добавят линии и криви към текущия път. Например lineTo може да добави права линия. Когато един път е завършен, той може да бъде запълнен с fill метод или очертан със stroke метода.

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

Трансформациите ни позволяват да направим форма в няколко посоки. Контекста на 2D чертежа има текуща трансформация, която може да бъде променена с методите translate, scale и rotate. Това ще се отрази на всички следващи операции на чертането. Състоянието на трансформацията може да бъде съхранено със save метода и възстановено със restore метода.

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

Упражнения

Фигури

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

  1. Трапец (правоъгълник, който е по-широк от едната страна)

  2. Червен диамант(правоъгълник завъртян на 45 градуса или ¼π радиана)

  3. Зиг-заг линия

  4. Спирала, съставена от 100 сегмента прави линии.

  5. Жълта звезда

The shapes to draw

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

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

<canvas width="600" height="200"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");

  // Your code here.
</script>

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

Диамантът (2), може да се направи по лесния начин с пътя или по интересен начин с rotate трансформация. За да използвате ротация, ще трябва да се приложи трик, подобен на това, което правихме в flipHorizontally функцията. Понеже искате да се завърти около центъра на правоъгълника, а не около точка (0, 0), за целта първо трябва да се транслират там, след това завъртате и после транслирате обратно.

За зиг-заг (3) е непрактично да се пише ново извикване към lineTo за всяка отсечка. Вместо това използвайте цикъл. Можете да начертаете всяка итерация или две отсечки (дясна и лява) или една, като в този случай трябва да използвате четност (% 2) за индекса на цикъла, за да определите дали да отиде наляво или надясно.

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

Изобразената звезда (5) е изградена от quadraticCurveTo линии. Но също така може да се направи и с прави линии. Разделете кръга на осем части или парче за всяка точка, където искате вашата звезда да има лъч. Начертайте линии между тези точки, като ги правите криви към центъра на звездата. С quadraticCurveTo, можете да използвате центъра, като контролен пункт.

Кръгова диаграма

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

Можете отново да използвате Math.sin и Math.cos, както е описано в предишното упражнение.

<canvas width="600" height="300"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var total = results.reduce(function(sum, choice) {
    return sum + choice.count;
  }, 0);

  var currentAngle = -0.5 * Math.PI;
  var centerX = 300, centerY = 150;
  // Add code to draw the slice labels in this loop.
  results.forEach(function(result) {
    var sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.beginPath();
    cx.arc(centerX, centerY, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(centerX, centerY);
    cx.fillStyle = result.color;
    cx.fill();
  });
</script>

Ще трябва да извикате fillText и да определите контекста на свойствата textAlign и textBaseline по някакъв начин, че текста да се озовава там, където желаете.

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

Ъгълът на тази линия е currentAngle + 0.5 * sliceAngle. Следният код устанвовява позицията на тази линия на 120 пиксела от центъра.

var middleAngle = currentAngle + 0.5 * sliceAngle;
var textX = Math.cos(middleAngle) * 120 + centerX;
var textY = Math.sin(middleAngle) * 120 + centerY;

За стойност на "средата" на textBaseline е подходящо използването на този подход. Какво да използвате за textAlign зависи от външната страна на кръга, където сте. От лявата страна, трябва да бъде "right", а от дясната "left", така че текста да е разположен далече от пая.

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

Подскачаща топка

Използвайте техниката requestAnimationFrame, която видяхме в Глава 13 и Глава 15, за да направите кутия с подскачаща топка в нея. Топката се движи с постоянна скорост и отскача от клетките на стените на решетката, когато ги удари.

<canvas width="400" height="400"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");

  var lastTime = null;
  function frame(time) {
    if (lastTime != null)
      updateAnimation(Math.min(100, time - lastTime) / 1000);
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  function updateAnimation(step) {
    // Your code here.
  }
</script>

Кутията може лесно да се направи с strokeRect и дефиниране на променлива, която държи размера, ако е квадрат или две променливи, ако ширината и височината на вашата кутия се различават. За да създадете кръг на топка, започнете с пътя, като извикате arc(x, y, radius, 0, 7), което създава дъга, като започва от нула до повече от цял кръг и го запълни.

За да моделирате позицията на топката и скоростта, може да използвате Vector типа от Глава 15(който е на разположение на тази страница). Дайте му начална скорост, за предпочитане такава, която не чисто вертикална или хоризонтална и за всеки кадър, умножете тази скорот по изминалия период от време. Когато топката стигне твърде близо до вертикалната стена, обърнете х-компонента на неговата скорост. По същия начин обърнете y-компонента, когато стигне хоризонталната стена.

За намиране на нова позиция за топката и скоростта, използвайте clearRect за изтриване на сцената и за нейното прехвърляне с помоща на новата позиция.

Precomputed mirroring

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

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

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

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

Ще се изискват някакви required, защото изображенията не се зареждат веднага. Вие правите обърнатия чертеж само веднъж и ако го правите преди да се зареди снимката, той няма да направи нищо. Манипулатора "load" може да се използва върху изображението за изготвяне на обърнати изображения към допълнителното платно. Това платно може да се използва, като източник на чертеж веднага (то просто ще бъде празно, докато не се начертае характер върху него).