第3版が公開されました。こちらで読んでください

第16章
キャンバスへの描画

描くとは、欺くことである。

M.C. エッシャー、Bruno Ernst著「M.C. エッシャーの魔法の鏡」より引用

ブラウザには、グラフィックを表示するいくつかの方法があります。最も簡単な方法は、スタイルを使用して通常のDOM要素の位置と色を設定することです。前の章のゲームが示したように、これでもかなり遠くまで行くことができます。ノードに半透明の背景画像を追加することで、ノードを完全に思い通りの外観にすることができます。transformスタイルを使用することで、ノードを回転または傾斜させることも可能です。

しかし、本来の目的とは異なる用途にDOMを使用することになります。任意の点間に線を引くなど、いくつかのタスクは、通常のHTML要素では非常に扱いにくいです。

2つの代替案があります。1つ目はDOMベースですが、HTML要素ではなくScalable Vector Graphics(SVG)を使用します。SVGは、テキストではなく図形に焦点を当てたドキュメント記述のダイアレクトと考えてください。SVGドキュメントはHTMLドキュメントに埋め込むことも、<img>タグを介して含めることもできます。

2つ目の代替案はキャンバスと呼ばれます。キャンバスは、画像をカプセル化する単一のDOM要素です。ノードが占める空間に図形を描画するためのプログラミングインターフェースを提供します。キャンバスと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>要素の色がシアンに変わります。

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

キャンバス要素

キャンバスグラフィックは、<canvas>要素に描画できます。このような要素には、ピクセル単位のサイズを決定するwidth属性とheight属性を与えることができます。

新しいキャンバスは空で、つまり完全に透明であるため、ドキュメントでは単に空のスペースとして表示されます。

<canvas>タグは、さまざまな描画スタイルをサポートすることを目的としています。実際の描画インターフェースにアクセスするには、まずコンテキストを作成する必要があります。コンテキストは、描画インターフェースを提供するメソッドを持つオブジェクトです。現在、広くサポートされている描画スタイルには2つあります。2次元グラフィック用の"2d"と、OpenGLインターフェースを介した3次元グラフィック用の"webgl"です。

この本ではWebGLについては説明しません。2次元にとどまります。しかし、3次元グラフィックに興味がある場合は、WebGLを調べてみることをお勧めします。WebGLは最新のグラフィックハードウェアに非常に直接的なインターフェースを提供するため、JavaScriptを使用して複雑なシーンでも効率的にレンダリングできます。

コンテキストは、<canvas>要素のgetContextメソッドを介して作成されます。

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

コンテキストオブジェクトを作成した後、この例では、左上が(10,10)座標にあり、幅100ピクセル、高さ50ピクセルの赤い長方形を描画します。

HTML(およびSVG)と同様に、キャンバスが使用する座標系では(0,0)が左上にあり、正のy軸はそこから下に伸びています。そのため、(10,10)は左上の角から下方向と右方向に10ピクセルの位置です。

塗りつぶしと言葉

キャンバスインターフェースでは、図形を塗りつぶす(つまり、その領域に特定の色またはパターンを与える)か、線を描く(つまり、その縁に沿って線を引く)ことができます。SVGでも同じ用語が使用されます。

fillRectメソッドは長方形を塗りつぶします。まず長方形の左上の角のx座標とy座標、次に幅、次に高さを指定します。同様のメソッドであるstrokeRectは、長方形の輪郭を描画します。

どちらのメソッドも、それ以上のパラメーターを取りません。塗りつぶしの色、線の太さなどは、メソッドの引数によって決定されるのではなく(当然そう期待するかもしれませんが)、コンテキストオブジェクトのプロパティによって決定されます。

fillStyleを設定すると、図形の塗りつぶし方が変更されます。色を指定する文字列に設定でき、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ピクセルが設定されます。

パス

パスは線の一連です。2Dキャンバスインターフェースは、このようなパスを記述するために特殊なアプローチを取ります。それは完全に副作用を介して行われます。パスは、保存して渡すことができる値ではありません。パスの処理を行う場合は、その形状を記述するメソッド呼び出しを順番に行う必要があります。

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

