こんにちは、コン(@pippi_kon)です。
この記事では『C言語でのクイズゲームの作り方』をご紹介しています。
前回は、プログラムを実行するたびに問題をランダムで出題する方法をご紹介しました。
出題にランダム性を取り入れることでクイズっぽくなりましたね!
でも、現状のプログラムだとランダムといってもまだ3問しかありません。
よりクイズっぽくするためには、もっと問題を増やしたいですよね。
ただ、単純に問題を増やすだけでは、プログラムの中がクイズの問題だらけになってしまいます。
そこで今回は、クイズの数を増やす前にクイズの問題を外部ファイル(テキストファイル)から読み込む方法をご紹介します。
今回の目標
今回作成するプログラムの出力結果です。
出力結果自体は前回と変わりませんが、中身のプログラム処理が変わります。
今回、クイズ問題が書かれた外部ファイルを読み込む処理を行っています。
ファイル読み込み処理に失敗した場合、エラーメッセージを出力するようにしています。
メッセージの内容はプログラムの解説と合わせて行います。
クイズ問題ファイルの記載形式
クイズ問題ファイルには以下の内容をリストアップします。
- 問題文
- 選択肢1~3
- 正解の番号
ただし、適当にリストアップするのではダメです。
ファイルの内容をプログラム内で解析する必要があるので、記載方法をルール化しなければいけません。
今回は以下のように「1問ずつカンマ(,)区切りで記載」をルールとしました。
1問目の問題文,選択肢1,選択肢2,選択肢3,正解の番号
2問目の問題文,選択肢1,選択肢2,選択肢3,正解の番号
3問目の問題文,選択肢1,選択肢2,選択肢3,正解の番号
実際のクイズ問題ファイルの中身は以下の通り。
リンゴは英語で何と言う?,apple,orange,banana,1
大正->昭和->○○->令和\n○○に入る年号は?,慶応,明治,平成,3
世界三大珍味はどれ?,イカスミ,キャビア,チーズ,2
プログラム全文
今回作成したプログラムのご紹介です。
のちほど、プログラムの解説を行います。
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
#define QUIZ_NUM 3
#define FLAG_ON 1
#define FLAG_OFF 0
#define QUESTION_FILE "questions.txt"
typedef struct {
char mondai[100];
char sentaku[3][100];
int answer_no;
int done_flag;
}quiz_t;
void set_quiz(quiz_t* t, char* m, char* s1, char* s2, char* s3, int a)
{
strncpy(t->mondai, m, sizeof(t->mondai));
strncpy(t->sentaku[0], s1, sizeof(t->sentaku[0]));
strncpy(t->sentaku[1], s2, sizeof(t->sentaku[1]));
strncpy(t->sentaku[2], s3, sizeof(t->sentaku[2]));
t->answer_no = a;
t->done_flag = FLAG_OFF;
}
void change_kaigyou(char* s)
{
char* p;
char* w;
while (1) {
p = strstr(s, "\\n");
if (p == NULL) {
return;
}
*p = '\n';
*p++;
w = p;
*w++;
while (*p++ = *w++);
}
}
int main(void)
{
int ans;
char result[QUIZ_NUM][10];
int ok_cnt = 0;
int i;
quiz_t quiz[QUIZ_NUM];
int r;
FILE* fp;
int set_quiz_error_flag = FLAG_OFF;
char line[1000];
char* ptr_mondai;
char* ptr_sentaku1;
char* ptr_sentaku2;
char* ptr_sentaku3;
char* ptr_answer_no;
fp = fopen(QUESTION_FILE, "r");
if (fp == NULL) {
printf("問題リストのオープンに失敗しました。ファイル名 = %s\n", QUESTION_FILE);
return -1;
}
i = 0;
while (fgets(line, sizeof(line), fp) != NULL) {
change_kaigyou(line);
ptr_mondai = strtok(line, ",");
if (ptr_mondai == NULL) {
printf("問題文の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku1 = strtok(NULL, ",");
if (ptr_sentaku1 == NULL) {
printf("選択肢1の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku2 = strtok(NULL, ",");
if (ptr_sentaku2 == NULL) {
printf("選択肢2の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku3 = strtok(NULL, ",");
if (ptr_sentaku3 == NULL) {
printf("選択肢3の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_answer_no = strtok(NULL, ",");
if (ptr_answer_no == NULL) {
printf("正解の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
set_quiz(&quiz[i], ptr_mondai, ptr_sentaku1, ptr_sentaku2, ptr_sentaku3, atoi(ptr_answer_no));
i++;
}
fclose(fp);
if (set_quiz_error_flag == FLAG_ON) {
return -1;
}
srand((unsigned int)time(NULL));
i = 0;
while (i < QUIZ_NUM) {
r = rand() % QUIZ_NUM;
if (quiz[r].done_flag == FLAG_ON) {
continue;
}
printf("[第%d問]\n", i + 1);
printf("%s\n", quiz[r].mondai);
printf("1:%s 2:%s 3:%s\n", quiz[r].sentaku[0], quiz[r].sentaku[1], quiz[r].sentaku[2]);
printf(">>> ");
scanf("%d", &ans);
if (ans == quiz[r].answer_no) {
printf("正解!\n\n");
strncpy(result[i], "○", sizeof(result[i]));
ok_cnt++;
}
else {
printf("不正解...\n");
printf("正解は %d:%s です。\n\n", quiz[r].answer_no, quiz[r].sentaku[quiz[r].answer_no - 1]);
strncpy(result[i], "×", sizeof(result[i]));
}
quiz[r].done_flag = FLAG_ON;
i++;
}
printf("--クイズ終了--\n");
for (i = 0; i < QUIZ_NUM; i++) {
printf("[第%d問]...%s\n", i + 1, result[i]);
}
printf("あなたは %d 問中 %d 問正解でした (正答率:%d %%)\n", QUIZ_NUM, ok_cnt, (ok_cnt * 100) / QUIZ_NUM);
return 0;
}
プログラムの解説
外部ファイル名用定数を追加
#define QUESTION_FILE "questions.txt"
クイズの問題を記載してあるファイル名を定数で宣言します。
今回は「questions.txt」という名前を指定しています。
実際にこの名前でファイルを作り、プロジェクト関連のファイルがある場所に保存しておいてください。
通常だと、「.vcxproj」という拡張子のファイルや、ソースファイルがある場所です。
よくわからない場合や、別の場所にファイルを置きたい場合は、そのファイルパスを定数に指定すればOKです。
例えば、「D:\mydir\Program\quiz」のフォルダの中に「mondai.txt」というファイルを作りたい場合は、定数に『D:\\mydir\\Program\\quiz\\mondai.txt』と指定します。
クイズ問題ファイルのオープン
外部ファイルを読み込む大まかな流れは以下の通り。
- ファイルのオープン
- ファイルの読み込み
- ファイルのクローズ
まずはファイルオープン処理についてです。
fp = fopen(QUESTION_FILE, "r");
ファイルのオープンは「fopen関数」で行います。
引数は2つあります。意味は以下の通り。
引数 | 意味 |
---|---|
第一引数 | オープンするファイルパス |
第二引数 | オープンするモード ↓よく使うモード "r":読み込み専用 "w":書き込み専用 "a":追加書き込み専用 |
今回は、第一引数に定数「QUESTION_FILE」を、第二引数に読み込み専用である"r"を指定します。
fopen関数の発行に成功(ファイルが開けた)したら、ファイルポインタがリターンされます。
以降のファイル操作系関数では、このファイルポインタを用いて制御を行いますので、変数fpに保存しておきます。
if (fp == NULL) {
printf("問題リストのオープンに失敗しました。ファイル名 = %s\n", QUESTION_FILE);
return -1;
}
fopen関数の発行が失敗したら、リターン値はNULLとなります。
ファイルのオープンに失敗したときのためにエラー処理を行っておきます。
失敗した旨のメッセージとともに、fopen関数に指定したファイル(開けなかったファイル名)も表示しておくと、エラーとなったときに調査しやすいです。
エラー後は後続の処理をしても無駄なので、おとなしくreturnして処理を終了させます。
今回の場合はあまり意味ないですが、プログラムを外部実行したときのために異常値を示す「-1」でリターンしておきます。
ファイルの読み込みと解析
i = 0;
while (fgets(line, sizeof(line), fp) != NULL) {
change_kaigyou(line);
ptr_mondai = strtok(line, ",");
if (ptr_mondai == NULL) {
printf("問題文の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku1 = strtok(NULL, ",");
if (ptr_sentaku1 == NULL) {
printf("選択肢1の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku2 = strtok(NULL, ",");
if (ptr_sentaku2 == NULL) {
printf("選択肢2の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku3 = strtok(NULL, ",");
if (ptr_sentaku3 == NULL) {
printf("選択肢3の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_answer_no = strtok(NULL, ",");
if (ptr_answer_no == NULL) {
printf("正解の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
set_quiz(&quiz[i], ptr_mondai, ptr_sentaku1, ptr_sentaku2, ptr_sentaku3, atoi(ptr_answer_no));
i++;
}
ここが今回追加したメインの部分となります。
ここでやっていることは以下の通り
- ファイルから1行取得
- 取得した行を解析して問題文を取得
- 取得した行を解析して選択肢1を取得
- 取得した行を解析して選択肢2を取得
- 取得した行を解析して選択肢3を取得
- 取得した行を解析して正解の番号を取得
- クイズの構造体に追加
- 次の行へ
細かくみていきます。
ファイルから1行取得
while (fgets(line, sizeof(line), fp) != NULL) {
// 割愛
}
この一文は「ファイルを1行ずつ読み込み続ける」ということを意味しています。
fgets関数はファイルを1行読み込みます。
読み込むデータがなくなったときはfgets関数のリターンがNULLとなります。
fgets関数の引数の意味は以下の通り。
引数 | 意味 |
---|---|
第一引数 | 読み込んだデータを保存する変数 |
第二引数 | 読み込むデータ量 |
第三引数 | 読み込むファイルのファイルポインタ (fopen関数で取得したもの) |
今回は1000バイトの容量をもったlineという変数にデータを保存します。
読み込むデータ量はlineに保存できる最大数で、sizeof関数で指定しています。
改行文字の変換
change_kaigyou(line);
テキストファイルから読み込むデータには「\n」が含まれているものがあります。
【例】
大正->昭和->○○->令和\n○○に入る年号は?,慶応,明治,平成,3
これをそのまま出力すると「\n」が文字列として認識されるため、改行処理が行われません。
そのため、文字列の「\n」を改行コードに変換する処理が必要です。
C言語の標準関数にはそのようなものはありませんので、「change_kaigyou()」という関数を自作しました。
void change_kaigyou(char* s)
{
char* p;
char* w;
while (1) {
p = strstr(s, "\\n");
if (p == NULL) {
return;
}
*p = '\n';
*p++;
w = p;
*w++;
while (*p++ = *w++);
}
}
「\n」が含まれている文字列が格納された変数を引数に指定することで、関数にて”\n”を改行コードに変換してくれます。
引数の内容を直接操作するので、戻り値として受け取る必要はありません。
問題文の取得
ptr_mondai = strtok(line, ",");
読み込んだ行を解析して問題文を取得します。
strtok関数は、指定した文字列を指定した区切り文字で分解できます。
strtok関数の引数の意味は以下の通り。
引数 | 意味 |
---|---|
第一引数 | 分解する文字列 NULL指定時は前回からの続き |
第二引数 | 読み込むデータ量 |
今回は、ファイルから読み込んだ行(line)を区切り文字(,)で分解します。
if (ptr_mondai == NULL) {
printf("問題文の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
strtok関数での分解に失敗した場合はNULLがリターンされますので、エラー処理も行っておきましょう。
- エラーメッセージの表示
- エラーフラグをON
- 解析処理のループから脱出
エラーメッセージには、問題文の解析に失敗した旨と、読み込んだファイル名・解析中の行番号を表示しておくと、エラー時に調査しやすいです。
set_quiz_error_flagは、後のファイルクローズ処理後にプログラムを中断させるためのフラグです。
選択肢の取得
ptr_sentaku1 = strtok(NULL, ",");
if (ptr_sentaku1 == NULL) {
printf("選択肢1の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
問題文につづいて選択肢1を解析取得します。
strtok関数の第一引数に「NULL」を指定すると、前回の分解から継続して分解するということができます。
ptr_sentaku2 = strtok(NULL, ",");
if (ptr_sentaku2 == NULL) {
printf("選択肢2の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_sentaku3 = strtok(NULL, ",");
if (ptr_sentaku3 == NULL) {
printf("選択肢3の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
ptr_answer_no = strtok(NULL, ",");
if (ptr_answer_no == NULL) {
printf("正解の登録に失敗しました。ファイル名 = %s, 行数 = %d\n", QUESTION_FILE, i + 1);
set_quiz_error_flag = FLAG_ON;
break;
}
選択肢1と同様に、残りの選択肢2,3と正解の番号も分解します。
正解の番号の分解では、番号の最後にカンマがないですが、行の終端までの区切りとなるので問題ありません。
クイズの構造体に登録
set_quiz(&quiz[i], ptr_mondai, ptr_sentaku1, ptr_sentaku2, ptr_sentaku3, atoi(ptr_answer_no));
i++;
問題文・選択肢・正解の番号の解析が正常に終わったら、読み込んだ内容をクイズの構造体に登録します。
正解の番号は整数型で登録する必要があるので、以前も登場したatoi関数で整数型に変換して登録します。
登録後、カウンタiを進めておきましょう。
クイズ問題ファイルのクローズ
fclose(fp);
ファイルの読み込み処理が終わったので、オープンしていたファイルをクローズします。
開けたら閉じる。
ファイル読み込み失敗時はプログラム終了
if (set_quiz_error_flag == FLAG_ON) {
return -1;
}
ファイル読み込みと解析処理で何かしら失敗していたら、後続の処理は行わずにプログラムを終了させます。