Code of Poem
Unknown programmer's programming note.

ブロック崩しを作る (1)

SDLの使い方がだいたいさわりだけでも分かったので簡単なゲームを作っていきます。

最初に作るのは定番のブロック崩しにします。長くなりそうなので何回かに分けていきます。

ブロックを描く

前回はSDL_Surfaceを使ってウィンドウの中身を青で塗りつぶしていました。

SDL_Surface* surface = SDL_GetWindowSurface(window);
SDL_FillRect(surface, nullptr, SDL_MapRGB(surface->format, 0, 0, 255));

このコードでは、ウィンドウのサーフェイスをSDL_GetWindowSurface関数で取得して、SDL_FillRect関数で第2引数に指定した矩形、この場合はnullptrを指定しているのでサーフェイス全体、を青色で塗りつぶしています。

サーフェイスに関連するAPI関数のリストを見てみると、矩形を塗りつぶす以外にはBMPをロードする関数だとか、別のサーフェイスに転送する関数だとか使えそうなのがいくつか見当たります。少なくとも矩形を塗りつぶすことはできるので、四角形だけで作られたブロック崩しならこのサーフェイスだけを使って作ることもできそです。一応もう少しドキュメントを調べてみることにします。

描画に関連しそうなものとして、2D Accelerated Renderingというカテゴリがあり、そこにはSDL_RendererとSDL_Textureというのがリストアップされています。一体どのように使い分けるのかを検索したらこことかここが見つかりました。これを簡単にまとめると、

SDL_Window
そのまんま、ウィンドウの情報をまとめた構造体。
SDL_Renderer
レンダリングに関連する操作をすべて担当する。ウィンドウに関連付けられている。
SDL_Texture
ハードウェアレンダリングに使われる。VRAMに置かれる。直接アクセスできない。→レンダラーを通して操作する。
SDL_Surface
ソフトウェアレンダリングに使われる。イメージのデータはメインメモリに置かれる。直接アクセスできる。

こんな感じになります。ブロック崩しを作るにあたって、SDL_Rendererを使うか、SDL_Surfaceを使うかという選択肢があります(SDL_TextureはSDL_Rendererを使わなければ使えないので、SDL_Rendererに含まれるものとします)。最大の違いはSDL_Rendererの方は「2D Accelerated Rendering」と銘打っている通り、ハードウェアで処理を行うことで、おそらく内部的にはDirect3DやOpenGLを通して処理が行われているものと推測されます。ハードウェアレンダリングを行うことの違い以外にもあらかじめ用意されている描画用の関数にも違いがあり、SDL_Rendererの方には、点を描画するとか、直線を描画するといった関数があります。SDL_Surfaceの方にはこのような関数はありません。SDL_Surfaceはピクセルデータに直接アクセスできるので不要なのかと思います。

今回作るブロック崩しではそれほど高速なレンダリング能力は必要ないのですが、サンプルコードなどを見る限り、SDL_Rendererを使っても特にコードが複雑になるわけでもなく、あえて機能も限られていて処理速度も遅くなるSDL_Surfaceの方を使う理由も見当たらないため、SDL_Rendererの方を使うことにします。

まず前回のウィンドウの中を塗りつぶすプログラムをレンダラーを使ったものに置き換えておきます。

#include <SDL2/SDL.h>

const int ScreenWidth = 400;
const int ScreenHeight = 400;

int main()
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        SDL_Log("SDL_Init() error: %s", SDL_GetError());
        exit(1);
    }
    
    SDL_Window* window = SDL_CreateWindow(
            "Drawing Square SDL Renderer",
            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);
    }
    
    bool running = true;
    while (running) {
        SDL_Event event;
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT) {
                running = false;
            }
        }
        
        SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
        SDL_RenderClear(renderer);
        SDL_RenderPresent(renderer);

        SDL_Delay(16);
    }
    
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
}

ハイライトしてあるところが前回から変更したところです。使っている関数へのリンクを貼っておきます。

SDL_CreateRenderer関数

