連携処理4:スクリプト処理1

お絵かきアルゴロジック のような プログラミング学習のための エデュテイメントアプリを開発してみよう.

教科書の該当範囲:付録B1.6,付録B3
参考書の該当範囲:なし

設計

2つのプログラムを連携動作させる:

構成図:

  [スクリプト] ⇨ [pdcl] ⇨ [キーシーケンス] ⇦⇨ [pendraw] ⇦⇨ [ユーザ]

スクリプト言語 PDCL(pendraw control language)の仕様:


開発

Step.1. フロントエンドの基本プログラム

とりあえず,ペン(カーソル)をキーボードで手動操作し, 線画を描くプログラムを作成しよう. ふつうの curses プログラムです.

ソース pendraw.c:(ver.1)

#include <ncurses.h>
#include <string.h>		// strlen()

/*
ペンをキー入力でコントロールする.
pen:ペンの状態(参照渡し)
x, y:ペンの位置(参照渡し)
w, h:画面サイズ
key:入力キー
*/
void Ctrl(int *pen, int *x, int *y, int w, int h, int key)
{
	switch (key) {
	case '-': *pen = 0; break;		// on
	case '+': *pen = 1; break;		// off
	case ' ': *pen = (*pen + 1)%2; break;	// on/off切替

	case 'h': (*x)--; if (*x < 0) *x = 0; break;		// ←
	case 'j': (*y)++; if (*y >= h) *y = h - 1; break;	// ↓
	case 'k': (*y)--; if (*y < 0) *y = 0; break;		// ↑
	case 'l': (*x)++; if (*x >= w) *x = w - 1; break;	// →
	}
}

/*
メッセージを画面中央に表示し,キー入力を待つ.
msg:メッセージ文字列
w, h:画面サイズ
return:入力キー
*/
int Info(char *msg, int w, int h)
{
	mvaddstr(h/2, (w - strlen(msg))/2, msg);
	refresh();
	return (getch());
}

/*
ゲーム本体
*/
void Game(void)
{
	int	pen;	// ペンの状態(0:off,1:on)が入るよ
	int	x, y;	// ペンの位置が入るよ
	int	w, h;	// 画面のサイズが入るよ
	int	key;	// 入力キーの文字が入るよ

	// 初期設定
	erase();			// 画面をクリア
	getmaxyx(stdscr, h, w);		// 画面サイズを取得
	pen = 0; x = w/2; y = h/2;	// ペンの状態・位置を初期化

	while (1) {
		// 描画
//		erase();		// 画面を消去しなければ軌跡が残るよ
		if (pen) mvaddch(y, x, '#');	// ペンのキャラクタを描画
		refresh();		// 画面を表示

		// 入力
		key = getch();		// 端末からキーを入力

		// ペンの操作
		Ctrl(&pen, &x, &y, w, h, key);

		if (key == 'q') break;	// [Q]で終了
	}
	Info("Done. Hit any key!", w, h);	// 終了確認
}

/*
タイトル画面を表示し,キー入力を待つ.
return:入力キー
*/
int Title(void)
{
	erase();
	mvaddstr( 1, 3, "PenDraw");
	mvaddstr( 3, 3, "Key:");
	mvaddstr( 4, 3, "  [ ][+][-] - pen on/off");
	mvaddstr( 5, 3, "  [H][J][K][L] - move");
	mvaddstr( 6, 3, "  [Q] - quit");
	refresh();
	return (getch());
}

int main(void)
{
	int	key;	// 入力キーの文字が入るよ

	// 初期設定
	initscr();	// 端末制御を開始
	noecho();	// 入力キーは表示するなよ
	cbreak();	// 入力バッファは使うなよ
	curs_set(0);	// カーソルは表示するなよ
	timeout(-1);	// キー入力は来るまで待てよ

	// メインループ
	while (1) {
		key = Title();		// タイトル
		if (key == 'q') break;
		Game();			// ゲーム本体
	}

	// 終了
	endwin();	// 端末制御を終了
	return (0);
}
フルスクリーンアプリでエラーも端末画面に表示する場合であれば, exit(EXIT_FAILURE) とかは要らんでしょう. もちろん,fprintf(stderr, ...) とか exit() も使えるが, exit() の前には必ず,endwin() をお忘れなく. endwin() せずに終了すると,端末が発狂してしまうよ.

