JavaScript 編程精解 中文第三版 十7、在畫布上繪圖

來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目

原文:Drawing on Canvasjavascript

譯者:飛龍html

協議:CC BY-NC-SA 4.0java

自豪地採用谷歌翻譯git

部分參考了《JavaScript 編程精解(第 2 版)》github

繪圖就是欺騙。web

M.C. Escher,由 Bruno Ernst 在《The Magic Mirror of M.C. Escher》中引用apache

瀏覽器爲咱們提供了多種繪圖方式。最簡單的方式是用樣式來規定普通 DOM 對象的位置和顏色。就像在上一章中那個遊戲展現的,咱們可使用這種方式實現不少功能。咱們能夠爲節點添加半透明的背景圖片,來得到咱們但願的節點外觀。咱們也可使用transform樣式來旋轉或傾斜節點。編程

可是,在一些場景中,使用 DOM 並不符合咱們的設計初衷。好比咱們很難使用普通的 HTML 元素畫出任意兩點之間的線段這類圖形。canvas

這裏有兩種解決辦法。第一種方法基於 DOM,但使用可縮放矢量圖形(SVG,Scalable Vector Graphics)代替 HTML。咱們能夠將 SVG 當作文檔標記方言,專用於描述圖形而非文字。你能夠在 HTML 文檔中嵌入 SVG,還能夠在<img>標籤中引用它。數組

咱們將第二種方法稱爲畫布(canvas)。畫布是一個可以封裝圖片的 DOM 元素。它提供了在空白的html節點上繪製圖形的編程接口。SVG 與畫布的最主要區別在於 SVG 保存了對於圖像的基本信息的描述,咱們能夠隨時移動或修改圖像。

另外,畫布在繪製圖像的同時會把圖像轉換成像素(在柵格中的具備顏色的點)而且不會保存這些像素表示的內容。惟一的移動圖形的方法就是清空畫布(或者圍繞着圖形的部分畫布)並在新的位置重畫圖形。

SVG

本書不會深刻研究 SVG 的細節,可是我會簡單地解釋其工做原理。在本章的結尾,我會再次來討論,對於某個具體的應用來講,咱們應該如何權衡利弊選擇一種繪圖方式。

這是一個帶有簡單的 SVG 圖片的 HTML 文檔。

<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 命名空間。這個由url定義的命名空間,規定了咱們當前使用的語言。在 HTML 中不存在<circle><rect>標籤,但這些標籤在 SVG 中是有意義的,你能夠經過這些標籤的屬性來繪製圖像並指定樣式與位置。

和 HTML 標籤同樣,這些標籤會建立 DOM 元素,腳本能夠和它們交互。例如,下面的代碼能夠把<circle>元素的顏色替換爲青色。

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

canvas元素

咱們能夠在<canvas>元素中繪製畫布圖形。你能夠經過設置widthheight屬性來肯定畫布尺寸(單位爲像素)。

新的畫布是空的,意味着它是徹底透明的,看起來就像文檔中的空白區域同樣。

<canvas>標籤容許多種不一樣風格的繪圖。要獲取真正的繪圖接口,首先咱們要建立一個可以提供繪圖接口的方法的上下文(context)。目前有兩種獲得普遍支持的繪圖接口:用於繪製二維圖形的"2d"與經過openGL接口繪製三維圖形的"webgl"

本書只討論二維圖形,而不討論 WebGL。可是若是你對三維圖形感興趣,我強烈建議你們自行深刻研究 WebGL。它提供了很是簡單的現代圖形硬件接口,同時你也可使用 JavaScript 來高效地渲染很是複雜的場景。

您能夠用getContext方法在<canvas> DOM 元素上建立一個上下文。

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

在建立完context對象以後,做爲示例,咱們畫出一個紅色矩形。該矩形寬 100 像素,高 50 像素,它的左上點座標爲(10,10)。

與 HTML(或者 SVG)相同,畫布使用的座標系統將(0,0)放置在左上角,而且y軸向下增加。因此(10,10)是相對於左上角向下並向右各偏移 10 像素的位置。

直線和平面

咱們可使用畫布接口填充圖形,也就是賦予某個區域一個固定的填充顏色或填充模式。咱們也能夠描邊,也就是沿着圖形的邊沿畫出線段。SVG 也使用了相同的技術。

