キャンバスへの描画

ブラウザはグラフィックを表示するいくつかの方法を提供しています。最も簡単な方法は、スタイルを使用して通常のDOM要素を配置し、色を付けることです。前の章のゲームで示したように、これによってかなりのことができます。ノードに半透明の背景画像を追加することで、思い通りの外観にすることができます。transform
スタイルを使用してノードを回転または歪めることも可能です。
しかし、これはDOMを本来の設計目的とは異なる用途に使用することになります。任意の点間に線を描くなど、一部のタスクは通常のHTML要素では非常に扱いにくいです。
代替手段は2つあります。1つ目はDOMベースですが、HTMLではなく*スケーラブルベクターグラフィックス*(SVG)を利用します。SVGは、テキストではなく図形に焦点を当てたドキュメントマークアップ言語と考えてください。SVGドキュメントは、HTMLドキュメントに直接埋め込むか、<img>
タグで含めることができます。
2つ目の代替手段は*キャンバス*です。キャンバスは、画像をカプセル化する単一のDOM要素です。ノードが占めるスペースに図形を描くためのプログラミングインターフェースを提供します。キャンバスとSVG画像の主な違いは、SVGでは図形の元の説明が保持されるため、いつでも移動またはサイズ変更できることです。一方、キャンバスは、図形が描画されるとすぐにピクセル(ラスター上の色のついた点)に変換し、これらのピクセルが何を表しているかを記憶しません。キャンバス上の図形を移動する唯一の方法は、キャンバス(または図形の周りのキャンバスの一部)をクリアし、図形を新しい位置に再描画することです。
SVG
本書では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名前空間*に変更します。URLで識別されるこの名前空間は、現在使用している言語を指定します。HTMLには存在しない<circle>
タグと<rect>
タグは、SVGでは意味を持ちます。これらのタグは、属性で指定されたスタイルと位置を使用して図形を描画します。
これらのタグは、HTMLタグと同様に、スクリプトが操作できるDOM要素を作成します。たとえば、これは<circle>
要素の色をシアンに変更します。
let circle = document.querySelector("circle"); circle.setAttribute("fill", "cyan");
canvas要素
キャンバスグラフィックは、<canvas>
要素に描画できます。このような要素にwidth
属性とheight
属性を指定して、ピクセル単位のサイズを決定できます。
新しいキャンバスは空です。つまり、完全に透明であり、ドキュメントでは空のスペースとして表示されます。
<canvas>
タグは、さまざまな描画スタイルを可能にすることを目的としています。実際の描画インターフェースにアクセスするには、最初に*コンテキスト*、つまりメソッドが描画インターフェースを提供するオブジェクトを作成する必要があります。現在、広くサポートされている描画スタイルは3つあります。2次元グラフィック用の"2d"
、OpenGLインターフェースを介した3次元グラフィック用の"webgl"
、そしてWebGLのより現代的で柔軟な代替手段である"webgpu"
です。
本書ではWebGLまたはWebGPUについては説明しません。2次元にとどまります。ただし、3次元グラフィックスに興味がある場合は、WebGPUを調べてみることをお勧めします。グラフィックハードウェアへの直接インターフェースを提供し、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>
コンテキストオブジェクトを作成した後、この例では、幅100ピクセル、高さ50ピクセルの赤い四角形を、左上隅の座標(10、10)に描画します。
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
属性が指定されていない場合、canvas要素はデフォルトの幅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次曲線を描画し、次にその制御点を通過して線の開始点に戻る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, Inc.に就職したばかりだとします。最初の課題は、顧客満足度調査の結果の円グラフを描くことです。
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π)を回答の総数で割り、その数(回答ごとの角度)に特定の選択肢を選んだ人の数を掛けて計算できます。
<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
を追加することもできます。
fillText
とstrokeText
の最後の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つの追加の引数を指定することもできます。
drawImage
に_9つ_の引数が指定されている場合、画像のフラグメントのみを描画するために使用できます。2番目から5番目の引数は、コピーする必要があるソース画像の長方形(x、y、幅、高さ)を示し、6番目から9番目の引数は、コピー先のキャンバス上の長方形を示します。
これは、複数の_スプライト_(画像要素)を単一の画像ファイルにパックし、必要な部分のみを描画するために使用できます。たとえば、この図には、複数のポーズのゲームキャラクターが含まれています