コンパイルと実行:

$  cc  pendraw.c  -lncurses  -o pendraw
$  ./pendraw

操作方法:

問題点:pen off状態だとペン位置が見えない... これについては次回,解決する予定.

Step 2. フロントエンドの拡張:自動操作の準備

自動操作の準備として, 手動操作のキーシーケンスの記録(ファイル出力)機能を追加しよう.

pendraw.c:(ver.2)

...
/*
ゲーム本体
fout:操作記録用ファイルポインタ
*/
void Game(FILE *fout)		// 引数の追加
{
	...
	while (1) {
		..
		Ctrl(&pen, &x, &y, w, h, key);

		// 操作の記録
		if (fout != NULL) fputc(key, fout);	// ファイルへキーを出力

		if (key == 'q') break;
	}
	...
}

...

int main(void)
{
	FILE	*fout;	// 操作記録用ファイルポインタ
	int	key;

	...
	// メインループ
	while (1) {
		key = Title();
		if (key == 'q') break;

		fout = fopen("key.txt", "w");	// 操作記録用ファイルの準備
				// とりあえずエラー処理は省略

		Game(fout);	// ゲームの本体(引数の追加)

		if (fout != NULL) fclose(fout);	// 操作記録用ファイルの後始末
	}
	...
}

実行・操作:

$  ./pendraw
...		# 適当に操作し,終了

$  cat  key.txt
...		# 操作した内容が記録された

Step 3. フロントエンドの拡張:自動操作の追加

記録したキーシーケンスファイルの入力機能を追加しよう. これで,自動操作が可能になる.

pendraw.c:(ver.3)

#include <ncurses.h>
#include <string.h>
#include <unistd.h>		// usleep()

...

/*
ゲーム本体
fin:操作入力用ファイルポインタ
fout:操作記録用ファイルポインタ
*/
void Game(FILE *fin, FILE *fout)
{
	...
	while (1) {
		...
		refresh();

		// 入力
		if (fin != NULL) {
			key = fgetc(fin);	// ファイルからキーを入力
			usleep(100000);		// 100ms停止(再生速度の調整)
		} else {
			key = getch();		// 端末からキーを入力
		}
		if (key == EOF) break;	// EOFで終了

		// ペンの操作
		Ctrl(&pen, &x, &y, w, h, key);

		...
	}
	...
}

/*
タイトル画面を表示し,キー入力を待つ.
return:入力キー
*/
int Title(void)
{
	...
	mvaddstr( 6, 3, "  [Q] - quit");
	mvaddstr( 8, 3, "Input from:");
	mvaddstr( 9, 3, "  [K] - keyboard");
	mvaddstr(10, 3, "  [F] - file (key sequence 'key.txt')");
	refresh();
	return (getch());
}

int main(void)
{
	FILE	*fin;	// 操作入力用ファイルポインタ
	FILE	*fout;
	int	key;

	...
	while (1) {
		key = Title();
		if (key == 'q') break;

		// ファイルの準備
		switch (key) {
		case 'f':	// 自動操作(ファイル入力)
			fin = fopen("key.txt", "r");
			fout = NULL;
			break;
		default:	// 手動操作(キー入力,ファイル記録)
			fin = NULL;
			fout = fopen("key.txt", "w");
			break;
		}

		Game(fin, fout);	// ゲーム本体(引数の再追加)

		// ファイルの後始末
		if (fin != NULL) fclose(fin);	// 入力用
		if (fout != NULL) fclose(fout); // 記録用
	}
	...
}

操作:タイトル画面で [F]キー.

これで直前に作成した軌跡がアニメーションとして再生されるハズ. または,キーシーケンスファイルkey.txt を手動作成しておけば, その内容通りの自動操作も可能.

Step 4. バックエンドの基本プログラム

長いキーシーケンスをすべて手動で作成するのは大変です. スクリプト言語を利用して, キーシーケンスを効率的に生成できるようにしたい.

とりあえず,ペン状態切替コマンド(onoff)と ペン移動コマンド(updownleftright)を 実装してみよう.

ソース pdcl.c:(ver.1)

#include <stdio.h>
#include <stdlib.h>	// atoi()
#include <string.h>	// strtok()

