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

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

描くことは欺瞞である。

M.C. エッシャー, Bruno Ernst著「M.C. エッシャーの魔法の鏡」より引用
Picture of a robot arm drawing on paper

ブラウザには、グラフィックを表示するためのいくつかの方法があります。最も簡単な方法は、スタイルを使用して通常の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>要素の色がシアンに変更されます。

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

キャンバス要素

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

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

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

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

<canvas> DOM要素のgetContextメソッドを使用してコンテキストを作成します。

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

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

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

線と面

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

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

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

fillStyleプロパティは、図形の塗りつぶし方法を制御します。CSSで使用されている色表記を使用して色を指定する文字列に設定できます。

strokeStyleプロパティは同様に機能しますが、線で描画される色を決定します。その線の幅は、正の数を指定できる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>
<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に渡された位置から始まります。

パスを塗りつぶす場合(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>

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

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)を制御点として使用してから、その制御点を通る2つの線分を描画し、線の開始点に戻ります。その結果、ややスタートレックのエンブレムに似ています。制御点の効果を確認できます。下隅から出ている線は、制御点の方向から始まり、ターゲットに向かって曲線を描きます。

bezierCurveToメソッドは、同様の曲線を描画します。単一の制御点ではなく、このメソッドは2つの制御点(線の両端のそれぞれ1つ)を使用します。このような曲線の動作を示す同様のスケッチを次に示します。

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

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

このような曲線は扱いにくい場合があります。探している形状を提供する制御点をどのように見つけるかは常に明確ではありません。計算できる場合もあれば、試行錯誤で適切な値を見つける必要がある場合もあります。

arcメソッドは、円周に沿って曲がる線を引く方法です。円弧の中心座標のペア、半径、開始角度、終了角度を指定します。

最後の2つのパラメータにより、円のうち一部だけを描画できます。角度は度ではなくラジアンで測定されます。つまり、円周は2π、または2 * Math.PIの角度を持ち、約6.28になります。角度の計測は円の中心の右側の点から始まり、そこから時計回りにカウントされます。開始を0、終了を2πより大きく(例えば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呼び出しの右側の点から、四分円を描画した2回目の呼び出しの右側の点まで線が引かれています。他のパス描画メソッドと同様に、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"}
];

円グラフを描画するには、複数の円弧セグメントを描画します。各セグメントは、円弧と、その円弧の中心への2本の線で構成されます。各円弧の角度は、円周(2π)を回答の総数で割って、その数(回答あたりの角度)を特定の選択肢を選んだ人の数で乗算することで計算できます。

<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キャンバス描画コンテキストは、fillTextメソッドとstrokeTextメソッドを提供します。後者は文字の輪郭を描くのに役立ちますが、通常はfillTextが必要です。これは、現在のfillStyleで指定されたテキストの輪郭を塗りつぶします。

<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の最後の2つの引数は、フォントを描画する位置を示します。デフォルトでは、テキストのアルファベットベースラインの開始位置を示します。ベースラインとは、文字が「立つ」線で、`j`や`p`などの文字のはみ出した部分は含まれません。textAlignプロパティを"end"または"center"に設定することで水平位置を、textBaselineプロパティを"top""middle"、または"bottom"に設定することで垂直位置を変更できます。

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

画像

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

drawImageメソッドを使用すると、ピクセルデータをキャンバスに描画できます。このピクセルデータは、<img>要素または別のキャンバスから取得できます。次の例では、分離した<img>要素を作成し、そこに画像ファイルを読み込みます。しかし、ブラウザがまだ読み込んでいない可能性があるため、この画像からの描画をすぐに開始することはできません。これに対処するために、"load"イベントハンドラを登録し、画像の読み込み後に描画を行います。

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

デフォルトでは、drawImageは画像を元のサイズで描画します。異なる幅と高さを設定する追加の2つの引数を指定することもできます。

drawImage9つの引数を渡すと、画像の一部のみを描画するために使用できます。2番目から5番目の引数は、コピーするソース画像内の長方形(x、y、幅、高さ)を示し、6番目から9番目の引数は、コピーする先の長方形(キャンバス上)を示します。

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

Various poses of a game character

描画するポーズを切り替えることで、歩いているキャラクターのように見えるアニメーションを表示できます。

キャンバス上の画像をアニメーション化するには、clearRectメソッドが役立ちます。fillRectに似ていますが、長方形を塗りつぶす代わりに透明にし、以前に描画されたピクセルを削除します。

スプライト、つまり各サブ画像は、幅24ピクセル、高さ30ピクセルであることを知っています。次のコードは、画像を読み込み、次に次のフレームを描画する間隔(繰り返しタイマー)を設定します。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "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バインディングは、アニメーションにおける位置を追跡します。各フレームでインクリメントされ、剰余演算子を使用して0〜7の範囲にクリップされます。このバインディングは、現在のポーズのスプライトが画像内で持つx座標を計算するために使用されます。

変換

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

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

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

スケーリングを行うと、線幅を含む描画された画像のすべてが、指定されたように引き伸ばされたり、圧縮されたりします。負の値でスケーリングすると、画像が反転します。反転は点(0,0)を中心に発生するため、座標系の方向も反転します。水平方向のスケーリングに-1を適用すると、x位置100に描画された形状は、以前の-100の位置になります。

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

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>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "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キャンバスコンテキストのsaveメソッドとrestoreメソッドは、この変換管理を行います。これらは概念的に変換状態のスタックを保持しています。saveを呼び出すと、現在の状態がスタックにプッシュされ、restoreを呼び出すと、スタックの一番上の状態が取り除かれ、コンテキストの現在の変換として使用されます。変換を完全にリセットするには、resetTransformを呼び出すこともできます。

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

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