SDL_CreateRenderer関数の引数は分かりづらいです。第1引数にSDL_Windowへのポインタであるwindowを渡しています。SDL_CreateWindowはウィンドウと強く関連付けられていて、この第1引数で指定したウィンドウに対してレンダリングを行います。一つのウィンドウにつき一つのレンダラーしか作成できません。2つ目を作ろうとするとエラーになります。SDL_GetRendererという関数もあり、これは引数で指定されたウィンドウに対してすでに作られたレンダラーを返します。まだ作られていなければNULLになります。第2引数は初期化するレンダリングドライバのインデックスということで、謎です。-1だと最初のドライバを使うということなので-1にしとけば大体問題ないという程度にしか理解していません。第3引数はフラグですが、これはビットOR演算で複数指定できます。SDL_RENDERER_ACCELERATEDはハードウェアをアクセラレーションを使うなら必要なのでまずこれを指定します。他は、SDL_RENDERER_SOFTWAREはソフトウェアレンダリングを行う場合、SDL_RENDERER_PRESENTVSYNCはディスプレイのリフレッシュレートと同期させる場合、SDL_RENDERER_TARGETTEXTUREはテクスチャへのレンダリングをサポートする場合、つまりSDL_SetRenderTarget関数を使う場合、に指定します。試してないので実際にどのような違いが出るのか確認してません。今回作るゲームではSDL_RENDERER_ACCELERATEDのみで十分そうです。

SDL_SetRenderDrawColor関数

レンダリングで使用する色を指定します。この関数自体は何も描画しません。この関数を呼び出した後の描画を行う関数がここで指定した色を使うことになります。描画を行うたびにいちいち色を指定して切り替えないといけないのは面倒に思えますが、ハードウェアによる高速化の恩恵を受けるためには仕方のないことと割り切ります。引数は、第1引数がレンダラーで、第2、第3、第4、第5、と順にRGBAの値の赤、緑、青、アルファを0から255で指定します。

SDL_RenderClear関数

レンダリングターゲット(今はウィンドウ)全体をセットされた色で「初期化」します。結果的には塗りつぶしたのと同じ効果になりました。上のコードではSDL_SetRenderDrawColor関数で青にセットしたので、青で全体が塗りぶされます。次のSDL_RendererPresent関数を呼び出したとき、バックバッファが無効になっているのでこの関数は常に呼び出すようにした方が無難かと思われます。

SDL_RenderPresent関数

レンダラリングの関数を使って描画した内容を、画面に反映させます。SDL_RenderClear関数などを始めとしたレンダリングの関数はすべてバックバッファに対してレンダリングが行われます。バックバッファの内容はこのSDL_RenderPresent関数を呼び出すまで画面に反映されません。これは画面のちらつきおさえるためのダブルバッファリングと呼ばれる手法でゲームには適しています。

SDL_DestroyRenderer関数

レンダラーを破棄します。SDL_CreateRenderer関数と対で使います。レンダラーというのは正確にはレンダリングコンテキストというようです。

四角を描く

これで準備は出来たので、四角を描く関数を使ってみることにします。SDL_RenderFillRectという関数があります。正確には四角形を描くというより、矩形を塗りつぶす関数です。第1引数がレンダラーで、第2引数にSDL_Rect構造体へのポインタを渡します。SDL_Rect構造体は矩形のx座標、y座標、幅、高さを保持します。

...
    SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
    SDL_RenderClear(renderer);
    
    SDL_SetRenderDrawColor(renderer, 255, 128, 255, 255);
    SDL_Rect rect = {ScreenWidth / 2, ScreenHeight / 2, 100, 50};
    SDL_RenderFillRect(renderer, &rect);
    
    SDL_RenderPresent(renderer);
  ...

ハイライト部分が追加したコードです。これを実行してみます。

中央からずれているので見栄えはいまいちですが四角形になりました。

四角形を動かす

表示するのは出来たので、次は四角形をブロック崩しのプレイヤーが操作するバー、パドルに見立てて、左キーと右キーで操作できるようにします。

キーボード入力を扱う方法の一つとして、イベントの処理をするところでキーの状態を調べる方法があります。イベントでキーを調べるには、SDL_PollEvent関数を使います。SDL_PollEvent関数の引数にSDL_Event型のオブジェクト(のアドレス)を渡し、イベントキューに蓄えられたイベントを取ってきて、そのイベントの種類を調べてイベントの種類がSDL_KEYDOWNならキーボードが押されたことになります。そのときには押されたキーの種類を調べて適当に処理します。