描画するポーズを交互に切り替えることで、歩いているキャラクターのように見えるアニメーションを表示できます。
キャンバス上の画像をアニメーション化するには、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)
を追加することはできません。それは私たちの画像をキャンバスの外に移動させ、そこでは見えなくなります。_x_位置0ではなく-50に画像を描画することにより、これを補償するためにdrawImage
に与えられた座標を調整できます。描画を行うコードがスケール変更を知る必要がない別の解決策は、スケーリングが行われる軸を調整することです。
scale
以外にも、キャンバスの座標系に影響を与えるメソッドがいくつかあります。rotate
メソッドで後続の描画された形状を回転し、translate
メソッドで移動できます。興味深い、そして混乱させることは、これらの変換が_スタック_されることです。つまり、それぞれが前の変換に対して発生します。
水平方向に10ピクセルを2回移動すると、すべてが右に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がある場所に配置されます。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回呼び出す(最初に左に回転し、次に右に回転する)ことで、樹木のような形状を描画します。呼び出しごとに描画される枝の長さが短くなり、長さが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>
`save` と `restore` の呼び出しがない場合、`branch` への2回目の再帰呼び出しは、最初の呼び出しによって作成された位置と回転で終了します。現在の枝ではなく、最初の呼び出しによって描画された最も内側の右端の枝に接続されます。結果の形状も面白いかもしれませんが、明らかに木ではありません。
ゲームに戻る
これで、前の章のゲームのキャンバスベースの表示システムの作業を開始するのに十分なキャンバス描画に関する知識が得られました。新しいディスプレイは、色のついたボックスだけを表示するだけではありません。代わりに、`drawImage` を使用してゲームの要素を表す画像を描画します。
`DOMDisplay` と同じインターフェース(つまり、`syncState` メソッドと `clear` メソッド)をサポートする `CanvasDisplay` と呼ばれる別のディスプレイオブジェクトタイプを定義します。第16章
このオブジェクトは、`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` メソッドは、`DOMDisplay` の `scrollPlayerIntoView` メソッドに似ています。プレイヤーが画面の端に近すぎるかどうかを確認し、その場合はビューポートを移動します。
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.max` と `Math.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` 画像には、プレイヤー以外の要素に使用される画像が含まれています。左から右に、壁タイル、溶岩タイル、コインのスプライトが含まれています。

`DOMDisplay` と同じスケールを使用するため、背景タイルは20 x 20ピクセルです。したがって、溶岩タイルのオフセットは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` メソッドは、ゲーム内のすべてのアクターを描画する `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) はレベルの左上ではなくビューポートの左上に対応するため、アクターの位置を計算するときはビューポートの位置を引く必要があります。これには `translate` を使用することもできました。どちらの方法でも機能します。
このドキュメントは、新しいディスプレイを `runGame` に接続します。
<body> <script> runGame(GAME_LEVELS, CanvasDisplay); </script> </body>
グラフィックインターフェースの選択
ブラウザでグラフィックを生成する必要がある場合は、プレーンHTML、SVG、キャンバスから選択できます。すべての状況で機能する単一の*最良*のアプローチはありません。各オプションには長所と短所があります。
プレーンHTMLには、シンプルであるという利点があります。また、テキストとの統合も良好です。SVGとキャンバスの両方でテキストを描画できますが、テキストの配置や、複数行にわたる場合の折り返しはできません。HTMLベースの画像では、テキストブロックを含める方がはるかに簡単です。
SVGを使用して、任意のズームレベルで見ても見栄えの良い鮮明なグラフィックを作成できます。HTMLとは異なり、描画用に設計されているため、その目的に適しています。
SVGとHTMLはどちらも、画像を表すデータ構造(DOM)を構築します。これにより、描画後に要素を変更することができます。ユーザーの操作やアニメーションの一部として、大きな画像のごく一部を繰り返し変更する必要がある場合、キャンバスで行うと不必要に費用がかかる可能性があります。DOMを使用すると、画像内のすべての要素(SVGで描画された形状でも)にマウスイベントハンドラーを登録することもできます。キャンバスではこれはできません。
しかし、canvasのピクセル指向のアプローチは、非常に多くの小さな要素を描画する場合に有利になることがあります。データ構造を構築するのではなく、同じピクセルサーフェスに繰り返し描画するという事実は、canvasの形状ごとのコストを低くします。また、シーンを1ピクセルずつレンダリングする(例えば、レイトレーサーを使用する)またはJavaScriptで画像を後処理する(ぼかしや歪みを加える)など、canvas要素でしか実用的でない効果もあります。
場合によっては、これらのテクニックをいくつか組み合わせる必要があるかもしれません。たとえば、SVGまたはcanvasでグラフを描画し、画像の上にHTML要素を配置することでテキスト情報を表示することができます。
要求の厳しいアプリケーションでない場合は、どのインターフェースを選択してもそれほど問題ありません。この章で作成したゲームの表示は、テキストの描画、マウスの操作の処理、非常に多くの要素の操作を行う必要がないため、これら3つのグラフィックステクノロジーのいずれかを使用して実装できます。
まとめ
この章では、ブラウザでグラフィックを描画するテクニックについて、<canvas>
要素を中心に説明しました。
canvasノードは、プログラムが描画できるドキュメント内の領域を表します。この描画は、getContext
メソッドで作成された描画コンテキストオブジェクトを介して行われます。
2D描画インターフェースを使用すると、さまざまな形状を塗りつぶしたり、線を引いたりできます。コンテキストのfillStyle
プロパティは、形状の塗りつぶし方法を決定します。strokeStyle
とlineWidth
プロパティは、線の描画方法を制御します。
矩形とテキストの一部は、1回のメソッド呼び出しで描画できます。fillRect
メソッドとstrokeRect
メソッドは矩形を描画し、fillText
メソッドとstrokeText
メソッドはテキストを描画します。カスタム形状を作成するには、まずパスを作成する必要があります。
beginPath
を呼び出すと、新しいパスが開始されます。他の多くのメソッドは、現在のパスに線と曲線を追加します。たとえば、lineTo
は直線を追加できます。パスが完了したら、fill
メソッドで塗りつぶすか、stroke
メソッドで線を引くことができます。
画像または別のcanvasからcanvasにピクセルを移動するには、drawImage
メソッドを使用します。デフォルトでは、このメソッドはソース画像全体を描画しますが、パラメータを追加することで、画像の特定の領域をコピーできます。ゲームでは、多くのポーズを含む画像からゲームキャラクターの個々のポーズをコピーすることで、これを使用しました。
変換を使用すると、複数の向きで形状を描画できます。2D描画コンテキストには、translate
、scale
、およびrotate
メソッドで変更できる現在の変換があります。これらは、後続のすべての描画操作に影響します。変換の状態は、save
メソッドで保存し、restore
メソッドで復元できます。
canvasにアニメーションを表示する場合、clearRect
メソッドを使用して、再描画する前にcanvasの一部をクリアできます。
演習
図形
canvasに次の図形を描画するプログラムを作成してください。

最後の2つの図形を描画する際には、第14章のMath.cos
とMath.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
し、次に回転し、次に元に戻す必要があります。
変換を作成する図形を描画した後、変換をリセットしてください。
ジグザグ(3)の場合、線分ごとにlineTo
を新たに呼び出すのは実用的ではありません。代わりに、ループを使用する必要があります。各反復で2つの線分(右に移動してから左に戻る)または1つの線分を描画できます。後者の場合、ループインデックスの偶数性(% 2
)を使用して、左に移動するか右に移動するかを決定する必要があります。
螺旋(4)にもループが必要です。螺旋の中心を中心とする円に沿って移動する一連の点を描画すると、円が得られます。ループ中に、現在の点を配置している円の半径を変更し、複数回回転すると、螺旋になります。
示されている星(5)は、quadraticCurveTo
線で構成されています。直線で描画することもできます。8つの点を持つ星の場合は円を8つの部分に分割します。これらの点の間に線を描き、星の의 중심に向かって曲線にします。quadraticCurveTo
では、中心を制御点として使用できます。
円グラフ
本章の前に、円グラフを描画するサンプルプログラムを見ました。各カテゴリの名前が、それを表すスライスの横に表示されるように、このプログラムを変更します。他のデータセットでも機能する、このテキストを自動的に配置するための見栄えの良い方法を見つけてください。カテゴリは、ラベルのための十分なスペースを残すのに十分な大きさであると想定できます。
14章で説明されているMath.sin
とMath.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
を呼び出し、コンテキストのtextAlign
プロパティとtextBaseline
プロパティを、テキストが目的の場所に配置されるように設定する必要があります。
ラベルを配置する賢明な方法は、円の中心からスライスの真ん中を通る線上にテキストを配置することです。テキストを円グラフの側面に直接配置するのではなく、指定されたピクセル数だけテキストを円グラフの側面に移動します。
この線の角度は、currentAngle + 0.
です。次のコードは、中心から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)
を呼び出します。これは、ゼロから全円よりも大きい円弧を作成します。次に、パスを塗りつぶします。
ボールの位置と速度をモデル化するには、16章のVec
クラス(このページで入手可能)を使用できます。開始速度を指定します。できれば、純粋に垂直または水平ではない速度を指定します。フレームごとに、経過した時間だけその速度を乗算します。ボールが垂直壁に近づきすぎると、速度の* x *成分が反転します。同様に、水平壁に当たると、* y *成分が反転します。
事前計算されたミラーリング
変換に関する1つの残念な点は、ビットマップの描画速度が低下することです。各ピクセルの位置とサイズを変換する必要があり、ブラウザが将来変換についてより賢くなる可能性はありますが、現在、ビットマップの描画にかかる時間が大幅に増加しています。
私たちのような、変換されたスプライトを1つだけ描画するゲームでは、これは問題ではありません。しかし、数百のキャラクターや、爆発による数千の回転するパーティクルを描画する必要があると想像してみてください。
追加の画像ファイルを読み込まず、フレームごとに`drawImage`で変換を行うことなく、反転したキャラクターを描画する方法を考えてみてください。
表示ヒント...
解決策の鍵は、`drawImage`を使用する際に、canvas要素をソース画像として使用できるという事実です。ドキュメントに追加することなく、追加の`<canvas>`要素を作成し、反転したスプライトを一度だけ描画することができます。実際のフレームを描画する際には、既に反転されたスプライトをメインキャンバスにコピーするだけです。
画像は瞬時に読み込まれないため、注意が必要です。反転描画は一度だけ行い、画像が読み込まれる前に行うと、何も描画されません。画像の`"load"`ハンドラを使用して、反転した画像を追加のキャンバスに描画できます。このキャンバスは、すぐに描画ソースとして使用できます(キャラクターが描画されるまでは空白になります)。