Open9

C言語でテトリスを作って学ぶ #1

ピン留めされたアイテム
EvaEva

C言語を学ぶために簡単なテトリスを実装していきます。
M1チップのMacbookにclangが入っていたので、それを使います。

clang -v

Apple clang version 14.0.3 (clang-1403.0.22.14.1)
Target: arm64-apple-darwin22.1.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin

Wikipediaに載っていたテトリスの画像を参考に、横10マス × 縦20マスのテトリスを開発。

EvaEva

ゲーム用のフィールドを用意する

一番最初に思いついたのは、10 × 20の多重配列を作り、0と1で埋まっているかどうかの状態を管理する方法。構造体で定義してみる。

struct field{
    int array[10][20];
}
EvaEva

Rustではintの型で小さい数値を扱うものがあるので、調べる。short intunsigned intが小さいらしい。ただ、short intは-32,768から32,767まで(16bit。Rustでいうi16型)、unsigned intは0から65,535まで(同じく16bit。Rustのu16型)。
Rustにはi8, u8があるのでもっと下の型があるはず、型を当てはめた瞬間にメモリをそのサイズ確保してしまうので、できる限り小さいものを選択したい。

signed char, unsigned charがいわゆるi8, u8に当たる8bitの型だという。

unsigned charは0から255までなので、先ほどよりも適切なサイズだが、01で済む型があるかもしれないので調べます(色んな型を調べたかったので、あえて遠回りしてます)。

EvaEva

_BoolというC99から導入された型を発見したが、どうやら8bit使ってしまうらしい。。。

単純に0と1ならもっとメモリが節約できるのではないかと考えたが、そう単純ではなさそう。

誤って2 ~ 255の数値が入るのを防げるため、unsigned charよりは_Boolの方が良さそう。

#include <stdbool.h>

上記のヘッダーを追加すれば使えるらしい。

EvaEva

絶対もっと小さくできると確信していたので調べたら、ビットフィールドというテクニックがあるらしい。

struct Flags {
    unsigned int flag1 : 1;
    unsigned int flag2 : 1;
    unsigned int flag3 : 1;
    unsigned int flag4 : 1;
    unsigned int flag5 : 1;
    unsigned int flag6 : 1;
    unsigned int flag7 : 1;
    unsigned int flag8 : 1;
};

を使ってビット数を指定することができ、上記の例だと1bitのフラグが8個集まった8bitの構造体となる。先ほどの方法だと、

unsigned char array[10][20]と定義し、200バイト(8bit * 200マス = 1600bit)必要なところを、200bitで済ませることができる(8分の1に!)。

struct BitField10 {
    unsigned int bit0 : 1;
    unsigned int bit1 : 1;
    unsigned int bit2 : 1;
    unsigned int bit3 : 1;
    unsigned int bit4 : 1;
    unsigned int bit5 : 1;
    unsigned int bit6 : 1;
    unsigned int bit7 : 1;
    unsigned int bit8 : 1;
    unsigned int bit9 : 1;
};

// 10×20のビットフィールドを持つ配列
struct BitField10 field[20];

この記述で20 × 10のビットフィールドを作ることができる。

ビットフィールドを含む構造体は以下のように明示的に0で初期化してあげられる。

struct BitField10 field[20] = {0};
EvaEva

ここまでで一旦出力してみる。

for文で配列の1つずつを並べて出力するために以下のコードを記述。

void printField() {
    for (int i = 0; i < 20; i++) {
        printf("%d%d%d%d%d%d%d%d%d%d\n",
               field[i].bit0, field[i].bit1, field[i].bit2, field[i].bit3,
               field[i].bit4, field[i].bit5, field[i].bit6, field[i].bit7,
               field[i].bit8, field[i].bit9);
    }
}

int main() {
    printField();

    return 0;
}

clang main.c -o mainでコンパイルし、出力されたバイナリファイルを./mainで実行する。

出力結果は以下。

0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000

大成功です。想定通りの挙動をしたので、ここまでのコードを書いておきます。

#include <stdio.h>

// 20ビットのビットフィールドを持つ構造体
struct TetrisLines {
    unsigned int bit0 : 1;
    unsigned int bit1 : 1;
    unsigned int bit2 : 1;
    unsigned int bit3 : 1;
    unsigned int bit4 : 1;
    unsigned int bit5 : 1;
    unsigned int bit6 : 1;
    unsigned int bit7 : 1;
    unsigned int bit8 : 1;
    unsigned int bit9 : 1;
};

// 10×20のビットフィールドを持つ配列
struct TetrisLines field[20] = {0};

void printField() {
    for (int i = 0; i < 20; i++) {
        printf("%d%d%d%d%d%d%d%d%d%d\n",
               field[i].bit0, field[i].bit1, field[i].bit2, field[i].bit3,
               field[i].bit4, field[i].bit5, field[i].bit6, field[i].bit7,
               field[i].bit8, field[i].bit9);
    }
}

int main() {
    printField();

    return 0;
}
EvaEva

時間経過で再描画する機能

コンソールでテトリスする場合、スプライトアニメーションのように一定時間でコンソールに出力されている画像を「現在の配列の状態」に更新する処理が必要になる。

なので、よくあるL字のブロックが落ちてきた場合、

0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000