今はこのようになってます。

...
bool running = true;
while (running) {
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            running = false;
        }
    }
    ...

イベントの種類がSDL_QUITのとき、これはウィンドウの✘ボタンを押したときなどに発生するのですが、ループ管理用のフラグをfalseにすることでループを終了して、プログラムの終了処理に向かうようにしています。SDL_Eventのドキュメントを見ると、SDL_Eventは共用体であることが書かれています。共用体については詳しく書きませんが、複数の型を、同じデータ領域を使って表現されるような使い方ができる型です。この共用体のtypeというフィールドにはイベントの種類が常に入るようになっていて、キーボードのキーが押されたときはSDL_KEYDOWNという値が入ってきます。そのとき、フィールドのkeyという名前でアクセスすると SDL_KeyboardEventのデータにアクセスすることが出来ます。さらにその中のkeysym、さらにその中のsymというフィールドに押されたキーのキーコードが格納されています。これを取得するには次のようなコードを書きます。

...
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_LEFT:
                SDL_Log("LEFT");
                break;
            case SDLK_RIGHT:
                SDL_Log("RIGHT");
                break;
            default:
                break;
            }
        }
    }
    ...

event.key.keysym.symがキーコードを調べているところです。実はこの方法はあまりいい方法ではないのですが、一旦これで進めておきます。ついでなので、EscとEnterでプログラムを終了できるようにしておきます。Enterで終了できるようにしておくとEnterを2回タイプすればGeanyから立ち上げたターミナルも終了できて便利です。

...
switch (event.key.keysym.sym) {
case SDLK_LEFT:
    SDL_Log("LEFT");
    break;
case SDLK_RIGHT:
    SDL_Log("RIGHT");
    break;
case SDLK_ESCAPE:
case SDLK_RETURN:
    running = false;
    break;
default:
    break;
}
...

ループ管理フラグをfalseにしても、直ちに一番外側のループを抜けるのではなく、ループの残りの処理を1回だけ行ってしまうのですが、これも一旦これで置いておきます。

次に本来の目的の四角形を動かす部分を作ります。

...
const double move_scale = 10;
const int paddle_width = 60;
const int paddle_height = 20;
int x = ScreenWidth / 2 - paddle_width / 2;
int y = ScreenHeight - paddle_height * 2;

bool running = true;
while (running) {
    int dx = 0;
    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_LEFT:
                --dx;
                break;
            case SDLK_RIGHT:
                ++dx;
                break;
            case SDLK_ESCAPE:
            case SDLK_RETURN:
                running = false;
                break;
            default:
                break;
            }
        }
    }
    
    x += dx * move_scale;
    
    SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
    SDL_RenderClear(renderer);
    
    SDL_SetRenderDrawColor(renderer, 255, 128, 255, 255);
    SDL_Rect rect = {x, y, paddle_width, paddle_height};
    SDL_RenderFillRect(renderer, &rect);
    
    SDL_RenderPresent(renderer);

    SDL_Delay(16);
}
...

画面下の方の真ん中に表示されるよう調整しました。

試しに左右キーで動かしてみると動きがカクカクです。これは左右キーを押し続けていても、イベントがループごとに毎回(毎フレーム)発生するわけではないからです。試しに次のようなコードを書いてみます。

...
while (running) {
    ...
    while (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            running = false;
        } else if (event.type == SDL_KEYDOWN) {
            switch (event.key.keysym.sym) {
            ...
            case SDLK_SPACE:
                SDL_Log("SPACE");
                break;
            ...
            }
        }
    }
    SDL_Log("loop");
    ...
}

一番外側のwhileループ毎回に"loop"というテキストを出力し、スペースキーが押されていたら、そしてキーボードのイベントが発生していたら"SPACE"というテキストを出力しています。これを実行した結果、次のようになりました。

...
INFO: SPACE
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
INFO: loop
INFO: SPACE
INFO: loop
INFO: loop
...

期待していたのは、

...
INFO: SPACE
INFO: loop
INFO: SPACE
INFO: loop
INFO: SPACE
INFO: loop
...