#define BUFLEN	256	// バッファ文字列のサイズ
#define ARGS	16	// コマンド引数の個数の最大値

int proc(FILE *fp)
{
	int	key;		// 出力キーの文字
	char	line[BUFLEN];	// 入力行(コマンドライン)文字列のバッファ
	int	argc;		// コマンド引数の個数
	char	*argv[ARGS];	// コマンド引数の配列
	char	*cmd;		// コマンド名(argv[0] のコピー)
	char	*tok, *p;	// strtok()の作業用ポインタ
	int	n;		// コマンドの反復回数
	int	i;		// カウンタ

	while (1) {
		// スクリプトの入力
		if (fgets(line, BUFLEN, fp) == NULL) break;

		// スクリプトのトークン分解
		p = line;	// strtok()の1回目は入力行の先頭を対象とする
		for (i = 0; i < ARGS; i++) {
			tok = strtok(p, " \t\n");
			// 空白,タブ,改行を区切としてトークンを抽出
			// 注意:lineの内容が改変される.区切文字 → 終端記号
			p = NULL;
				// 2回目以降は入力行内の残りの部分を対象とする

			if (tok == NULL) break;
			if (tok[0] == '#') break;	// コメントを無視
			argv[i] = tok;
		}
		if (i == 0) break;continue;	// スクリプトの空行で終了してしまうバグの修正
		argc = i;
		cmd = argv[0];

		// スクリプトの解釈
		// ペン状態切替コマンド
		if (strcmp(cmd, "off") == 0) key='-';
		else if (strcmp(cmd, "on") == 0) key='+';

		// ペン移動コマンド
		else if (strcmp(cmd, "up") == 0) key='k';
		else if (strcmp(cmd, "down") == 0) key='j';
		else if (strcmp(cmd, "left") == 0) key='h';
		else if (strcmp(cmd, "right") == 0) key='l';

		// その他...シンタックスエラー
		else continue;		// とりあえず無視しておく

		// pendraw操作用キーシーケンスの出力
		if (argc < 2) {		// 引数なしの場合
			n = 1;			// 1回だけ
		} else {		// ありの場合
			n = atoi(argv[1]);	// 反復回数
		}
		for (i = 0; i < n; i++) {
			fputc(key, stdout);
		}
	}
	return (EOF);
}

int main(int argc, char *argv[])
{
	FILE	*fp;
	int	i;

	for (i = 1; i < argc; i++) {
		fp = fopen(argv[i], "r");
		if (fp == NULL) break;
		proc(fp);
		fclose(fp);
	}
	fputc('q', stdout);
	return (EXIT_SUCCESS);
}
strtok() は文字列をトークンに分解するための標準ライブラリ関数. 以前作成したユーザ関数 sgettok() のようなもの.

スクリプトファイルの例 script-1.txt

down 5
on
right 10
up 10
left 10
down 10

コンパイル・実行・操作:

$  cc  pdcl.c  -o pdcl

$  ./pdcl  script-1.txt
jjjjj+llllllllllkkkkkkkkkkhhhhhhhhhhjjjjjjjjjjq		# キーシーケンスが生成された

$  ./pdcl  script-1.txt  >  key.txt	# キーシーケンスファイルへ保存

$  ./pendraw
...			# [F]キーで再生してみよう

Step 5. バックエンドの拡張:制御構造の追加

よりプログラミング言語らしく, 制御構造コマンド(repeatend)も追加しよう.

pdcl.c:(ver.2)

#include <stdio.h>	// ftell(),fseek()

...

int proc(FILE *fp)
{
	...
	int	n;		// コマンドの反復回数
	int	i;		// カウンタ
	long int	pos;	// repeatコマンドのファイル内位置

	while (1) {
		...
		// スクリプトの解釈
		// 制御構造コマンド repeat-end
		if (strcmp(cmd, "repeat") == 0) {
			if (argc < 2) {
				n = 1;
			} else {
				n = atoi(argv[1]);
			}

			pos = ftell(fp);
				// ファイル内の現在位置(repeat直後)を記録
			for (i = 0; i < n; i++) {
				if (fseek(fp, pos, SEEK_SET)) goto ERROR;
					// ファイルを巻き戻す(repeat直後へ)
				if (proc(fp)) goto ERROR;
					// repeat-end間を再帰処理(n回反復)
			}
			// ... n <= 0 だとバグるのは仕様です
			continue;
		} else
		if (strcmp(cmd, "end") == 0) return (0);
					// 再帰終了(for内へ戻る)

		// ペン状態切替コマンド
		if (strcmp(cmd, "off") == 0) key='-';
		...
	}
ERROR:				// エラー処理は未実装
	return (EOF);
}
...
ftell()fseek() が新出. ファイル入出力のランダムアクセスを実現するための標準ライブラリ関数.