ゲームに戻る

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

16章DOMDisplayと同じインターフェース、つまりsyncStateメソッドとclearメソッドをサポートする、CanvasDisplayと呼ばれる別の表示オブジェクト型を定義します。

このオブジェクトは、DOMDisplayよりも多くの情報を保持しています。DOM要素のスクロール位置を使用するのではなく、独自のビューポートを追跡します。これにより、現在見ているレベルの部分がわかります。最後に、flipPlayerプロパティを保持しているので、プレイヤーが静止していても、最後に移動した方向を向き続けます。

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();
  }
}

syncStateメソッドは、最初に新しいビューポートを計算し、適切な位置にゲームシーンを描画します。

CanvasDisplay.prototype.syncState = 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)は、結果の数がゼロ未満にならないようにします。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 = "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画像は、プレイヤー以外の要素に使用される画像が含まれています。左から順に、壁のタイル、溶岩のタイル、コインのスプライトが含まれています。

Sprites for our game

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

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

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

スプライトはプレイヤーオブジェクトよりもわずかに幅が広く(足と腕のスペースを確保するために16ピクセルではなく24ピクセル)、メソッドはx座標と幅を指定された量(playerXOverlap)調整する必要があります。

let playerSprites = document.createElement("img");
playerSprites.src = "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メソッドは、ゲーム内のすべてのactorを描画する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(scaleの2倍)にあります。

キャンバス上の(0,0)はレベルの左上ではなくビューポートの左上に対応するため、actorの位置を計算する際にビューポートの位置を差し引く必要があります。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またはキャンバスでグラフを描画し、画像の上にHTML要素を配置してテキスト情報を表示することができます。

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

まとめ

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

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

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

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

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

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

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

キャンバスにアニメーションを表示する際には、clearRect メソッドを使用して、キャンバスの一部を消去してから再描画できます。

練習問題

図形

キャンバス上に次の図形を描画するプログラムを作成してください。

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

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

  3. ジグザグ線

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

  5. 黄色の星

The shapes to draw

最後の2つの図形を描画する際には、これらの関数を使用して円上の座標を取得する方法について説明している14章Math.cosMath.sinの説明を参照すると役立つ場合があります。

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

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

  // Your code here.
</script>

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

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

変換を作成する図形を描画した後は、変換を必ずリセットしてください。

ジグザグ線(3)については、各線分に対してlineToへの新しい呼び出しを記述するのは非現実的です。代わりに、ループを使用する必要があります。各反復で2つの線分(右、次に左)または1つの線分を描画できます。後者の場合、ループインデックスの偶奇(% 2)を使用して、左に行くか右に行くかを判断する必要があります。

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

描かれている星(5)は、quadraticCurveTo 線で構成されています。直線を使用して描画することもできます。8点の星の場合は円を8つの部分に分割するか、必要な数の部分に分割します。これらの点間に線を引いて、星のセンターに向かって曲げます。quadraticCurveToを使用すると、センターを制御点として使用できます。

円グラフ

この章の前の方で、円グラフを描画するプログラムの例を見ました。このプログラムを修正して、各カテゴリの名前がそれを表すスライスに隣接して表示されるようにしてください。他のデータセットでも機能する、自動的にテキストの位置を調整する魅力的な方法を見つけてください。カテゴリは十分に大きく、ラベルに十分なスペースを残していると仮定できます。

14章で説明されているMath.sinMath.cosが必要になる場合があります。

<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.
  for (let result of results) {
    let 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を呼び出し、コンテキストのtextAligntextBaselineプロパティを適切に設定する必要があります。

ラベルの位置を調整する賢明な方法は、円の中心からスライスの真ん中を通る線上にテキストを配置することです。テキストをパイの側面に直接配置するのではなく、テキストをパイの側面に一定のピクセル数移動します。

この線の角度はcurrentAngle + 0.5 * sliceAngleです。次のコードは、中心から120ピクセルの位置にあるこの線上の点を見つけます。

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

textBaselineの場合、このアプローチを使用する場合は、値"middle"が適切です。textAlignに何をすべきかは、円のどちら側にいるかによって異なります。左側では"right"、右側では"left"にする必要があります。これにより、テキストがパイから離れて配置されます。

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

跳ねるボール

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>

ボックスはstrokeRectを使用して簡単に描画できます。そのサイズを保持するバインディングを定義するか、ボックスの幅と高さが異なる場合は2つのバインディングを定義します。丸いボールを作成するには、パスを開始し、arc(x, y, radius, 0, 7)を呼び出します。これにより、0から円全体よりも多くの円弧が作成されます。次に、パスを塗りつぶします。

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

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

事前計算されたミラーリング

変換に関する不幸な点の1つは、ビットマップの描画速度が低下することです。各ピクセルの位置とサイズを変換する必要があり、ブラウザが将来変換についてより賢くなる可能性がありますが、現在、ビットマップを描画するのにかかる時間に測定可能な増加を引き起こしています。

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

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

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

画像は即座に読み込まれないため、注意が必要です。反転した描画は1回だけ行い、画像の読み込み前にこれを行うと、何も描画されません。画像の"load"ハンドラーを使用して、反転した画像を追加のキャンバスに描画できます。このキャンバスは、すぐに描画ソースとして使用できます(キャラクターを描画するまで、単に空白になります)。