の状態から

0100000000
0100000000
0110000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000
0000000000

で再描画される必要がある。

現在、./mainを実行すると0のフィールドを描画してプログラムが終了してしまうため、無限ループをさせてみる。

#include <unistd.h> // Unix系システムでsleep関数を使用するために必要
#include <stdlib.h> // system関数を使用するために必要

// ~~ 中略 ~~

int main() {
    while (1) {
        printField();
        sleep(1); // 1秒待機
        system("clear");
    }

    return 0;
}

whileで無限ループしている限りは、プログラムは終了しなくなった。

また、構造体として静的にメモリ確保をしているため、プログラムが終了するまで追加でメモリを確保しないようになっている。

これがしっかりと再描画されているかわからないので、ブロックを落とす機能を作ってみる。

EvaEva

ブロックを落とす機能

単純な垂直方向にブロックを落下させる機能を開発する。

条件としては一旦以下で作ってみようと思う(もっと良さそうなアルゴリズムはありそう)。

  1. field[i].bit1 が0でfield[i - 1].bit1が1の場合、field[i - 1].bit1 を0にし、field[i].bit1 を1にする。
  2. 1の処理を下の行から順に実行する。
void moveBitDown() {
    for (int i = 18; i >= 0; i--) {
        if (field[i].bit1 == 1 && field[i + 1].bit1 == 0) {
            field[i].bit1 = 0;
            field[i + 1].bit1 = 1;
        }
    }
}

//~~ 中略 ~~

int main() {
    field[0].bit1 = 1;
    while (1) {
        printField();
        moveBitDown();
        sleep(1); // 1秒待機
        system("clear");
    }

    return 0;
}

うまくいっていますね。画面の再描画も問題なく行えていますし、1番下の行では1秒経っても停止するようになっています。

ちなみに、moveBitDown()のfor文でなぜ18を指定してるかというと、ダングリングポインタ(有効ではないメモリアクセス)を防ぐためです。

縦には20列あり、配列のインデックスは0 ~ 19になります。

ですが関数内でi + 1にアクセスしているため、ここを19としてしまうとUB(未定義動作)が発生します(エラーで終了しません)。これを未然に防いでる感じです。

EvaEva

field[i].bit1だけではなく、bit0 ~ bit9まで全てで行うように修正します。

ビットフィールドを使っている制約上、ビット演算子は使えないようなので、ちょっと冗長に書きます(ここももっと上手く書けそう)。

void moveBitDown() {
    for (int i = 18; i >= 0; i--) { // 下から2番目の行までループ
        for (int bit = 0; bit < 10; bit++) { // bit0からbit9までループ
            // 現在のビットと次の行のビットの状態を取得
            int currentBitSet = 0;
            int nextBitClear = 0;
            switch (bit) {
                case 0: currentBitSet = field[i].bit0; nextBitClear = !field[i + 1].bit0; break;
                case 1: currentBitSet = field[i].bit1; nextBitClear = !field[i + 1].bit1; break;
                case 2: currentBitSet = field[i].bit2; nextBitClear = !field[i + 1].bit2; break;
                case 3: currentBitSet = field[i].bit3; nextBitClear = !field[i + 1].bit3; break;
                case 4: currentBitSet = field[i].bit4; nextBitClear = !field[i + 1].bit4; break;
                case 5: currentBitSet = field[i].bit5; nextBitClear = !field[i + 1].bit5; break;
                case 6: currentBitSet = field[i].bit6; nextBitClear = !field[i + 1].bit6; break;
                case 7: currentBitSet = field[i].bit7; nextBitClear = !field[i + 1].bit7; break;
                case 8: currentBitSet = field[i].bit8; nextBitClear = !field[i + 1].bit8; break;
                case 9: currentBitSet = field[i].bit9; nextBitClear = !field[i + 1].bit9; break;
            }
            // ビットを下に移動
            if (currentBitSet && nextBitClear) {
                switch (bit) {
                    case 0: field[i].bit0 = 0; field[i + 1].bit0 = 1; break;
                    case 1: field[i].bit1 = 0; field[i + 1].bit1 = 1; break;
                    case 2: field[i].bit2 = 0; field[i + 1].bit2 = 1; break;
                    case 3: field[i].bit3 = 0; field[i + 1].bit3 = 1; break;
                    case 4: field[i].bit4 = 0; field[i + 1].bit4 = 1; break;
                    case 5: field[i].bit5 = 0; field[i + 1].bit5 = 1; break;
                    case 6: field[i].bit6 = 0; field[i + 1].bit6 = 1; break;
                    case 7: field[i].bit7 = 0; field[i + 1].bit7 = 1; break;
                    case 8: field[i].bit8 = 0; field[i + 1].bit8 = 1; break;
                    case 9: field[i].bit9 = 0; field[i + 1].bit9 = 1; break;
                }
            }
        }
    }
}

テトリスでよくある、L字のブロックを落としてみます。

int main() {
    // L字ブロック
    field[0].bit1 = 1;
    field[1].bit1 = 1;
    field[2].bit1 = 1;
    field[2].bit2 = 1;

    while (1) {
        printField();
        moveBitDown();
        sleep(1); // 1秒待機
        system("clear");
    }

    return 0;
}

はい、完璧です。