PC-98でのグラフィカルゲームの作成

中3 S

PC-98(with Turbo C)でのマインスイーパーの制作記。

なぜマインスイーパーか

  • 全てがイベントハンドラで書くことができ、厳密なtick管理などが不要で楽。
  • 程よく複雑で勉強に最適。
  • 知っている人が多く、文化祭での展示に丁度良い。

使用する物

  • PC-9821Xa10
  • MS-DOS Version 6.20
  • Turbo C++ Version 4.0

コード

定義部

グローバル変数を定義する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const int origin_x = 65; // (0, 0)のマスの左上x座標
const int origin_y = 77; // (0, 0)のマスの左上y座標
const int mines = 10;    // 地雷の数

int mode = 0;       // `graphics.h`の初期化時に必要なモード番号
int driver = 0;     // `graphics.h`の初期化時に必要なドライバ番号
int error_code = 0; // `graphics.h`の初期化時のエラーコード
int px, py = 0;     // ポインタ座標

int end = 0;                // エンドフラグ
int cells = ROWS * COLUMNS; // まだ空いていないマスの数
int flags = 0;              // 旗の数

int mine_data[ROWS+2][COLUMNS+2] = {0};    // 地雷の位置を格納
int display_data[ROWS+2][COLUMNS+2] = {0}; // ヒントの数字を格納
int open_data[ROWS+2][COLUMNS+2] = {0};    // 開閉を格納

char cell_msg[32];    // `draw_cell`関数で描画関数の引数に使用
char msg_mines[32];   // 地雷の数
char msg_flagged[32]; // 旗の数(`flags`)
char msg_cells[32];   // まだ空いていないマスの数(`cells`)

main関数

まず、変数の定義は冒頭でしか行えないため定義を済ましておく。 グラフィックを扱うために初期化が必要なので、グラフィックモジュールの初期化とエラーのチェックを行うinit関数を呼び出し、その後盤面の描画を行うdraw_map関数を呼び出す。

1
2
3
4
5
6
7
int first = 1; // 初回のみマップを生成するためのフラグ
int c = 0;     // キーコードを格納
int id;        // `switch`文での分岐のためのID

init();

draw_map();

メインループwhite(1)に入る。getch()関数で入力を待ち受け、その後if文でキーコード別に処理を分岐させる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
c = getch(); // キー入力待受

if (0x1b == c) {  // ESC
    closegraph(); // グラフィックモードを終了
    return 0;
} else if (0x0d == c | 0x20 == c) { // Enter or Space
    if (first) {
        first = 0;
        create_map(); // 初回のみマップを生成
    }

    open(px, py); // ポインターのxy座標を引数に渡して開く

    /* マスの数の表示更新処理 */
} else if (0x09 == c) { // Tab
    flag(px, py); // ポインターのxy座標を引数に渡して旗を配置

    /* 旗の数の表示更新処理 */
} else {
    if (0 == open_data[py+1][px+1] &
        11 != display_data[py+1][px+1]) {
        draw_cell(origin_x+px*50, origin_y+py*45,
                  -1);
        // 空いていない場合は白を描画
    } else {
        draw_cell(origin_x+px*50, origin_y+py*45,
                  display_data[py+1][px+1]);
        // すでに開いている or 旗が立っている場合は数字または旗を描画
    }

    if { /* キー別に`id`を振る */ }

    switch (id) { /* ポインタ移動処理 */ }
}

// ポインタ移動後にポインタを再描画
setcolor(RED);
rectangle(origin_x+px*50, origin_y+py*45,
         origin_x+px*50+45, origin_y+py*45+40);
setcolor(WHITE);

if (end) {
    closegraph(); // グラフィックモードを終了
    return 0;
}


main関数の要点は以上。

init関数

コメントに記した通り。初期化→エラー処理→描画関係の初期化処理という形である。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
driver = DETECT;               // 自動検出を要求
initgraph(&driver, &mode, ""); // ドライバと画面モードIDを渡して初期化
error_code = graphresult();    // エラーコードを取得

if (error_code != grOk) {
    printf("Graphics System Error: %s\n", grapherrormsg(error_code));
    exit(1); // エラーならメッセージを出して終了
}

// 描画初期化処理
settextstyle(DEFUALT_FONT, HORIZ_DIR, 3);
setcolor(WHITE);

open関数

基本的には開閉を格納するリストと照らし合わせ、開いているないしは座標が描画枠外に出ているなら処理を終了する。