この例では、塗りつぶされた三角形を描画します。三角形の辺のうち2つだけが明示的に描画されていることに注意してください。3番目の辺(右下隅から上に戻る辺)は暗黙的に示されており、パスに輪郭を描画しても表示されません。

closePathメソッドを使用して、パスの開始点に戻る実際の線分を追加することで、パスを明示的に閉じることもできます。この線分は、パスに輪郭を描画するときに描画されます。

曲線

パスには曲線も含まれる場合があります。残念ながら、これらは直線よりも描画がやや複雑です。

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)を制御点として、左から右に二次曲線を描き、次にその制御点を通って線の始点に戻る2つの線分を描画します。「スタートレック」のエンブレムに似ています。制御点の効果がわかります。下隅から伸びる線は、制御点の方向に伸びてから目標に向かってカーブします。

bezierCurveToメソッドは、同様の曲線を描画します。このメソッドには、1つの制御点ではなく、2つの制御点があります(線の両端のそれぞれに1つずつ)。このような曲線の挙動を示す同様の図を以下に示します。

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

2つの制御点は、曲線の両端の方向を指定します。対応する点から離れているほど、曲線はその方向に「膨らみ」ます。

このような曲線は扱いが難しい場合があります。求める形状を実現する制御点を見つける方法が必ずしも明確ではないからです。計算できる場合もあれば、試行錯誤で適切な値を見つけるしかない場合もあります。

円弧(円の一部)の方が扱いやすいです。arcToメソッドは5つ以上の引数を取ります。最初の4つの引数はquadraticCurveToの引数と多少似ています。最初のペアは一種の制御点を、2番目のペアは線の終点を指定します。5番目の引数は円弧の半径を指定します。このメソッドは概念的にコーナー(制御点へ向かう線、そして終点へ向かう線)を投影し、そのコーナー点を、指定された半径を持つ円の一部を形成するように丸めます。そして、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を4回呼び出すことができます(それぞれ90度回転)。しかし、arcメソッドの方が簡単な方法を提供します。これは円弧の中心の座標のペア、半径、開始角度と終了角度を取ります。

