UFOを飛ばそう
ここからは、いよいよUFOゲームを作っていきます。
こういったゲームを作るとき、大きく分けて以下のふた通りの方法があります。
- UFOやキャノン砲をDOM要素として作成し、その位置をJavaScriptで動かす。
- 絵を描く領域としてキャンバス(canvas)を用意し、そこにJavaScriptで自力で絵を描く。
今回のUFOゲームは、別にどちらの方法でも作れると思いますが、今回は2番目の、キャンバスに自力で絵を描く方法とします。ゲームを作るのなら、こちらの方が応用の幅が広いのでは、と思います。
canvasを貼る
自由に絵を描くための領域として、HTMLにはcanvas要素という要素があります。ここでは、幅200ピクセル、高さ100ピクセルのcanvas要素を貼ってみましょう。
width(ウィドゥス:幅)属性、height(ハイト:高さ)属性で、幅と高さを指定しています。デフォルトでは背景色が白で、白地のページに貼ったのでは見えないので、背景色として黒を指定しています。閉じタグは省略できません。
width属性とheight属性の値には、「200px」のように「px」が付いていますが、これはピクセル(pixel)を意味します。ピクセルというのは、パソコンの画面を構成する点(ドット)のことです。パソコン(テレビやスマホでも同じですが)の画面は、たくさんの光る点で構成されていて、いまどきの普通のWindowsパソコンなら横方向に1920、縦方向に1080ぐらいのドットで構成されていると思います(この数字を解像度と言います)。
上のHTMLで、実際に貼ったのがこちら。
ではここに絵を描きます。まずは手始めに、長方形を書いてみましょう。
下のcanvasに長方形が描画されていると思います。単に画像を貼ったように見えるかもしれませんが、下の図2は、実際にJavaScriptで描画したcanvasです。
このcanvasにはfig2というid属性が付けられているので、リスト2の1行目でそのcanvasを取得し、2行目で、そのcanvasからgetContext("2d")により2次元描画用のコンテキスト(context)を取得しています。3行目でそのコンテキストのstrokeStyleに「白」という色を指定し、4行目で長方形を描画しています。なお、ここでは色をwhiteという色名で指定していますが、RGB(red, green, blue)の値を0~255で指定する方法もあります。リスト4で実演します。
4行目のstrokeRect()ですが、まずrect(レクト)はrectangleの略で、長方形という意味です。stroke(ストローク)は、「ペンや筆で、ひと筆で書いた動き」のような意味があります。なのでstrokeRect()で長方形が描けるわけです。
このstrokeRect()の引数は、20, 20, 30, 50となっていますが、これはそれぞれ、この長方形の左上のX座標、Y座標、この長方形の幅、高さです。
上の図3で、うすいグレーの領域がcanvasです。canvasで位置を指定するときに使う座標系は、左上が原点です。算数や数学で、正比例のグラフとかを書くときに使う座標系では、Y軸は上を向いていますが、canvasの座標系では下を向いていることに注意してください。
リスト2で描画している長方形は、左上の点のX座標とY座標がそれぞれ20、長方形の幅が30、高さが50であることがわかるでしょう。
「あれ? strokeStyleとやらで色に白を指定したはずなのに、図2の長方形、灰色に見えない?」と思った人がいるかもしれません。鋭いです。
実はcanvasの座標系では、X座標やY座標を整数にしたとき、その座標は、ドットとドットの間を意味します。そのため、切りのいい整数の座標で線を引くと、その線は、画面上の2ドットにまたがります。
2ドットにまたがるからといって、幅2ドットで線を引いてしまうと太く見えるので(線幅は自由に指定できますが、デフォルトは1ドット分です)、細く見せるために白を灰色にしているのです。よって、位置を0.5だけずらしてやれば、正しく1ドット分の線になり、灰色ではなく白で描画されます。以下の例では、長方形の左上が、X座標、Y座標ともに20だったのを、20.5にしました。
「それはさておき、canvasの最初の例がなんで長方形なの? 単純に、線を引いてみるとかじゃダメなの?」という声も聞こえてきそうですが、線を引くのはちょっと面倒だからです。まあ、やってみましょう。
リスト3にあるように、線を引くには、contextのbeginPath()メソッドで、まずパス(path)を開始します(3行目)。「begin」(ビギン)というのは、「始める」という意味です。その後、moveTo()メソッドで「ペン」を移動し(4行目)、lineTo()メソッドで線を描画します(5~6行目)。moveTo()を使えば、ペンを上げた状態で移動できるので、図6のように途中が途切れた線も引けます。ただし、lineTo()の時点ではまだ線は引けていなくて、11行目のstroke()の呼び出しで実際に線が引かれます。
別段難しくはないと思いますが、「線ぐらい、単純に始点と終点の座標を与えるだけで引けてもいいだろう」と思う人はいるように思います。実際、長方形は左上の座標と幅と高さを与えるだけで描けたわけですから、なぜ線を引くだけのことに「パス」などというものを使わなければいけないのか疑問に思うのは無理もありません。
ただ、パスには直線だけではなくて円弧も追加できますし、中を塗りつぶすこともできます。こういうのは、パスを使わないとうまく描けないでしょう。
contextって何だ
初めてcanvasを使ってみた人にとって、パス以上にその意味がわからないのは、「context」だと思います。canvasに絵を描くなら、strokeRect()とかlineTo()とかのメソッドがcanvasについていればいいのに、なんでわざわざcontextなるものを持ってこなければいけないのか、と。
まず、ここのプログラムでは「canvas.getContext("2d")」としてcontextを取得しています。この「2d」は2次元という意味で、このcontextを使えば2次元の図が描画できるということです。getContext()の引数には他にwebglとかwebgl2とかbitmaprendererが指定でき、たとえばwebglを指定すれば3次元のグラフィックスを表示できます。canvasにはこのように色々な方法で描画できるので、strokeRect()とかlineTo()とかのメソッドを直接canvasに付けるわけにはいきません。
context(コンテキスト)という名前も謎です。実のところこういうものをcontextと呼ぶのは、JavaScriptに限った話ではなくて、たとえばWindowsでC言語とかでグラフィックを扱うときはDevice Context(デバイスコンテキスト)というものを使いますし、LinuxなどのUNIXで使われているX WindowにはGraphics Context(グラフィックスコンテキスト)というものがあります。JavaScriptのcontextがstrokeStyleやlineWidthで現在の色とか線幅を保持しているのと同様、X WindowのグラフィックスコンテキストやWindowsのデバイスコンテキストも、そういった状態を保持します。英語でcontextといえば、文脈とか状況とかの意味があります。「現在の色」のようなものを文脈とか状況とかだと考えれば、この名前でよいのかもしれません(正直、私はよくわかっていませんが……)。
ところで、contextが現在の色とか線幅とかを覚えていてくれるものなら、contextを複数使えれば、場合によっては便利だと思います。ただ、JavaScriptのcanvasのgetContext()は、2回呼んでも、2回目に返すcontextは、1回目のcontextと同じものなので、複数を並行して使うことはできないようです。
アンチエイリアシング
図6で斜めの線を引いていますが、これを拡大すると、こんな感じになっています。
黒地に白で線を引いたはずが、灰色の点がかなりあります。これは、黒地に対して白1色で斜めの線を引くとドットのギザギザ(ジャギーといいます)が目立つので、灰色の点を打つことで目立たなくしているのです。この処理をアンチエイリアシング(anti-aliasing)と呼びます。
図2で、線が灰色になったのも、このアンチエイリアシングの効果です。
画像を表示する
長方形や線を描くのもよいですが、UFOゲームを作るならUFOの画像を表示できる必要があるでしょう。
まずUFOの画像を用意します(どっかで見たような形ですが…… オマージュです)。
私はこれをWindowsに付属のペイントで描きました。スタートメニューのWのところから、「Windowsアクセサリ」→「ペイント」で起動できます。
この画像のサイズは79×31ピクセルです。ペイントでは、(わかりにくいですが)メニューの「ファイル」の下の「プロパティ」から、画像のサイズの確認や変更ができます。また、画面右下のスライダで表示の拡大/縮小ができます。このサイズの絵を描くのなら、最大に拡大(800%)にするのがよいでしょう。背景は黒く塗っています。ゲームの画面の背景が黒いからです。透過画像にすれば、背景部分を透明にできますが、ペイントではそのような画像は作れません。
UFOが描けたら、「ファイル」→「名前を付けて保存」から、ufo.pngというファイル名で保存します。「ファイルの種類」のところが「PNG(*.png)」になっていることを確認してください。
こちらのページでは、パンケーキの写真を「.jpg」という拡張子で保存しています。それに対し、今回は「.png」です。拡張子が.jpgの画像はJPEG(ジェイペグ)というフォーマットで、.pngの画像はPNG(ピング)というフォーマットで保存されています。JPEGは写真に向いたフォーマットですが、この手の小さなドット絵はPNGの方が向いています(JPEGだと、ブロックノイズというノイズが乗って、ちょっと色が変になります)。
まあ、このUFOでよければ、絵を自分で描かなくても、目次のところからダウンロードできるzipファイルに.pngファイルを同梱していますが……
さて、では、この画像を、canvasに表示します。
画像をcanvasに表示するには、まず画像を読み込んで、それをcontextのdrawImage()メソッドでcanvasに表示します。具体的には以下のように書く――のですが、後で説明する通り、これではうまくいきません。
11~12行目で、UFOの画像を読み込んでいます。11行目で、「new Image()」と書くことで、画像の要素を作っています(HTMLにimg要素を書いて作られるものと同じです)。「newって何だ?」と思うかもしれませんが、ここでは深入りしません。12行目で、その画像のsrcプロパティに画像ファイルのパスを与えることで、画像の読み込みが開始されます。「./ufo.png」のようにカレントディレクトリを指定しているので、HTMLと同じフォルダにufo.pngを置いてください。
こうすれば、17行目のdrawImage()の呼び出しで、画像が表示される――はずなのですが、実際には、おそらく表示されなかったと思います。12行目で画像のパスを指定して、画像の読み込みが開始されましたが、まだ終わっていないためです。ブラウザでWebページを表示するとき、画像はちょっと遅れて表示されたりするでしょう。あれと同じことです。
これを解決するには、画像の読み込みが終了し、onloadメソッドが呼び出されたタイミングで画像を表示します。具体的には以下のようになります。
13行目で、画像のonloadに、drawUfo()関数を設定しています。これにより、画像のロードが終わったら、drawUfo()関数が呼び出されるようになります。ボタンのonclickに関数を設定したらボタンをクリックしたときにその関数が呼び出されたのと同じことです。そして、drawUfo()の中で、画像の描画を行います。
これで画像が表示されたと思います。
――ただし、この入門では、この「正しい方法」は取らず、あえて失敗した方の、lesson04_1.htmlの方法で進めようと思います。ここで作ろうとしているのはUFOゲームであり、UFOゲームではUFOが動きます。動くということは、場所を少しずつ変えながら何度も何度も書き直す、ということであり、何度も書いているうちに画像のロードは終わるので、すぐ表示されるようになるからです。最初の一瞬、何回かは表示されなかったのかもしれませんが、そんなのは人間の目にはばれないので、問題ありません。
なお、上のプログラムでは、drawImage()に3つの引数を渡しています。ひとつめは画像のオブジェクト、後の「10, 10」は画像の左上のX座標とY座標です。この座標を変えて何度も表示してやれば、UFOを動かすことができるわけです。
UFOを左から右に動かす
ではさっそくUFOを動かしてみます。
UFOゲームでは、UFOはランダムに(でたらめに)飛び回る予定ですが、いきなりそれは難しいので、まずは単純に、左から右に動かすことを考えます。
「drawImage()の引数でX座標、Y座標を渡しているのなら、for文で、X座標を増やしながら回してやれば、動かすことができるのでは?」と思うかもしれません。もっともです。
ものはためし、やってみましょう。19行目からのforループで、xを0から720まで(canvasの幅が800で、UFOの画像幅が79なので、右端の直前で止めています)変化させています。
ここをクリックすると、実行結果が開きます。こんなのが表示されたはずです。
UFOは動かず、右端にひとつだけ表示され、途中には、なにやら残骸が見えます。この「残骸」は、UFOを描いた後、消さずに次の座標にUFOを描いたので、前のUFOが一部残っているのです。
それはさておきUFOが動いて見えないのはなぜでしょうか。「いまどきのコンピュータは高速で動作するから、一瞬で右端まで行ってしまった」からでしょうか。それはまあ事実ではあるのですが、それだけでもありません。ためしに、左右に永久に動き続けるようにプログラムを変更してみましょう。