fillRect方法能夠填充一個矩形。他的輸入爲矩形框左上角的第一個xy座標,而後是它的寬和高。類似地,strokeRect方法能夠畫出一個矩形的外框。

兩個方法都不須要其餘任何參數。填充的顏色以及輪廓的粗細等等都不能由方法的參數決定(像你的合理預期同樣),而是由上下文對象的屬性決定。

設置fillStyle參數控制圖形的填充方式。咱們能夠將其設置爲描述顏色的字符串,使用 CSS 所用的顏色表示法。

strokeStyle屬性的做用很類似,可是它用於規定輪廓線的顏色。線條的寬度由lineWidth屬性決定。lineWidth的值都爲正值。

<canvas></canvas>
<script>
  let 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 像素。

路徑

路徑是線段的序列。2D canvas接口使用一種奇特的方式來描述這樣的路徑。路徑的繪製都是間接完成的。咱們沒法將路徑保存爲能夠後續修改並傳遞的值。若是你想修改路徑,必需要調用多個方法來描述他的形狀。

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

本例建立了一個包含不少水平線段的路徑,而後用stroke方法勾勒輪廓。每一個線段都是由lineTo以當前位置爲路徑起點繪製的。除非調用了moveTo,不然這個位置一般是上一個線段的終點位置。若是調用了moveTo,下一條線段會從moveTo指定的位置開始。

當使用fill方法填充一個路徑時,咱們須要分別填充這些圖形。一個路徑能夠包含多個圖形,每一個moveTo都會建立一個新的圖形。可是在填充以前咱們須要封閉路徑(路徑的起始節點與終止節點必須是同一個點)。若是一個路徑還沒有封閉,會出現一條從終點到起點的線段,而後纔會填充整個封閉圖形。

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

本例畫出了一個被填充的三角形。注意只顯示地畫出了三角形的兩條邊。第三條從右下角回到上頂點的邊是沒有顯示地畫出,於是在勾勒路徑的時候也不會存在。

你也可使用closePath方法顯示地經過增長一條回到路徑起始節點的線段來封閉一個路徑。這條線段在勾勒路徑的時候將被顯示地畫出。

曲線

路徑也可能會包含曲線。繪製曲線更加複雜。

quadraticCurveTo方法繪製到某一個點的曲線。爲了肯定一條線段的曲率,須要設定一個控制點以及一個目標點。設想這個控制點會吸引這條線段,使其成爲曲線。線段不會穿過控制點。可是,它起點與終點的方向會與兩個點到控制點的方向平行。見下例:

<canvas></canvas>
<script>
  let 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),而後畫出兩條穿過控制點而且回到線段起點的線段。繪製的結果相似一個星際迷航的圖章。你能夠觀察到控制點的效果:從下端的角落裏發出的線段朝向控制點並向他們的目標點彎曲。

bezierCurve(貝塞爾曲線)方法能夠繪製一種相似的曲線。不一樣的是貝塞爾曲線須要兩個控制點而不是一個,線段的每個端點都須要一個控制點。下面是描述貝塞爾曲線的簡單示例。

<canvas></canvas>
<script>
  let 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>

兩個控制點規定了曲線兩個端點的方向。兩個控制點相對兩個端點的距離越遠,曲線就會越向這個方向凸出。

因爲咱們沒有明確的方法,來找出咱們但願繪製圖形所對應的控制點,因此這種曲線仍是很難操控。有時候你能夠經過計算獲得他們,而有時候你只能經過不斷的嘗試來找到合適的值。

arc方法是一種沿着圓的邊緣繪製曲線的方法。 它須要弧的中心的一對座標,半徑,而後是起始和終止角度。

咱們可使用最後兩個參數畫出部分圓。角度是經過弧度來測量的,而不是度數。這意味着一個完整的圓擁有的弧度,或者2*Math.PI(大約爲 6.28)的弧度。弧度從圓心右邊的點開始並以順時針的方向計數。你能夠以 0 起始並以一個比大的數值(好比 7)做爲終止值,畫出一個完整的圓。

<canvas></canvas>
<script>
  let 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 得到了一份工做,而且你的第一個任務是畫出一個描述其用戶滿意度調查結果的餅狀圖。results綁定包含了一個表示調查結果的對象的數組。