最後の2つのパラメータにより、円の一部だけを描画することができます。角度は度ではなくラジアンで測定されます。つまり、円全体は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への最初の呼び出し)から四分円(2番目の呼び出し)の右側までの線が含まれています。他のパス描画メソッドと同様に、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本の線で構成されています。各円弧が占める角度は、円全体(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を追加できます。

fillText(およびstrokeText)の最後の2つの引数は、フォントが描画される位置を指定します。デフォルトでは、テキストのアルファベットベースラインの開始位置を示します。これは、文字が「立っている」線であり、jやpのような文字の垂れ下がった部分は考慮されません。textAlignプロパティを"end"または"center"に設定することで水平位置を、textBaselineプロパティを"top""middle"、または"bottom"に設定することで垂直位置を変更できます。

円グラフと、セグメントにラベル付けする問題については、章末の演習で改めて取り上げます。

画像

コンピュータグラフィックスでは、ベクターグラフィックスとビットマップグラフィックスの区別がよくされます。前者は、この章でこれまで行ってきたこと、つまり形状の論理的な記述を与えることで画像を指定することです。一方、ビットマップグラフィックスは実際の形状を指定するのではなく、ピクセルデータ(着色された点のラスター)を扱います。

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は画像を元のサイズで描画します。異なる幅と高さを指定する2つの追加引数を渡すこともできます。

drawImage9つの引数が渡された場合、画像の一部だけを描画するために使用できます。2番目から5番目の引数は、コピーする必要があるソース画像内の長方形(x、y、幅、高さ)を示し、6番目から9番目の引数は、それをコピーする必要がある長方形(キャンバス上)を指定します。

これは、複数のスプライト(画像要素)を1つの画像ファイルにパックし、必要な部分だけを描画するために使用できます。たとえば、複数のポーズでゲームキャラクターが含まれているこの画像があります。

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の範囲にクリップされます。この変数は、現在のポーズのスプライトが画像内で持つx座標を計算するために使用されます。

変換

しかし、キャラクターを右ではなく左に歩かせたい場合はどうでしょうか?もちろん、別のスプライトセットを追加できます。しかし、キャンバスに画像を反対向きに描画するように指示することもできます。

scaleメソッドを呼び出すと、それ以降に描画されるものはすべてスケーリングされます。このメソッドは、水平スケールを設定するためのパラメータと垂直スケールを設定するためのパラメータの2つを取ります。

<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の場合、x位置100に描画された形状は、以前の位置-100に配置されます。

したがって、画像を反転させるには、drawImageの呼び出しの前にcx.scale(-1, 1)を追加するだけでは不十分です。なぜなら、それによって画像がキャンバスの外に移動し、表示されなくなるからです。画像をx位置0ではなく-50に描画することで、これに対応するためにdrawImageに渡される座標を調整できます。スケール変更を知る必要がある描画コードを必要としない別の解決策は、スケーリングの中心となる軸を調整することです。

scale以外にも、キャンバスの座標系に影響を与えるメソッドがいくつかあります。rotateメソッドを使用して、その後描画される形状を回転させることができ、translateメソッドを使用して移動させることができます。興味深い点、そして混乱する点としては、これらの変換が積み重なることです。つまり、各変換は前の変換を基準に行われます。

したがって、水平方向に10ピクセルずつ2回移動すると、すべてが右側に20ピクセル移動して描画されます。最初に座標系のセンターを(50,50)に移動し、次に20度(ラジアンで0.1π)回転させると、その回転は点(50,50)を中心に行われます。

Stacking transformations

しかし、最初に20度回転してからその後(50,50)だけ平行移動すると、平行移動は回転後の座標系で行われるため、異なる向きになります。変換を適用する順序は重要です。

与えられたx座標の位置にある垂直線を中心に画像を反転するには、次のようにします。

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

ミラーにしたい位置にy軸を移動し、ミラーリングを適用し、最後にy軸をミラーリングされた宇宙の適切な位置に戻します。次の図はこの方法がなぜ機能するかを示しています。

Mirroring around a vertical line

これは、中心線を中心としたミラーリングの前後の座標系を示しています。正のx座標の位置に三角形を描くと、デフォルトでは三角形1の位置になります。`flipHorizontally`の呼び出しは最初に右への平行移動を行い、三角形2になります。次にスケーリングを行い、三角形を位置3に反転させます。これは、指定された線でミラーリングした場合の位置ではありません。2番目の`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>

変換の保存とクリア

変換は保持されます。ミラーリングされた文字を描画した後に描画する他のすべてもミラーリングされます。これは問題になる可能性があります。

現在の変換を保存し、いくつかの描画と変換を行い、古い変換を復元することができます。これは、座標系を一時的に変換する必要がある関数に対して通常行うべきことです。まず、関数を呼び出したコードで使用されていた変換を保存します。次に、関数は(既存の変換の上に)その処理を行い、さらに変換を追加する場合があります。最後に、開始時の変換に戻ります。

2Dキャンバスコンテキストの`save`メソッドと`restore`メソッドは、この種の変換管理を実行します。概念的には、変換状態のスタックを保持します。`save`を呼び出すと、現在の状態がスタックにプッシュされ、`restore`を呼び出すと、スタックの一番上の状態が取り除かれ、コンテキストの現在の変換として使用されます。

次の例にある`branch`関数は、変換を変更してから別の関数(この場合は自分自身)を呼び出し、指定された変換で描画を続ける関数で何ができるかを示しています。

この関数は、線を描画し、座標系の中心を線の終点に移動し、自身を2回呼び出すことでツリーのような形状を描画します。最初に左に回転し、次に右に回転します。各呼び出しで描画される枝の長さが短くなり、長さが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`への2回目の再帰呼び出しは、最初の呼び出しによって作成された位置と回転になります。現在の枝に接続されるのではなく、最初の呼び出しによって描画された一番内側の右端の枝に接続されます。結果の形状も興味深いものになるかもしれませんが、間違いなく木ではありません。

ゲームに戻る

これで、前の章のゲームのキャンバスベースの表示システムに取り組むのに十分なキャンバス描画の知識が得られました。新しい表示では、単色のボックスだけが表示されなくなります。代わりに、`drawImage`を使用して、ゲームの要素を表す画像を描画します。

15章の`DOMDisplay`と同じインターフェース、つまり`drawFrame`メソッドと`clear`メソッドをサポートする`CanvasDisplay`というオブジェクト型を定義します。

このオブジェクトは`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`カウンターは、15章で`DOMDisplay`は使用しなくても`drawFrame`にステップサイズを渡した理由です。新しい`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)`は、結果の数値が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);
    }
  }
};