前述の二つ両方に当てはまらないのなら地雷の存否で分岐し、あるならgame_over関数を走らせ処理を終了、ないのなら自己のマスを再描画しゲームクリアの判定を行ったのちに数字の存否で分岐、0、つまり何も書かれていない状態であれば周り八マスに対しopen関数を呼び出して再帰する。(数字があるのなら再描画のみで終了)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (x<0 | x>COLUMNS-1 | y<0 | y>ROWS-1 | open_data[y+1][x+1])
return; // 枠外またはすでに開いているのなら終了

open_data[y+1][x+1] = 1;
cells--;

setfillstyle(SOLID_FILL, BLACK);
bar(origin_x+x*50, origin_y+y*45,
    origin_x+x*50+45, origin_y+y*45+40);
setfillstyle(SOLID_FILL, WHITE);

if (1 == mine_data[y+1][x+1]) {
    game_over();
    return;
} else {
    draw_cell(origin_x+x*50, origin_y+y*45,
              display_data[y+1][x+1]); // 自己のマスを再描画
    if (cells == mines) {
        game_clear();
    }
    if (0 == display_data[y+1][x+1]) {
        /* 周囲の八マスに対し再帰処理 */
    }
}


open関数の要点は以上。

create_map関数

rand関数でランダムな座標を指定、初手で詰まないようにポインタを中心とする九マスに入っていないことを確認してからiに加算し周囲八マスのdisplay_data(表示用データ)に加算する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
int i = 0; // for文括弧内では定義できないので先に定義

srand((unsigned)time(NULL)); // 乱数を初期化

for (i=0;i<mines;) {
    int x, y = 0;
    x = rand()%COLUMNS+1; // 描画範囲外とならないために1を加算
    y = rand()%ROWS+1;    // 描画範囲外とならないために1を加算

    if (1 != mine_data[y][x]) {
        if (x < px | px+2 < x | y < py | py+2 < y) {
            mine_data[y][x] = 1; // 地雷を設置
            i++; // `i`を加算しループを進める

            /* 周囲八マスの`display_data`を加算 */
        }
    }
}


以上で解説は終了。


詰まったところ

やはり性能が低い

今のPCであれば全てのマスを再描画することを毎フレーム行えるだろうが、PC-98の性能では(高水準言語で書いていることもあるだろうが)処理が追い付かず、少しラグが生じてしまう。そのため、毎度更新があるマスだけを更新しなければいけないことがコードを複雑化させる原因となった。

警告などない

Turbo C付属のエディタを使用しているのだが、警告など出ない。コピー&ペーストはショートカットがキーボードのほぼ対局の位置にあり、シンタックスハイライトは甘えと言わんばかりに数値と文字列にしか色がつかない。現代のCtrl+Zのようなものを使用するとカーソルの動作が戻る。if文で===と書こうとも警告など出ない。(コンパイル時にも)しかしこれだけは対処法がある。現代の一般的な記法では変数を左辺に置き数値/文字列を右辺に置くだろうがそれを逆にすることでコンパイル時にエラーが出るようになり、誤りに気づける。(これはヨーダ記法と言われている)

型変換が特殊

私はC言語もC++も常用しないのだが、方変換の方法が特殊なようだ。例えばint->charの変換を行いたい場合、itoa関数というものを使用し、第一引数にint型の変数、第二引数にchar型、第三引数に基数を入力することで変換ができる。これを知るまでは、if文を列挙して1(int)なら1(char)を出力するというスパゲッティコードを書いていた。

セグメントエラーでPCが落ちる。

draw_cell関数で毎度char型のローカル変数を定義していたらがPCが落ちるという状況に陥っていたことがある。おそらくメモリを使いすぎたのかなんなのか強制的に落ちてしまうのである。解決策はグローバル変数で定義することだった。

終わりに

今回でPC-98でのグラフィック処理の初歩を学んだ。やはり普段からPythonのようなぬるま湯のような言語に浸かってきているため、PC-98とC言語という環境は新しいことを沢山学ばせてくれた。来年に向けてはブロック崩しのような毎秒動きがある高度ゲームなどを作成していきたい。

参考文献

  • OBのTwitter(𝕏)
  • THE C PROGRAMMING LANGUAGE (Prentice Hall Software)
  • [KB405963 - EMM386.EXEのエラー(Exception Error)について][http://radioc.web.fc2.com/weblib/ms/dos62v/405963.htm]
  • [Turbo_C_Reference_Guide_1987.pdf][http://bitsavers.informatik.uni-stuttgart.de/pdf/borland/turbo_c/Turbo_C_Reference_Guide_1987.pdf]
  • [ASCIIコード表][https://www.k-cube.co.jp/wakaba/server/ascii_code.html]
次へPC-猫だとわからない弊部のネットワーク>
前へAIを競技プログラミングで活用してみる>