const 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"}
];

要想畫出一個餅狀圖,咱們須要畫出不少個餅狀圖的切片,每一個切片由一個圓弧與兩條到圓心的線段組成。咱們能夠經過把一個整圓()分割成以調查結果數量爲單位的若干份,而後乘以作出相應選擇的用戶的個數來計算每一個圓弧的角度。

<canvas width="200" height="200"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  // Start at the top
  let currentAngle = -0.5 * Math.PI;
  for (let result of results) {
    let 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 畫布的context對象提供了fillText方法和strokeText方法。第二個方法能夠用於繪製字母輪廓,但一般狀況下咱們須要的是fillText方法。該方法使用當前的fillColor來填充特定文字的輪廓。

<canvas></canvas>
<script>
  let 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來選擇樣式。

傳遞給fillTextstrokeText的後兩個參數用於指定繪製文字的位置。默認狀況下,這個位置指定了文字的字符基線(baseline)的起始位置,咱們能夠將其假想爲字符所站立的位置,基線不考慮jp字母中那些向下突出的部分。你能夠設置textAlign屬性(endcenter)來改變起始點的水平位置,也能夠設置textBaseline屬性(topmiddlebottom)來設置基線的豎直位置。

在本章末尾的練習中,咱們會回顧餅狀圖,並解決給餅狀圖分片標註的問題。

圖像

計算機圖形學領域常常將矢量圖形和位圖圖形分開來討論。本章一直在討論第一種圖形,即經過對圖形的邏輯描述來繪圖。而位圖則相反,不須要設置實際圖形,而是經過處理像素數據來繪製圖像(光柵化的着色點)。

咱們可使用drawImage方法在畫布上繪製像素值。此處的像素數值能夠來自<img>元素,或者來自其餘的畫布。下例建立了一個獨立的<img>元素,而且加載了一張圖像文件。但咱們沒法立刻使用該圖片進行繪製,由於瀏覽器可能尚未完成圖片的獲取操做。爲了處理這個問題,咱們在圖像元素上註冊一個"load"事件處理程序而且在圖片加載完以後開始繪製。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/img/hat.png";
  img.addEventListener("load", () => {
    for (let x = 10; x < 200; x += 30) {
      cx.drawImage(img, x, 10);
    }
  });
</script>

默認狀況下,drawImage會根據原圖的尺寸繪製圖像。你也能夠增長兩個參數來設置不一樣的寬度和高度。

若是咱們向drawImage函數傳入 9 個參數,咱們能夠用其繪製出一張圖片的某一部分。第二個到第五個參數表示須要拷貝的源圖片中的矩形區域(xy座標,寬度和高度),同時第六個到第九個參數給出了須要拷貝到的目標矩形的位置(在畫布上)。

該方法能夠用於在單個圖像文件中放入多個精靈(圖像單元)並畫出你須要的部分。

咱們能夠改變繪製的人物造型,來展示一段看似人物在走動的動畫。

clearRect方法能夠幫助咱們在畫布上繪製動畫。該方法相似於fillRect方法,可是不一樣的是clearRect方法會將目標矩形透明化,並移除掉以前繪製的像素值,而不是着色。

咱們知道每一個精靈和每一個子畫面的寬度都是 24 像素,高度都是 30 像素。下面的代碼裝載了一幅圖片並設置定時器(會重複觸發的定時器)來定時繪製下一幀。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/img/player.png";
  let spriteW = 24, spriteH = 30;
  img.addEventListener("load", () => {
    let cycle = 0;
    setInterval(() => {
      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綁定用於記錄角色在動畫圖像中的位置。每顯示一幀,咱們都要將cycle加 1,並經過取餘數確保cycle的值在 0~7 這個範圍內。咱們隨後使用該綁定計算精靈當前形象在圖片中的x座標。

變換

可是,若是咱們但願角色能夠向左走而不是向右走該怎麼辦?誠然,咱們能夠繪製另外一組精靈,但咱們也可使用另外一種方式在畫布上繪圖。

咱們能夠調用scale方法來縮放以後繪製的任何元素。該方法接受兩個輸入參數,第一個參數是水平縮放比例,第二個參數是豎直縮放比例。

<canvas></canvas>
<script>
  let 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>

由於調用了scale,所以圓形長度變爲原來的 3 倍,高度變爲原來的一半。scale能夠調整圖像全部特徵,包括線寬、預約拉伸或壓縮。若是將縮放值設置爲負值,能夠將圖像翻轉。因爲翻轉發生在座標(0,0)處,這意味着也會同時反轉座標系的方向。當水平縮放 –1 時,在x座標爲 100 的位置畫出的圖形會繪製在縮放以前x座標爲 –100 的位置。

爲了翻轉一張圖片,只是在drawImage以前添加cx.scale(–1,–1)是沒用的,由於這樣會將咱們的圖片移出到畫布以外,致使圖片不可見。爲了不這個問題,咱們還須要調整傳遞給drawImage的座標,將繪製圖形的x座標改成 –50 而不是 0。另外一個解決方案是在縮放時調整座標軸,這樣代碼就不須要知道整個畫布的縮放的改變。

除了scale方法還有一些其餘方法能夠影響畫布裏座標系統的方法。你可使用rotate方法旋轉繪製完的圖形,也可使用translate方法移動圖形。畢竟有趣但也容易引發誤解的是這些變換以棧的方式工做,也就是說每一個變換都會做用於前一個變換的結果之上。

若是咱們沿水平方向將畫布平移兩次,每次移動 10 像素,那麼全部的圖形都會在右方 20 像素的位置從新繪製。若是咱們先把座標系的原點移動到(50, 50)的位置,而後旋轉 20 度(大約0.1π弧度),這次的旋轉會圍繞點(50,50)進行。

可是若是咱們先旋轉 20 度,而後平移原點到(50,50),這次的平移會發生在已經旋轉過的座標系中,所以會有不一樣的方向。變換髮生順序會影響最後的結果。

咱們可使用下面的代碼,在指定的x座標處豎直反轉一張圖片。

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

咱們先把y軸移動到咱們但願鏡像所在的位置,而後進行鏡像翻轉,最後把y軸移動到被翻轉的座標系當中相應的位置。下面的圖片解釋了以上代碼是如何工做的:

上圖顯示了經過中線進行鏡像翻轉先後的座標系。對三角形編號來講明每一步。若是咱們在x座標爲正值的位置繪製一個三角形,默認狀況下它會出如今圖中三角形 1 的位置。調用filpHorizontally首先作一個向右的平移,獲得三角形 2。而後將其翻轉到三角形 3 的位置。這不是它的根據給定的中線翻轉以後應該在的最終位置。第二次調用translate方法解決了這個問題。它「去除」了最初的平移的效果,而且使三角形 4 變成咱們但願的效果。

咱們能夠沿着特徵的豎直中心線翻轉整個座標系,這樣就能夠畫出位置爲(100,0)處的鏡像特徵。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/img/player.png";
  let spriteW = 24, spriteH = 30;
  img.addEventListener("load", () => {
    flipHorizontally(cx, 100 + spriteW / 2);
    cx.drawImage(img, 0, 0, spriteW, spriteH,
                 100, 0, spriteW, spriteH);
  });
</script>

存儲與清除圖像的變換狀態

圖像變換的效果會保留下來。咱們繪製出一次鏡像特徵後,繪製其餘特徵時都會產生鏡像效果,這可能並不方便。

對於須要臨時轉換座標系統的函數來講,咱們常常須要保存當前的信息,畫一些圖,變換圖像而後從新加載以前的圖像。首先,咱們須要將當前函數調用的全部圖形變換信息保存起來。接着,函數完成其工做,並添加更多的變換。最後咱們恢復以前保存的變換狀態。

2D 畫布上下文的saverestore方法執行這個變換管理。這兩個方法維護變換狀態堆棧。save方法將當前狀態壓到堆棧中,restore方法將堆棧頂部的狀態彈出,並將該狀態做爲當前context對象的狀態。

下面示例中的branch函數首先修改變換狀態,而後調用其餘函數(本例中就是該函數自身)繼續在特定變換狀態中進行繪圖。

這個方法經過畫出一條線段,並把座標系的中心移動到線段的端點,而後調用自身兩次,先向左旋轉,接着向右旋轉,來畫出一個相似樹同樣的圖形。每次調用都會減小所畫分支的長度,當長度小於 8 的時候遞歸結束。

<canvas width="600" height="300"></canvas>
<script>
  let 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>

若是沒有調用saverestore方法,第二次遞歸調用branch將會在第一次調用的位置結束。它不會與當前的分支相鏈接,而是更加靠近中心偏右第一次調用所畫出的分支。結果圖像會頗有趣,可是它確定不是一棵樹。

回到遊戲

咱們如今已經瞭解了足夠多的畫布繪圖知識,咱們已經可使用基於畫布的顯示系統來改造前面幾章中開發的遊戲了。新的界面不會再是一個個色塊,而使用drawImage來繪製遊戲中元素對應的圖片。

咱們定義了一種對象類型,叫作CanvasDisplay,支持第 14 章中的DOMDisplay的相同接口,也就是setState方法與clear方法。

這個對象須要比DOMDisplay多保存一些信息。該對象不只須要使用 DOM 元素的滾動位置,還須要追蹤本身的視口(viewport)。視口會告訴咱們目前處於哪一個關卡。最後,該對象會保存一個filpPlayer屬性,確保即使玩家站立不動時,它面朝的方向也會與上次移動所面向的方向一致。

class CanvasDisplay {
  constructor(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.flipPlayer = false;

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

  clear() {
    this.canvas.remove();
  }
}

setState方法首先計算一個新的視口,而後在適當的位置繪製遊戲場景。

CanvasDisplay.prototype.setState = function(state) {
  this.updateViewport(state);
  this.clearDisplay(state.status);
  this.drawBackground(state.level);
  this.drawActors(state.actors);
};

DOMDisplay相反,這種顯示風格確實必須在每次更新時從新繪製背景。 由於畫布上的形狀只是像素,因此在咱們繪製它們以後,沒有什麼好方法來移動它們(或將它們移除)。 更新畫布顯示的惟一方法,是清除它並從新繪製場景。 咱們也可能發生了滾動,這要求背景處於不一樣的位置。

updateViewport方法與DOMDisplayscrollPlayerintoView方法類似。它檢查玩家是否過於接近屏幕的邊緣,而且當這種狀況發生時移動視口。

CanvasDisplay.prototype.updateViewport = function(state) {
  let view = this.viewport, margin = view.width / 3;
  let player = state.player;
  let 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,
                         state.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,
                        state.level.height - view.height);
  }
};

Math.maxMath.min的調用保證了視口不會顯示當前這層以外的物體。Math.max(x,0)保證告終果數值不會小於 0。一樣地,Math.min`保證了數值保持在給定範圍內。

在清空圖像時,咱們依據遊戲是獲勝(明亮的顏色)仍是失敗(灰暗的顏色)來使用不一樣的顏色。

CanvasDisplay.prototype.clearDisplay = function(status) {
  if (status == "won") {
    this.cx.fillStyle = "rgb(68, 191, 255)";
  } else if (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);
};

要畫出一個背景,咱們使用來自上一節的touches方法中的相同技巧,遍歷在當前視口中可見的全部瓦片。

let otherSprites = document.createElement("img");
otherSprites.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/img/sprites.png";

CanvasDisplay.prototype.drawBackground = function(level) {
  let {left, top, width, height} = this.viewport;
  let xStart = Math.floor(left);
  let xEnd = Math.ceil(left + width);
  let yStart = Math.floor(top);
  let yEnd = Math.ceil(top + height);
  for (let y = yStart; y < yEnd; y++) {
    for (let x = xStart; x < xEnd; x++) {
      let tile = level.rows[y][x];
      if (tile == "empty") continue;
      let screenX = (x - left) * scale;
      let screenY = (y - top) * scale;
      let tileX = tile == "lava" ? scale : 0;
      this.cx.drawImage(otherSprites,
                        tileX,         0, scale, scale,
                        screenX, screenY, scale, scale);
    }
  }
};

非空的瓦片是使用drawImage繪製的。otherSprites包含了描述除了玩家以外須要用到的圖片。它包含了從左到右的牆上的瓦片,火山岩瓦片以及精靈硬幣。

背景瓦片是20×20像素的,由於咱們將要用到DOMDisplay中的相同比例。所以,火山岩瓦片的偏移是 20,牆面的偏移是 0。

咱們不須要等待精靈圖片加載完成。調用drawImage時使用一幅並未加載完畢的圖片不會有任何效果。由於圖片仍然在加載當中,咱們可能沒法正確地畫出遊戲的前幾幀。可是這不是一個嚴重的問題,由於咱們持續更新熒幕,正確的場景會在加載完畢以後當即出現。

前面展現過的走路的特徵將會被用來代替玩家。繪製它的代碼須要根據玩家的當前動做選擇正確的動做和方向。前 8 個子畫面包含一個走路的動畫。當玩家沿着地板移動時,咱們根據當前時間把他圍起來。咱們但願每 60 毫秒切換一次幀,因此時間先除以 60。當玩家站立不動時,咱們畫出第九張子畫面。當豎直方向的速度不爲 0,從而被判斷爲跳躍時,咱們使用第 10 張,也是最右邊的子畫面。

由於子畫面寬度爲 24 像素而不是 16 像素,會稍微比玩家的對象寬,這時爲了騰出腳和手的空間,該方法須要根據某個給定的值(playerXOverlap)調整x座標的值以及寬度值。

let playerSprites = document.createElement("img");
playerSprites.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/img/player.png";
const playerXOverlap = 4;

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

  let tile = 8;
  if (player.speed.y != 0) {
    tile = 9;
  } else if (player.speed.x != 0) {
    tile = Math.floor(Date.now() / 60) % 8;
  }

  this.cx.save();
  if (this.flipPlayer) {
    flipHorizontally(this.cx, x + width / 2);
  }
  let tileX = tile * width;
  this.cx.drawImage(playerSprites, tileX, 0, width, height,
                                   x,     y, width, height);
  this.cx.restore();
};

drawPlayer方法由drawActors方法調用,該方法負責畫出遊戲中的全部角色。

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

當須要繪製一些非玩家元素時,咱們首先檢查它的類型,來找到與正確的子畫面的偏移值。熔岩瓷磚出如今偏移爲 20 的子畫面,金幣的子畫面出如今偏移值爲 40 的地方(放大了兩倍)。

當計算角色的位置時,咱們須要減掉視口的位置,由於(0,0)在咱們的畫布座標系中表明着視口層面的左上角,而不是該關卡的左上角。咱們也可使用translate方法,這樣能夠做用於全部元素。

這個文檔將新的顯示屏插入runGame中:

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

選擇圖像接口

因此當你須要在瀏覽器中繪圖時,你均可以選擇純粹的 HTML、SVG 或畫布。沒有惟一的最適合的且在全部動畫中都是最好的方法。每一個選擇都有它的利與弊。

單純的 HTML 的優勢是簡單。它也能夠很好地與文字集成使用。SVG 與畫布均可以容許你繪製文字,可是它們不會只經過一行代碼來幫助你放置text或者包裝它,在一個基於 HTML 的圖像中,包含文本塊更加簡單。

SVG 能夠被用來製造能夠任意縮放而仍然清晰的圖像。與 HTML 相反,它其實是爲繪圖而設計的,所以更適合於此目的。

SVG 與 HTML 都會構建一個新的數據結構(DOM),它表示你的圖片。這使得在繪製元素以後對其進行修改更爲可能。若是你須要重複的修改在一張大圖片中的一小部分,來對用戶的動做進行響應或者做爲動畫的一部分時,在畫布裏作這件事情將會極其的昂貴。DOM 也能夠容許咱們在圖片上的每個元素(甚至在 SVG 畫出的圖形上)註冊鼠標事件的處理器。在畫布裏則實現不了。

可是畫布的基於像素的方法在須要繪製大量的微小元素時會有優點。它不會構建新的數據結構而是僅僅重複的在同一個像素上繪製,這使得畫布在每一個圖形上擁有更低的消耗。

有一些效果,像在逐像素的渲染一個場景(好比,使用光線追蹤)或者使用 javaScript 對一張圖片進行後加工(虛化或者扭曲),只能經過基於像素的技術來進行真實的處理。在某些狀況下,你可能想要將這些技術整合起來使用。好比,你可能用 SVG 或者畫布畫出一個圖形,可是經過將一個 HTML 元素放在圖片的頂端來展現像素信息。

對於一些要求低的程序來講,選擇哪一個接口並無什麼太大的區別。由於不須要繪製文字,處理鼠標交互或者處理大量的元素。咱們在本章爲遊戲構建的顯示屏,能夠經過使用三種圖像技術中的任意一種來實現。

本章小結

在本章中,咱們討論了在瀏覽器中繪製圖形的技術,重點關注了<canvas>元素。

一個canvas節點表明了咱們的程序能夠繪製在文檔中的一片區域。這個繪圖動做是經過一個由getContext方法建立的繪圖上下文對象完成的。

2D 繪圖接口容許咱們填充或者拉伸各類各樣的圖形。這個上下文的fillStyle屬性決定了圖形的填充方式。strokeStylelineWidth屬性用來控制線條的繪製方式。

矩形與文字能夠經過使用一個簡單的方法調用來繪製。採用fillRectstrokeRect方法繪製矩形,同時採用fillTextstrokeText方法繪製文字。要建立一個自定義的圖形,咱們必須首先創建一個路徑。

調用beginPath會建立一個新的路徑。不少其餘的方法能夠向當前的路徑添加線條和曲線。好比,lineTo方法能夠添加一條直線。當一條路徑畫完時,它能夠被fill方法填充或者被stroke方法勾勒輪廓。

從一張圖片或者另外一個畫布上移動像素到咱們的畫布上能夠用drawImage方法實現。默認狀況下,這個方法繪製了整個原圖像,可是經過給它更多的參數,你能夠拷貝一張圖片的某一個特定的區域。咱們在遊戲中使用了這項技術,從包括許多動做的圖像中拷貝出遊戲角色的單個獨立動做。

圖形變換容許你向多個方向繪製圖片。2D 繪製上下文擁有一個當前的能夠經過translatescalerotate進行變換。這些會影響全部的後續的繪製操做。一個變換的狀態能夠經過save方法來保存,經過restore方法來恢復。

在一個畫布上展現動畫時,clearRect方法能夠用來在重繪以前清除畫布的某一部分。

習題

形狀

編寫一個程序,在畫布上畫出下面的圖形。

  1. 一個不規則四邊形(一個在一邊比較長的矩形)
  2. 一個紅色的鑽石(一個矩形旋轉45度角)
  3. 一個鋸齒線
  4. 一個由 100 條直線線段構成的螺旋
  5. 一個黃色的星星

當繪製最後兩個圖形時,你能夠參考第 14 章中的Math.cosMath.sin的解釋,它描述瞭如何使用這兩個函數得到圓上的座標。

建議你爲每個圖形建立一個方法,傳入座標信息,以及其餘的一些參數,好比大小或者點的數量。另外一種方法,能夠在你的代碼中硬編碼,會使得你的代碼變得難以閱讀和修改。

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

  // Your code here.
</script>

餅狀圖

在本章的前部分,咱們看到一個繪製餅狀圖的樣例程序。修改這個程序,使得每一個部分的名字能夠被顯示在相應的切片旁邊。試着找到一個合適的方法來自動放置這些文字,同時也能夠適用於其餘數據。你能夠假設分類大到足覺得標籤留出空間。

你可能還會須要Math.sinMath.cos方法,像第 14 章描述的同樣。

<canvas width="600" height="300"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  let currentAngle = -0.5 * Math.PI;
  let centerX = 300, centerY = 150;

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

彈力球

使用在第 14 章和第 16 章出現的requestAnimationFrame方法畫出一個裝有彈力球的盒子。這個球勻速運動而且當撞到盒子的邊緣的時候反彈。

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

  let 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>

預處理鏡像

當進行圖形變換時,繪製位圖圖像會很慢。每一個像素的位置和大小都必須進行變換,儘管未來瀏覽器可能會更加聰明,但這會致使繪製位圖所需的時間顯着增長。

在一個像咱們這樣的只繪製一個簡單的子畫面圖像變換的遊戲中,這個不是問題。可是若是咱們須要繪製成百上千的角色或者爆炸產生的旋轉粒子時,這將會成爲一個問題。

思考一種方法來容許咱們不須要加載更多的圖片文件就能夠畫出一個倒置的角色,而且不須要在每一幀調用drawImage方法。

相關文章
相關標籤/搜索