空でない(nullではない)タイルは`drawImage`で描画されます。`otherSprites`画像は、プレイヤー以外の要素に使用される画像を含んでいます。左から右に、壁タイル、溶岩タイル、コインのスプライトが含まれています。

Sprites for our game

背景タイルは20 x 20ピクセルです。これは`DOMDisplay`で使用したのと同じスケールを使用するためです。したがって、溶岩タイルのオフセットは20(`scale`変数の値)、壁のオフセットは0です。

スプライト画像の読み込みを待つ手間は省きます。読み込まれていない画像で`drawImage`を呼び出すと、単に何も行われません。したがって、画像の読み込み中は最初の数フレームでゲームを正しく描画できない可能性がありますが、これは深刻な問題ではありません。画面を更新し続けるため、読み込みが完了するとすぐに正しいシーンが表示されます。

前に示した歩いているキャラクターは、プレイヤーを表すために使用されます。それを描画するコードは、プレイヤーの現在の動きに基づいて正しいスプライトと方向を選択する必要があります。最初の8つのスプライトには歩行アニメーションが含まれています。プレイヤーが床を移動している場合、表示の`animationTime`プロパティに基づいてそれらを入れ替えます。これは秒単位で測定され、1秒間に12回フレームを切り替えたいので、最初に時間は12倍されます。プレイヤーが静止している場合は、9番目のスプライトを描画します。垂直速度がゼロでないことで認識されるジャンプ中は、一番右の10番目のスプライトを使用します。