スクリプトの例 script-2.txt

up 5
left 10
repeat 4
	on
	right 3
	off
	right 2
end

実行・操作:

$  ./pdcl  script-2.txt
kkkkkhhhhhhhhhh+lll-ll+lll-ll+lll-ll+lll-llq

$  ./pdcl  script-2.txt  >  key.txt

$  ./pendraw
...

Step 6. フロントエンドの拡張:バックエンドとの連携

いちいちキーシーケンスファイルを事前作成するという二度手間は面倒くさい. スクリプトをフロントエンドから直接実行できるようにしたい. 完全に直接ではないですが... ファイル入力ではなくパイプ入力を使えば実現できます.

フロントエンド pendraw 内から バックエンド pdcl を実行します.を間接的に実行し, その出力データをファイルを介さず直接的に入力します.

pendraw.c:(ver.4)

...
/*
タイトル画面を表示し,キー入力を待つ.
return:入力キー
*/
int Title(void)
{
	...
	mvaddstr( 8, 3, "Input from:");
	mvaddstr( 9, 3, "  [K] - keyboard");
	mvaddstr(10, 3, "  [F] - file (key sequence 'key.txt')");
	mvaddstr(11, 3, "  [P] - pipe (PDCL script 'script.txt')");
	refresh();
	return (getch());
}

int main(void)
{
	...
	while (1) {
		...
		switch (key) {
		case 'f':	// 自動操作(ファイル入力)
			fin = fopen("key.txt", "r");
			fout = NULL;
			break;
		case 'p':	// 自動操作(パイプ入力)
			fin = popen("./pdcl script.txt", "r");
					// pdclの出力を入力するよ
			fout = NULL;
			break;
		default:	// 手動操作(キー入力,ファイル記録)
			fin = NULL;
			fout = fopen("key.txt", "w");
			break;
		}

		Game(fin, fout);		// ゲーム本体

		if (fin != NULL) {
			if (key == 'p') {
				pclose(fin);	// パイプの後始末
			} else {
				fclose(fin);	// ファイルの後始末
			}
		}
		if (fout != NULL) fclose(fout);
	}
	...
}
fopen() でファイルとの入出力が可能なように, popen() では他のプログラムとの入出力が可能となる. 後始末はpclose()

実行・操作:

$  vim  script.txt
...		# スクリプトファイルを作成

$  ./pendraw
...		# [P]キーでスクリプトを実行

ファイルアクセス

基本的に,ファイル入出力処理では, データは,ファイルの先頭から末尾の方向へ順番に連続的に読み書きされる. このような方式は, シーケンシャルアクセス(sequential access)と呼ばれている.

前回までに利用してきた入出力関数 fgets( )scanf( )printf( ), 等だけによるファイル処理は,シーケンシャルアクセス.

一方,ファイル内でのデータの読み書き位置が不連続的な入出力の方式は, ランダムアクセス(random access)と呼ばれている. これはデータを再読み込みしたり,読み飛ばしたりする場合に必要となる.

Cの標準ライブラリには, ランダムアクセスを実現するための ファイル位置関数が用意されている:

昭和な解説: 「ファイルとは カセットテープ のようなものだ」 と考えると理解しやすいんだが... 知ってますか?

これらの関数についての詳しくは, 教科書 K&R p.311 を参照せよ.


練習問題

  1. 消しゴム機能を実装せよ.
  2. Hint:消しゴムモードでは,'#' ではなく,' ' を表示.
  3. スクリプトのファイル名を指定可能とせよ.
  4. Hint:scanw()等でファイル名の文字列を入力, sprintf() 等でパイプ用コマンドラインの文字列を作成.

(c) 2018, yanagawa@kushiro-ct.ac.jp