ブロック崩しを作る (2)
今回は配列の話がメインです。
ループ内の処理を関数に分ける
前回はパドルを左右キーで動かすところまで作りました。すべてをmain関数の中に置いて、特にwhileループの中に大部分のコードを詰め込んでいました。
ところで、main関数の中にはwhileループがwhile (running)というところとwhile (SDL_PollEvent(&event)というところの2箇所に出てきて紛らわしいです。今話しているのはこのうちの外側のwhile (running)の方です。こういうゲームの進行全体の単位になるループのことはよくメインループとかゲームループと呼ばれます。そして、このループ1回分の処理のことをフレームと呼ぶことが多いです。混乱しないようにこれからこのような呼び名を使うようにします。
このままmain関数の中のメインループの場所にゲームのコードを追加していくことも不可能ではないです。しかし、パドルをただ左右に動かすだけでこの分量になっていて、これからボールを動かす処理や、ブロックに玉が当たったらブロックが消えるようにする処理などを追加していくとあまりに長くなりすぎることは目に見えています。なので、今のうちに手を打って処理を関数に分割することを検討しておきます。
現時点のコードを眺めてすぐに思いつくのは、パドルの位置を更新する部分と、更新されたパドルの位置を元に描画を行う部分の処理を抜きだすことです。これをupdate関数とrender関数として分けることにしてみます。すると全体は次のようなコードになるでしょう。
...
void update()
{
// キーボード入力を元にパドルの位置を更新
}
void render(SDL_Renderer* renderer)
{
// パドルを描画
}
int main()
{
...
while (running) {
// イベント処理
...
update();
renderer(renderer);
SDL_Delay(16);
}
...
}
こうしてみるとmain関数に全部書いていたのよりはだいぶスッキリして見えます。しかし、このままこのように書き直すことは出来ません。update関数では左右キーの状態を調べて、パドルの位置を更新します。一方render関数はそのパドルの位置を元に描画を行います。つまり、update関数とrender関数の間で情報をやり取りする必要があります。メインループはその仲介をすることになります。updateが更新するのはパドルのx座標のみなので、必要な変数は一つだけです。書き直してみます。
...
void update(int* paddle_x)
{
// キーボードの状態を調べる
*paddle_x = ...; // パドルの位置を更新
}
void render(SDL_Renderer* renderer, int paddle_x)
{
// paddle_xの位置にパドルを描画
}
int main()
{
int paddle_x = ...; // パドルの初期位置
while (running) {
...
update(&paddle_x);
renderer(renderer, paddle_x);
SDL_Delay(16);
}
...
}
うまく行きそうですがrenderer関数はもう少し多くの情報が必要になります。パドルを表す矩形を描画するには次のようなコードを書いていました。
SDL_SetRenderDrawColor(renderer, 255, 128, 255, 255);
SDL_Rect rect = {x, y, paddle_width, paddle_height};
SDL_RenderFillRect(renderer, &rect);
rectのxは今paddle_xという引数で渡すようにしたのでOKです。残りのyとpaddle_widthとpaddle_heightは固定で、ゲーム中に変化することがありません。なのでrender関数の中に書き込んでしまうことも出来ます。
void render(SDL_Renderer* renderer, int paddle_x)
{
const int paddle_width = 60;
const int paddle_height = 20;
const int paddle_y = ScreenHeight - paddle_height * 2;
SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 255, 128, 255, 255);
SDL_Rect rect = {paddle_x, paddle_y, paddle_width, paddle_height};
SDL_RenderFillRect(renderer, &rect);
SDL_RenderPresent(renderer);
}
このやり方はあまりいいとは思えません。もし、パドルの幅や高さを変更したくなっても、render関数を書き換える以外方法がなくなってしまいます。それにrender関数は描画に関する処理だけを行うべきであって、パドルの情報を保持し続けるのは役割を間違えてます。関数の引数として受け取るようにしたらどうかも試してみます。
void render(SDL_Renderer* renderer, int paddle_x, int paddle_y,
int paddle_width, int paddle_height)
{
SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 255, 128, 255, 255);
SDL_Rect rect = {paddle_x, paddle_y, paddle_width, paddle_height};
SDL_RenderFillRect(renderer, &rect);
SDL_RenderPresent(renderer);
}
これでrender関数の呼び出し側からパドルの状態を指示できるようになりました。しかし、引数の数が多くなってます。よく見るとrennder関数にはパドルの色の情報も埋め込まれています。これも引数にしたほうがいいでしょう。色はSDL_Color構造体で1つの変数にまとめることが出来ます。
void render(SDL_Renderer* renderer, int paddle_x, int paddle_y,
int paddle_width, int paddle_height, const SDL_Color* paddle_color)
{
SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, paddle_color->r, paddle_color->g,
paddle_color->b, paddle_color->a);
SDL_Rect rect = {paddle_x, paddle_y, paddle_width, paddle_height};
SDL_RenderFillRect(renderer, &rect);
SDL_RenderPresent(renderer);
}
これでrender関数はパドルの情報を全て外部から与えられるようになりました。しかし、今はパドル一つですがブロック崩しにはたくさんのブロックがあります。そのために引数を追加していかなければならないかと考えると憂鬱になります。このrender関数のやっていることは、
- バックバッファをクリアする。
- 引数で与えられた情報を元にパドルを描画する。
- 画面に反映させる。
という3つに分けられます。このうち真ん中のパドルを描画するという処理は、結局、色を指定して矩形を塗りつぶすというだけです!この処理はパドルに限らず、ブロックやボール(まだ円にせず四角形をボールに見立てて使うつもりなので)を描くという処理にも使えるはずです。もし、引数にパドルに限定されずにゲーム上に存在するすべてのもの共通の情報を与えられれば、このrender関数の中にパドルを描画して、ブロックを描画して、ボールを描画して、と埋め込まなくても済むようになります。そこで、
void render(SDL_Renderer* renderer, すべてのブロック)
{
SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
SDL_RenderClear(renderer);
すべてのブロックを描画;
SDL_RenderPresent(renderer);
}
のように書けないかを検討してみます。
配列
描画に必要な情報は、一つのブロックにつき、座標と矩形のサイズと色の情報です。座標とサイズはSDL_Rect構造体でまとめて保持できます。なので必要なのは矩形と色の情報だけです。これを構造体を使ってまとめると、
struct Brick {
SDL_Rect rect;
SDL_Color color;
};
と書けます。これをまとめたものが必要になります。あるもののまとまりを表現する方法として配列を使うことが出来ます。ブロックを表現するBrickを複数、例えば10個、まとめた配列は次のように書けます。
Brick bricks[10];
これは「bricksはサイズ(要素数)10の型Brickの配列」と読むことが出来ます。配列の各要素の番号、インデックスは0から9、すなわちbricks[0]からbricks[9]のようにしてアクセスできます。これで10個のブロックが確保されました。しかし、その中身は未初期化のままです。宣言と同時に0に初期化する方法があります。
Brick bricks[10] = {};
あるいは、
Brick bricks[10]{};
と書くことが出来ます。上の2つがまったく同じ意味というわけではないのですが、結果としてははbricksの各要素のすべては適当な初期値、Brick型の場合は0で初期化されます。
この配列は便利そうですが一つ大きな制約があります。それは配列のサイズに関わるもので、ひとつはサイズがコンパイル時に分かっていないといけないことです。
int n = 10;
Brick bricks[n];
とすることはできません、と言いたいところですが、GCCはこれを受け入れます。-pedanticという怪しげな名前のオプションを付けることで警告を出させるように出来ます。上のコードをg++ -pedantic -o bricks bricks.cpp
としてコンパイルすると警告が出ます。エラーにはなりませんでした(これはあまり望ましくない挙動です)。
g++ -pedantic -o bricks bricks.cpp
pedantic.cpp:11:19: warning: ISO C++ forbids variable length array ‘bricks’ [-Wvla]
Brick bricks[n];
配列のサイズをコンパイル時定数、つまり定数式とすることで、解決できます。
const int n = 10;
Brick bricks[n];
こうすると-pedanticオプションをつけても警告はでません(これは望ましい挙動です)。
このタイプの配列は固定長配列と呼ばれます。この配列の生存期間はスコープに依存します。つまりローカル変数と同じで、スタック領域に確保されるので、関数の外にデータを持ち出すことは出来ません。自動的に削除されてしまうのが困るならば次の動的配列を使います。
動的配列
固定長配列とは別の配列として、動的配列と呼ばれるものがあります。これはnew[]を使って確保します。
Brick* bricks = new Brick[10];
これもまた未初期化のままです。もし0になっていたとしてもたまたまそうなっていただけです。確実に0で初期化するには、
Brick* bricks = new Brick[10]{};
のように書くことが出来ます。
この動的配列と固定長配列の一番の違いは、通常のnewで確保したオブジェクトの生存期間がスコープに限定されないという以外に、コンパイル時に初期サイズが分かっていなくてもいいということです。例えば次のように書くことが出来ます。
Brick* new_bricks(int n)
{
return new Brick[n]{};
}
つまり、[]の中のサイズを変数で指定することが出来ます。newで確保したので、通常のオブジェクトをnewで生成した場合と同様にdeleteで解放しなければいけないです。ただし、配列のdeleteはdelete[]演算子を使った奇妙な書き方をします。
void delete_bricks(Brick* bricks)
{
delete[] bricks;
}
もう一つの制約は、一度決めたサイズは後からサイズを変更することも出来ないことです。上の場合、一度10個と決めたので後から11個めのブロックを付け足したくなったら[10]のところを[11]に書き換えた新しい配列を用意する以外にありません。これは固定長配列にも動的配列にも言えることで、その意味では両方とも固定長です。「固定長」に対し、new[]で確保する配列が「可変長」と、対称になっていない理由はここにあるのでしょう。結局のところ、両方共やっていることは指定された要素数から必要なメモリ量を計算して(指示があれば初期化して)、それを用意してくれるというところまでです。
現実のプログラムでは、あとで好きなように追加したりできないと困る場合が多いです。動的配列を使ってこれをやるには結構多くのことをやらないといけないです。適当に書いてみました。
#include <SDL2/SDL.h>
#include <algorithm>
struct Brick {
SDL_Rect rect;
SDL_Color color;
};
// 配列の未使用の部分にブロックを追加する
void push_brick(Brick** array, int* size, int* last, const Brick* brick)
{
// lastの位置がサイズを超えていたら
if (*last >= *size) {
// 元の配列の2倍のサイズの配列を確保
Brick* fresh_array = new Brick[(*size) * 2];
// 新しい配列に元の配列の値をコピー
std::copy(*array, (*array) + *size, fresh_array);
*size *= 2; // サイズ情報を更新
delete[] *array;
// 元の配列を指していていたポインタが新しい配列を指すように更新する
*array = fresh_array;
}
(*array)[*last] = *brick; // 末尾にブロックを追加
++*last;
}
int main()
{
Brick* bricks = new Brick[10];
int size = 10;
int last = 0;
for (int i = 0; i < 100; ++i) {
Brick brick{};
push_brick(&bricks, &size, &last, &brick);
}
delete[] bricks;
SDL_Log("%d %d", size, last); //=>160 100
}
こんな馬鹿げたコードは書かないにしても、問題点は配列そのものを指すポインタとは別に配列の許容量、現在使用済みの位置を記憶して置かなければならず、配列そのものの外に情報を散らかしてしてしまう点です。分散を避けるために、構造体にまとめることも出来ます。
struct BrickArray {
int capcity;
int used;
Brick* data;
};
void push_brick(BrickArray* array, const Brick* brick);
これでだいぶ使いやすくはなります。しかし、わざわざ自分で書かなくてもC++には遥かに使いやすく洗練されたものが標準ライブラリとして用意されています。
std::vector
ほとんどの場合、サイズが未定で作成した時点より後でサイズを伸長出来なければいけない配列には、標準ライブラリに用意されているstd::vectorを使います。new[]で配列を確保するのはメモリを意識してデータを扱う比較的低レベルな操作や、パフォーマンス上の理由からそうしないといけない場合など特別な事情がある場合にした方がいいです。しかし、使い方と動作にやや癖があるので注意しないといけないところでもあります。ちなみにこのvectorという妙に数学的な名前はCommon Lispが由来だそうです。new[]で確保した配列は動的配列ではあるものの可変長配列とは呼べないものでした。std::vectorは本物の可変長の配列として使えます。std::vectorは多くの機能があります。もっとも使用頻度の高いのは末尾に要素を追加するpush_back()と要素にアクセスするための[]による添字演算です。
push_back()の例を書いてみます。
#include <SDL2/SDL.h>
#include <vector>
struct Brick {
SDL_Rect rect;
SDL_Color color;
};
int main()
{
Brick brick;
brick.rect = {0, 0, 100, 100};
brick.color = {0, 0, 255, 255}; // 青
// 空のvectorを作成後push_back()を使って追加
std::vector<Brick> bricks;
for (int i = 0; i < 100; ++i) {
bricks.push_back(brick); // コピーを追加
}
SDL_Log("%lu", bricks.size()); //=>100
}
std::vector<Brick>では、<T>のように追加で型の情報が必要になります。このTはstd::vectorの中に格納する値の型です。std::vectorを始めとする標準ライブラリの値の集合を扱うデータ構造は、コンテナと呼ばれ、テンプレートによって実現されています。そのため、std::vectorやstd::listのようなコンテナに加え、関連する各種アルゴリズムなどはStandard Template Library、略してSTLと呼ばれています。
他に2つほど例を上げておきます。
初期サイズと初期値を指定して作成する例。
// 初期サイズ(10)と初期値(brick)を指定して作成
std::vector<Brick> bs{10, brick};
for (auto i = 0ul; i < bs.size(); ++i) {
// 添字で要素にアクセスできる
SDL_Log("%d %d %d %d, %d %d %d %d",
bs[i].rect.x, bs[i].rect.y, bs[i].rect.w, bs[i].rect.h,
bs[i].color.r, bs[i].color.g, bs[i].color.b, bs[i].color.a);
//=>0 0 100 100, 0 0 255 255 X10
}
初期化リストを使った例。
// 初期化リストを使って4つのbrickで初期化
std::vector<Brick> another_bricks = { brick, brick, brick, brick };
for (auto& b : another_bricks) {
// 範囲for文が使える
SDL_Log("%d %d %d %d, %d %d %d %d",
b.rect.x, b.rect.y, b.rect.w, b.rect.h,
b.color.r, b.color.g, b.color.b, b.color.a);
//=>0 0 100 100, 0 0 255 255 X 4
}
範囲for文とautoと左辺値参照を使ってますので、簡単に説明すると、範囲for文はコンテナの要素を1つずつ順番に取り出しながら最後の要素までループします。autoは変数の型を自動で判別してくれます。
&による左辺値参照、あるいは単に参照、は例えば、int& a = x;と書いたとき、オブジェクトのコピーではなく、ポインタのようにそのオブジェクトを参照する値を取るようになります。しかし、ポインタのように直接アドレスを操作することは出来ません。一度初期化したら参照先を変更することも出来ません。a = 42;と書いたとき、xの値は42になります。参照は、単に変数の別名のように作用します。参照を使用することで、関数の引数などに変数を引き渡すとき無駄なコピーを避けることができることと、関数の引数や上のように範囲for文などで、変更を参照先に反映させることができること、といった効果が得られます。ポインタを使える場所で参照を使える場面は多くあります。どちらを使用するべきかという厳密な決まりはありません。慣れてくればこの関数の引数は参照にしたほうがいいという感覚はつかめるようになってきます。
容量を増やすことができる以外にもstd::vectorを使う利点はたくさんあります。その1つはnew[]で作成した動的配列のようにdelete[]する必要がないことです。std::vectorのオブジェクトの生存期間はローカル変数と同じです。スコープを抜けた時点で破棄されます。内部的に使用している要素のためのメモリはその時点で開放されるようになっています。具体的にはデストラクタと呼ばれる自動的に呼び出されるメンバ関数のメカニズムによって解放されます。他にも別のstd::vectorに代入が可能なこと、関数の返り値としてムーブ演算によって効率的に使うことができること、各種STLアルゴリズムの利用に適していること、そして、地味ながら重要なのは、サイズについての情報を持っているので、別の変数として管理する必要がないこと、などが挙げられます。これからは配列の必要なシーンでは積極的にstd::vectorを使っていくことにします。
std::vectorをゲームをゲームのコードに組み込む
話をゲームに戻します。さっきはrenderという関数を作成している途中でした。やりたいことは、render関数にパドルの他にもボールやブロックを処理させたいので引数として渡せないか、ということでした。std::vectorを使って先のコードで必要な部分だけ書き換えていきます。
まずメインループの外でブロックを初期化します。
vector<Brick> bricks = setup_bricks();
setup_bricks関数は後で考えることにします。今はこの関数はゲームのパドル、ボール、ブロックの初期状態を作って返してくるものとしておきます。
次にメインループの中でupdate関数とrender関数にこのbircksを渡します。
update(bricks);
render(renderer, bricks);
ここで、update関数とrender関数でのbricksの引き渡しには参照を使うことに変更したので、&bircksのようにアドレスを渡していない点に注意して下さい。
update関数とrender関数は次のように内容にします。
void update(vector<Brick>& bricks)
{
キーボード入力を処理する
for (auto& brick : bricks) {
パドルの位置を更新
ボールの位置を更新
ボールがパドルに当たったか判定
ボールが壁に判定判定
ボールがブロックにあたったか判定
ボールが当たったブロックを消す
}
}
void render(SDL_Renderer* renderer, vector<Brick>& bricks)
{
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
for (auto& brick : bricks) {
パドル、ボール、ブロックを描画
}
SDL_RenderPresent(renderer);
}
update関数ではパドルとボールとブロックそれぞれについて、別々の処理を書かなければいけません。なのでbricksに含まれるものの中からそれがどの種類のものなのかを判定できなければいけません。よくある方法としては、Brick構造体にtypeというフィールドを設けて、パドルなら1、ボールなら2、ブロックなら3というふうにして番号で判別する方法です。もう一つのよくある方法は、C++の継承と仮想関数の仕組みを使って処理をそれ自身の中に記述することです。今回は最初の方法を取ることにします。ただし、typeではなくtagという名前にして文字列で判定することにします。そのために、Bricks構造体を少し変えます。
struct Brick {
std::string tag;
SDL_Rect rect;
SDL_Color color;
}
std::stringはstd::vectorに似ているのですが、文字列を扱うように特化されています。C++では組み込み型の文字列というものは存在しません。文字列リテラルはCから引き継いだ、文字型の配列として表現されます。std::stringはそのC文字列とうまく連携できるようになってます。詳しくはまた別の機会にすることにして今回はあまり詳しくは述べないでおきます。
この新しいtagというフィールドを使って、update関数ではBrickの型が何を表しているのか判定することにします。
void update(vector<Brick>* bricks)
{
キーボード入力を処理する
for (auto& brick : *bricks) {
if (brick.tag == "paddle") {
パドルの位置を更新
} else if (brick.tag == "ball") {
ボールの位置を更新
ボールがパドルに当たったか判定
ボールが壁に判定判定
ボールがブロックにあたったか判定
} else if (brick.tag == "wall") {
ボールが当たったブロックを消す
}
}
}
render関数ではどれも四角形を描くだけで違いはないので判定は必要ないです。これらを全部まとめて書き直します。
#include <SDL2/SDL.h>
#include <vector>
#include <string>
using namespace std;
const int ScreenWidth = 400;
const int ScreenHeight = 400;
struct Brick {
string tag;
SDL_Rect rect;
SDL_Color color;
};
void update(vector<Brick>& bricks)
{
const double move_scale = 3.0;
int dx = 0;
auto key_state = SDL_GetKeyboardState(nullptr);
if (key_state[SDL_SCANCODE_LEFT]) {
dx -= 1;
}
if (key_state[SDL_SCANCODE_RIGHT]) {
dx += 1;
}
for (auto& brick : bricks) {
if (brick.tag == "paddle") {
brick.rect.x += move_scale * dx;
} else if (brick.tag == "ball") {
//~ ボールの位置を更新
//~ ボールがパドルに当たったか判定
//~ ボールが壁に判定判定
//~ ボールがブロックにあたったか判定
} else if (brick.tag == "brick") {
//~ ボールが当たったブロックを消す
}
}
}
void render(SDL_Renderer* renderer, vector<Brick>& bricks)
{
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
for (auto& brick : bricks) {
SDL_SetRenderDrawColor(renderer, brick.color.r, brick.color.g,
brick.color.b, brick.color.a);
SDL_RenderFillRect(renderer, &brick.rect);
}
SDL_RenderPresent(renderer);
}
vector<Brick> setup_bricks()
{
vector<Brick> bricks;
// setup paddle
Brick paddle;
paddle.tag = "paddle";
paddle.rect.w = 50;
paddle.rect.h = 10;
paddle.rect.x = ScreenWidth / 2 - paddle.rect.w / 2;
paddle.rect.y = ScreenHeight - 2 * paddle.rect.h;
paddle.color = {222, 222, 255, 255};
bricks.push_back(paddle);
// setup ball
Brick ball;
ball.tag = "ball";
ball.rect.w = paddle.rect.h * 1.2;
ball.rect.h = ball.rect.w; // square
ball.rect.x = paddle.rect.x + (paddle.rect.w / 2) - (ball.rect.w / 2);
ball.rect.y = paddle.rect.y - ball.rect.h;
ball.color = {255, 128, 200, 255};
bricks.push_back(ball);
// setup bricks
const int space = 4;
const int margin = 20;
const int columns = 15;
const int rows = 20;
const int brick_width
= (ScreenWidth - (2 * margin) - ((columns - 1) * space)) / columns;
const int brick_height = brick_width * 0.3;
for (int y = 0; y < rows; ++y) {
for (int x = 0; x < columns; ++x) {
Brick brick;
brick.tag = "brick";
brick.rect = {x * (brick_width + space) + margin,
y * (brick_height + space) + margin,
brick_width, brick_height};
Uint8 r = (columns - x) * (255 / columns);
Uint8 g = (y + 1) * (255 / rows);
Uint8 b = (x + 1) * (255 / columns);
brick.color = {r, g, b, 255};
bricks.push_back(brick);
}
}
return bricks;
}
int main()
{
if (SDL_Init(SDL_INIT_VIDEO) != 0) {
SDL_Log("SDL_Init() error: %s", SDL_GetError());
exit(1);
}
SDL_Window* window = SDL_CreateWindow("Bricks SDL 2", 0, 0,
ScreenWidth, ScreenHeight, 0);
if (window == nullptr) {
SDL_Log("SDL_CreateWindow() error: %s", SDL_GetError());
exit(1);
}
SDL_Renderer* renderer
= SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
if (renderer == nullptr) {
SDL_Log("SDL_CreateRenderer() error: %s", SDL_GetError());
exit(1);
}
vector<Brick> bricks = setup_bricks();
bool running = true;
while (running) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
running = false;
} else if (event.type == SDL_KEYDOWN) {
switch (event.key.keysym.sym) {
case SDLK_ESCAPE:
case SDLK_RETURN:
running = false;
break;
default:
break;
}
}
}
update(bricks);
render(renderer, bricks);
SDL_Delay(16);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
}
setup_bricks関数の中で色々やってます。ブロックの位置の調整と、見栄えを良くするために色の調整をやっているのが主な内容です。実行すると次のような画面になります。
今回はこれで終わっておきます。