スプライトはプレイヤーオブジェクトよりもわずかに幅が広い(16ピクセルではなく24ピクセル)ため、足や腕のためのスペースを確保するために、メソッドはx座標と幅を指定された量(`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(`scale`の2倍)にあります。

キャンバス上の(0,0)はレベルの左上ではなくビューポートの左上に対応するため、アクターの位置を計算する際にはビューポートの位置を引く必要があります。`translate`を使用しても機能します。どちらの方法でも問題ありません。

次に示す小さなドキュメントは、新しい表示を`runGame`に接続します。

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

グラフィックインターフェースの選択

ブラウザでグラフィックを生成する必要がある場合、プレーンHTML、SVG、キャンバスのいずれかを選択できます。すべての状況で機能する単一の最良のアプローチはありません。各オプションには長所と短所があります。

プレーンHTMLはシンプルであるという利点があります。また、テキストともよく統合されます。SVGとキャンバスの両方でテキストを描画できますが、テキストの位置合わせや、複数の行にわたる場合の折り返しには役立ちません。HTMLベースの画像では、テキストブロックを簡単に含めることができます。

SVGは、あらゆるズームレベルで美しく見える鮮明なグラフィックを作成するために使用できます。プレーンHTMLよりも使いにくいですが、はるかに強力です。

SVGとHTMLの両方で、画像を表すデータ構造(DOM)が構築されます。これにより、描画後に要素を変更することができます。ユーザーの操作に応じて、またはアニメーションの一部として、大きな画像の一部分を繰り返し変更する必要がある場合、キャンバスで行うことは無駄にコストが高くなる可能性があります。DOMを使用すると、画像内のすべての要素(SVGで描画されたシェイプでも)にマウスイベントハンドラを登録できます。キャンバスではそれはできません。

しかし、キャンバスのピクセル指向のアプローチは、膨大な数の小さな要素を描画する場合に利点となる可能性があります。データ構造を構築せず、同じピクセルサーフェスに繰り返し描画するだけであるという事実は、キャンバスのシェイプあたりのコストを低くします。

また、シーンを1ピクセルずつレンダリングする(たとえば、レイ トレーサーを使用する)や、JavaScriptで画像を後処理する(ぼかしや歪ませる)など、ピクセルベースの手法でしか現実的に処理できない効果もあります。

いくつかのケースでは、これらのテクニックを組み合わせたいと思うかもしれません。例えば、SVGやcanvasを使ってグラフを描画し、画像の上にHTML要素を配置することでテキスト情報を表示することができます。

要求の厳しいアプリケーションでない限り、どのインターフェースを選択するかはそれほど重要ではありません。この章で作成したゲームのセカンドディスプレイは、テキストを描画したり、マウス操作を処理したり、非常に大量の要素を扱う必要がないため、これらの3つのグラフィックテクノロジーのいずれかを使用して実装できました。

概要

この章では、ブラウザでグラフィックを描画するためのテクニックについて説明し、<canvas>要素に焦点を当てています。

canvasノードは、プログラムが描画できるドキュメント内の領域を表します。この描画は、getContextメソッドで作成された描画コンテキストオブジェクトを通じて行われます。

2D描画インターフェースを使用すると、さまざまな形状を塗りつぶしたり、ストロークしたりすることができます。コンテキストのfillStyleプロパティは、形状の塗りつぶし方法を決定します。strokeStyleおよびlineWidthプロパティは、線の描画方法を制御します。

長方形とテキストの部分は、単一のメソッド呼び出しで描画できます。fillRectおよびstrokeRectメソッドは長方形を描画し、fillTextおよびstrokeTextメソッドはテキストを描画します。カスタムシェイプを作成するには、最初にパスを作成する必要があります。

beginPathを呼び出すと、新しいパスが開始されます。他のいくつかのメソッドは、現在のパスに線や曲線を追加します。例えば、lineToは直線を追加できます。パスが完成したら、fillメソッドで塗りつぶしたり、strokeメソッドでストロークしたりできます。

画像または別のcanvasからピクセルを私たちのcanvasに移動させるには、drawImageメソッドを使用します。デフォルトでは、このメソッドはソース画像全体を描画しますが、より多くのパラメータを与えることで、画像の特定の領域をコピーできます。ゲームでは、多くのポーズが含まれている画像からゲームキャラクターの個々のポーズをコピーするためにこれを使用しました。

変換を使用すると、複数の向きで形状を描画できます。2D描画コンテキストには、translatescalerotateメソッドで変更できる現在の変換があります。これらは、後続のすべての描画操作に影響します。変換状態はsaveメソッドで保存し、restoreメソッドで復元できます。

canvasにアニメーションを描画する場合、clearRectメソッドを使用して、canvasの一部をクリアしてから再描画できます。

練習問題

図形

次の図形をcanvas上に描くプログラムを作成してください。

  1. 台形(片側が広い長方形)

  2. 赤い菱形(45度または¼πラジアン回転させた長方形)

  3. ジグザグ線

  4. 100本の直線セグメントで構成された渦巻き

  5. 黄色の星

The shapes to draw

最後の2つの図形を描画する際には、これらの関数を使用して円上の座標を取得する方法について説明している13章Math.cosMath.sinの説明を参照することをお勧めします。

各図形に対して関数を作成することをお勧めします。位置を、必要に応じてサイズや点数などの他のプロパティをパラメータとして渡してください。コード全体に数値をハードコードする代替案は、コードの可読性と変更性を不必要に難しくする傾向があります。

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

  // Your code here.
</script>

台形(1)はパスを使用して簡単に描画できます。適切な中心座標を選択し、その周囲に4つの角のそれぞれを追加します。

菱形(2)は、パスを使用して簡単に描画することも、rotate変換を使用して興味深い方法で描画することもできます。回転を使用するには、flipHorizontally関数で行ったものと同様のトリックを適用する必要があります。長方形の中心ではなく点(0,0)を中心に回転させる必要があるため、最初にそこへtranslateし、回転してから、元に戻す必要があります。

ジグザグ(3)の場合、各線分に対してlineToへの新しい呼び出しを記述することは非現実的になります。代わりに、ループを使用する必要があります。各反復で2つの線分(右、左)または1つの線分を描画し、その場合、ループインデックスの偶奇(% 2)を使用して左右どちらに移動するかを決定する必要があります。

渦巻き(4)にもループが必要です。各点が渦巻きの中心を中心とした円に沿ってさらに移動する一連の点を描画すると、円が得られます。ループ中に、現在の点を配置する円の半径を変化させ、複数回回ると、渦巻きになります。

描かれている星(5)は、quadraticCurveTo線で構成されています。直線で描くこともできます。円を8つの部分、または星にしたい点の数だけ分割します。これらの点間に線を描き、星の中心に向かって曲げます。quadraticCurveToを使用すると、中心を制御点として使用できます。

円グラフ

この章の前半では、円グラフを描画するプログラムの例を示しました。このプログラムを修正して、各カテゴリの名前を、それを表すスライス(扇形)の横に表示するようにしてください。他のデータセットでも機能する、自動的にテキストの位置を調整する見栄えの良い方法を見つけてください。カテゴリは5%より小さくはないと仮定できます(つまり、非常に小さなものがたくさん隣り合うことはありません)。

前の練習問題で説明したように、Math.sinMath.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には、このアプローチを使用する場合は"middle"の値が適切です。textAlignに使用するものは、円のどちら側にいるかによって異なります。左側の場合は"right"、右側の場合は"left"にする必要があります。これにより、テキストがパイから離れた位置に配置されます。

特定の角度が円のどちら側にあるかを判断する方法がわからない場合は、前の練習問題のMath.cosの説明を参照してください。角度の余弦は、対応するx座標を知らせ、それによって円のどちら側にあるかを正確に示します。

跳ね回るボール

13章15章で見たrequestAnimationFrameテクニックを使用して、跳ね回るボールが入ったボックスを描画します。ボールは一定の速度で移動し、側面に当たるとボックスの側面から跳ね返ります。

<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で簡単に描画できます。そのサイズを保持する変数を定義するか、ボックスの幅と高さが異なる場合は2つの変数を定義します。丸いボールを作成するには、パスを開始し、arc(x, y, radius, 0, 7)を呼び出します。これは、0から円全体よりも多くの円弧を作成し、塗りつぶします。

ボールの位置と速度をモデル化するには、15章Vector型(このページで使用できます)を使用できます。純粋に垂直または水平ではない開始速度を与え、フレームごとに経過した時間量でその速度を掛けます。ボールが垂直の壁に近づきすぎたら、速度のx成分を反転させます。同様に、水平の壁に当たったらy成分を反転させます。

ボールの新しい位置と速度を見つけた後、clearRectを使用してシーンを削除し、新しい位置を使用して再描画します。

プリコンパイルされたミラーリング

変換の残念な点の1つは、ビットマップの描画速度を低下させることです。ベクターグラフィックの場合、円の中心など、変換する必要があるのは少数の点のみであるため、その後は通常どおり描画できるため、その影響はそれほど深刻ではありません。ビットマップ画像の場合、各ピクセルの位置を変換する必要があり、ブラウザがこの点で今後さらに賢くなる可能性はありますが、現在ではビットマップを描画するのにかかる時間に測定可能な増加を引き起こします。

私たちのような、単一の変換済みスプライトしか描画しないゲームでは、これは問題になりません。しかし、爆発からの数百ものキャラクターや数千もの回転するパーティクルを描画する必要があると想像してみてください。

追加の画像ファイルを読み込むことなく、また、毎フレーム変換されたdrawImage呼び出しを行うことなく、反転したキャラクターを描画できるようにする方法を考えてみましょう。

解決策の鍵は、drawImageを使用する際に、キャンバス要素をソース画像として使用できるという事実です。ドキュメントに追加することなく、余分な<canvas>要素を作成し、反転したスプライトを一度そこに描画することができます。実際のフレームを描画する際には、既に反転されたスプライトをメインキャンバスにコピーするだけです。

画像は瞬時にロードされないため、注意が必要です。反転した描画は一度だけ行い、画像のロード前に実行すると何も描画されません。画像の「load」ハンドラーを使用して、反転した画像を余分なキャンバスに描画できます。このキャンバスはすぐに描画ソースとして使用できます(キャラクターをそこに描画するまでは、単に空白です)。