というものでしたが、実際はループ2〜3回に1回しかキー入力を検出できていません。カクカクになる原因はこのせいでした。なので別の方法を使います。

SDLにはもう一つキーボードを扱う関数が用意されています。SDL_GetKeyboardState関数です。この関数の使い方は簡単です。単純に呼び出すと、キーの状態が入った配列を表すポインタを返してきます。配列については別のところで書こうと思いますが、簡単に言うと、一つの名前をつけた変数に対し複数個のアイテムを格納して、name[番号]で特定のアイテムにアクセスできるようにするものです。例を挙げるとint xs[10]と書いた場合、10個のintのアイテムが作られ、1つ目のアイテムにはxs[0]、2つ目のアイテムにはxs[1]、3つ目はxs[2]、10個目にはxs[9]のようにアクセスできます。番号は0から始まり、最後の番号はアイテムの数-1になります。普通アイテムといい方はせず要素といい、番号は添字とかインデックスとか言うのでこれからそう言います。

SDL_GetKeyboarState関数に話を戻すと、この関数はUint8 state[SDL_NUM_SCANCODES]のようなものを返してきます(正確には配列とは違います)。また、include/SDL_scancode.hにはSDL_NUM_SCANCODES = 512ように書かれているので、stateの要素数は現状512個ということになります。注意しておきたいのは、この配列そのものを返してくるわけではなく、ポインタを返してくる点です。C++では配列そのものを関数の返り値とすることはできません。代わりにポインタを返すことが出来ます。配列とポインタはとても密接な関係があって、ポインタを通してインデックスで配列にアクセスすることが出来ます。stateのどの位置に目的のキーの状態が入っているかはSDL_SCANCODE_XXXという定数に定められていて、左矢印キーはstate[SDL_SCANCODE_LEFT]、右矢印はstate[SCANCODE_RIGHT]、スペースキーはsatte[SDL_SCANCODE_SPACE]とすることでアクセスすることが出来ます。

このSDL_GetKeyboardState関数を使って先のスペースキーの実験を書き直してみます。

...
while (running) {
    ...
    ...
    const Uint8* key_state = SDL_GetKeyboardState(nullptr);
    if (key_state[SDL_SCANCODE_SPACE]) {
        SDL_Log("SPACE");
    }

    SDL_Log("loop");
    ...
}
...

このコードに書き換えたプログラムを実行して、スペースキーを押し続けると、SPACEというテキストとloopというテキストが交互に表示されます。

...
INFO: SPACE
INFO: loop
INFO: SPACE
INFO: loop
INFO: SPACE
INFO: loop
INFO: SPACE
INFO: loop
INFO: SPACE
INFO: loop
...

これは期待したとおりの結果です。この関数を使って先のパドルを動かすコードを書き直します。今回はこれで終わりにしたいので全部のせておきます。

#include <SDL2/SDL.h>

const int ScreenWidth = 400;
const int ScreenHeight = 400;

int main()
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        SDL_Log("SDL_Init() error: %s", SDL_GetError());
        exit(1);
    }
    
    SDL_Window* window = SDL_CreateWindow(
            "Drawing Square SDL Renderer",
            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);
    }
    
    const double move_scale = 7.0;
    const int paddle_width = 60;
    const int paddle_height = 20;
    int x = ScreenWidth / 2 - paddle_width / 2;
    int y = ScreenHeight - paddle_height * 2;
    
    bool running = true;
    while (running) {
        int dx = 0;
        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;
                }
            }
        }
        
        const Uint8* key_state = SDL_GetKeyboardState(nullptr);
        if (key_state[SDL_SCANCODE_LEFT]) {
            --dx;
        }
        if (key_state[SDL_SCANCODE_RIGHT]) {
            ++dx;
        }
      
        x += dx * move_scale;
        
        SDL_SetRenderDrawColor(renderer, 0, 0, 255, 255);
        SDL_RenderClear(renderer);
        
        SDL_SetRenderDrawColor(renderer, 255, 128, 255, 255);
        SDL_Rect rect = {x, y, paddle_width, paddle_height};
        SDL_RenderFillRect(renderer, &rect);
        
        SDL_RenderPresent(renderer);

        SDL_Delay(16);
    }
    
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
}

今度はスムーズに操作できるようになりました。

続く…