端末(terminal)とは,コンピュータの基本的な機能 (キーボードから命令を受け取り,画面に文字を描く機能) を実現するためのハードウェアやソフトウェアのことである.
これまでのC言語プログラムでは,標準ライブラリの入出力関数 printf( ) と scanf( ) 等を利用して, 端末の入出力を実行してきた. しかし,これらの関数では,一行単位でしか入出力できないし, 白黒でしか表示できない,等の制約が多かった. 「なーんだC言語では,まったく面白味のないプログラムしか作れないのかー」 いや,それは誤解だ.
端末自身は,キーボードや画面をより自由自在に制御する機能をもっており, C言語の標準ライブラリでも,それらの機能の一部を利用できる. さらに,curses ライブラリを利用すれば, エディタやビデオゲームのような高機能な端末制御プログラムを 効率的に作成できるようになる.
端末画面上の任意の位置にカーソルを移動したり, 表示する文字の色を変更したりするには, エスケープシーケンス(escape sequence) と呼ばれる特殊な文字列を利用する.
たとえば,次のようなコマンドを実行すると, 文字の色を変更できる:
$ echo "^[[31m Red" # '^['の入力方法は下記の通り
Red
$ echo "^[[0m Reset"
Reset
$
$ PS1="$ "
ただし,文字列の先頭の記号 '^[' は, [Esc] キーを表わす特殊な文字である. 2 文字の文字列 "^[" ではない. この文字をコマンドラインで入力するためには, 次のように操作する:
これら Esc 文字から始まる文字列 ( "^[[31m" や "^[[0m") がエスケープシーケンスである. 代表的なエスケープシーケンスを紹介しておく. なお,この表では,エスケープ文字を「Esc」と表記している.
|
|
|
これらのエスケープシーケンスは,端末自身がもっている機能なので, C言語でも利用できる. エスケープシーケンスを printf( ) 関数などで出力するだけだ. ここでは,関数ではなく,printf コマンドで試してみよう:
$ printf "クリアして \e[2J" $ printf "\e[3;0H 3行目で \e[31m 赤にしたり\n" $ printf "\e[1;10H 1行目へ戻ってみたり\n" $ printf "\e[5;0H\e[0m 5行目で元通りに\n" ...
ここで '\e' は Esc を表わす特殊文字である. 改行記号 '\n' などと同様,2文字で1文字を表わす.
エスケープシーケンスの文字列は, 人間が読んでもほとんど意味不明な「呪文(curse)」のようなものであり, それを直接利用してプログラムを作成するなんてことは非常に面倒だ. そこで,この面倒な処理を簡単化するための関数群が curses ライブラリとして用意されている.
このライブラリの関数を利用すると, 端末画面上の任意の位置にカーソルを移動したり, 表示する文字の色を変更したりするプログラム (いわゆるフルスクリーンアプリケーション) が簡単に実現可能になる.
単純な curses プログラム hello.c を作ってみよう:
#include <ncurses.h> int main(void) { initscr(); // 端末制御の開始 start_color(); // カラーの設定 init_pair(1, COLOR_RED, COLOR_BLUE); // 色番号1を赤文字/青地とする bkgd(COLOR_PAIR(1)); // 色1をデフォルト色とする erase(); // 画面表示 move(10, 20); addstr("Hello World"); refresh(); timeout(-1); getch(); // キー入力 endwin(); // 端末制御の終了 return (0); }
これをコンパイルするには,-lncurses オプションを指定し, ライブラリをリンクする必要がある:
$ cc hello.c -lncurses -o hello
実行すると,メッセージが表示され,何かキーを押すと終了する.
ソースを読んでみよう. これまでのプログラムとは全然違うものに見えるだろう. とりあえず今は,細かいことについては,気にしないでよい.
まず,おなじみのヘッダファイル stdio.h とか 標準ライブラリの入出力関数 printf( ) や scanf( ) とかは, まったく使われていない. その代わり,ncurses.h をインクルードし, curses ライブラリの関数だけを使っている.
画面表示やキー入力のために, 標準ライブラリの標準入出力関数(printf() 等)と curses ライブラリの端末入出力関数(addstr( ) 等)とを 混ぜて使わないこと. 一緒に使ってもエラーにはならないが, 多分,期待した結果にはならない. 注意せよ.
ま,curses の入出力関数があれば, 標準入出力関数を使う必要はないので, 心配する必要もない. 端末入出力には,curses の関数だけを使うこと. ただし,ファイルの入出力の場合には, 端末制御とは無関係なので, 標準ライブラリの入出力関数(fprintf( ) 等)のお世話にもなる.
curses の関数についての詳しくは, 参考資料を参照せよ.
次のセクションから, curses ライブラリの使い方を段階的に学習して行く. ある程度まで理解できたら,自由に改造してみよう.
基本プログラムとして,端末画面に文字列を表示してみよう. 何らかのキーを押すと終了する.
hello-1.c:
#include <ncurses.h> int main(void) { initscr(); move(10, 20); addstr("Hello World"); getch(); endwin(); return (0); }
これは,端末画面の上から 10 行目,左から 20 桁目の位置に 文字列 "Hello World" を表示するだけのものであり, 何かキーを押すと終了する.
ここで利用されている関数の意味は次の通り:
なお,画面表示の関数としては, addstr() の他に,次のようなものもある:
文字列表示をセンタリングしてみよう.
hello-2.c:
#include <ncurses.h> #include <string.h> int main(void) { int x, y, w, h; char *str = "Hello World"; initscr(); getmaxyx(stdscr, h, w); y = h/2; x = (w - strlen(str))/2; move(y, x); addstr(str); getch(); endwin(); return (0); }
まず,一度,実行してみよう. そして,端末ウィンドウのサイズを変更してから, 再度,実行してみよう. サイズに応じて,表示位置が自動的に調整されるハズだ.
次の関数が登場した:
ここで,stdscr は端末画面を表わす定数である. 標準出力 stdout の curses 版だと考えればよい.
ところで,Cの基本がわかっている人は疑問に思うハズだ. なぜ,利用方法が getmaxyx(stdscr, &h, &w) ではなく, getmaxyx(stdscr, h, w) なのか?
それは,getmaxyx( ) が実は,関数ではなくマクロであるからだ. 次のような感じに引数付きマクロとして定義されている:
#define getmaxyx(画面, y, x) { y=画面の行数; x=画面の桁数; }
ポインタがわからないドシロートさんでもわかるような親切設計? しかし,Cについて生半可な知識があると,かえって混乱してしまいそうだ. 注意せよ. (このマクロは良い設計なのだろうか?良い子はマネしないこと!)
次に,何かキーを押すたびに表示位置を変化するようにしてみよう. [Q]キーを押すと終了する.
hello-3.c:
#include <ncurses.h> #include <string.h> int main(void) { int x, y, w, h; char *str="Hello World"; int key; initscr(); // noecho(); // echo(); // cbreak(); // nocbreak(); getmaxyx(stdscr, h, w); y = h/2; x = (w - strlen(str))/2; while (1) { // erase(); move(y, x); addstr(str); // refresh(); key = getch(); if (key == 'q') break; y++; if (y >= h) y = 0; } endwin(); return (0); }
無効化(コメント化)されている部分(// 以降)を 有効化しないと(// を外さないと)うまく行かないだろう. まず,コメントのまま実行し,その後,有効化して実行し, 動作を比較してみよう.
次の関数が出てきた:
ここで,refresh() の意味がよくわからないかもしれない. curses では, 実際の表示用の画面(物理画面,実画面)の他に, 内部的な作業用の画面(論理画面,裏画面)を利用している. 通常,出力の処理は,論理画面だけに対して作用する. つまり,出力しただけでは,実際の画面上には表示されない(場合がある).
表示したい場合には,出力処理の後で,refresh() しよう. これで,論理画面の内容が物理画面へ確実にコピーされ, 実際に見えるようになるハズだ.
今度は,キー入力しなくても自動的に動くようにしてみよう. hello-3.c のコピーを作り,次のように変更しよう. (また後で使うので,hello-3.c は書き換えずに残しておこう.)
hello-4.c:
... #include <unistd.h> // usleep() int main(void) { ... initscr(); noecho(); cbreak(); timeout(0); ... while (1) { ... usleep(100000); } endwin(); return (0); }
次の関数を追加しただけ:
前のコードでは,下方向にしか移動できなかった. 今度は,カーソルキーの入力によって上下左右に移動できるようにする. hello-3 を元にして,次のように書き換えよう.
hello-5.c:
... int main(void) { ... initscr(); noecho(); cbreak(); keypad(stdscr, TRUE); ... while (1) { ... key = getch(); if (key == 'q') break; switch (key) { case KEY_UP: y--; break; case KEY_DOWN: y++; break; case KEY_LEFT: x--; break; case KEY_RIGHT: x++; break; } } endwin(); return (0); }
新たな関数はひとつだけ:
さてここで, getch( ) の戻り値の型が char ではなく int だったことの理由を明かそう. ASCII コード(1バイトの数値,0x00 〜 0xFF) だけでは表わしきれない程に多数ある特殊キーにも 番号(4バイトの数値,0x00000100 〜 0xFFFFFFFF)が割り当てられている. また,各特殊キーには, わかり易いマクロ名も定義されている:
今度は,カラーを使ってみる. ソースファイルをゼロから作成しよう.
color.c:
#include <ncurses.h> int main(void) { // 端末の準備 initscr(); // 色の準備 start_color(); init_pair(1, COLOR_RED, COLOR_BLUE); // 色1 は青地に赤文字 init_pair(2, COLOR_GREEN, COLOR_BLUE); // 色2 は青地に緑文字 init_pair(3, COLOR_YELLOW, COLOR_BLUE); // 色3 は青地に黄文字 init_pair(10, COLOR_WHITE, COLOR_BLUE); // 色10 は青地に白文字 bkgd(COLOR_PAIR(10)); // 背景は色10 // 表示 attrset(COLOR_PAIR(1)); // 色1 を使う mvaddstr(5, 5, "Hello World"); attrset(COLOR_PAIR(2)); // 色2 を使う mvaddstr(5, 25, "Hello World"); attrset(COLOR_PAIR(3)); // 色3 を使う mvaddstr(5, 45, "Hello World"); attrset(COLOR_PAIR(1) | A_BOLD); // 色&強調表示 mvaddstr(6, 5, "Hello World"); attrset(COLOR_PAIR(2) | A_BOLD); mvaddstr(6, 25, "Hello World"); attrset(COLOR_PAIR(3) | A_BOLD); mvaddstr(6, 45, "Hello World"); attrset(COLOR_PAIR(1) | A_REVERSE); // 色&反転表示 mvaddstr(7, 5, "Hello World"); attrset(COLOR_PAIR(2) | A_REVERSE); mvaddstr(7, 25, "Hello World"); attrset(COLOR_PAIR(3) | A_REVERSE); mvaddstr(7, 45, "Hello World"); attrset(COLOR_PAIR(1) | A_REVERSE | A_BOLD); // 色&反転&強調 mvaddstr(8, 5, "Hello World"); attrset(COLOR_PAIR(2) | A_REVERSE | A_BOLD); mvaddstr(8, 25, "Hello World"); attrset(COLOR_PAIR(3) | A_REVERSE | A_BOLD); mvaddstr(8, 45, "Hello World"); // 終了 getch(); endwin(); return (0); }
端末の種類や設定によっては,うまく働かない機能もあるかもしれないが, あまり気にしないでおこう.
全角日本語文字列(というかマルチバイト文字列)も利用できる:
#include <ncurses.h> #include <locale.h> // setlocale() int main(,..) { setlocale(LC_ALL, ""); // システム側の言語環境(日本語)を利用するよ initscr(); ... addstr("こんにちWorld"); // 全角・半角の混合文字列も使えるよ ... }
コンパイル方法: (ncurses ではなく,ncursesw ライブラリを使用)
$ cc ... -lncursesw
このように,文字列全体を表示するだけなら,とても簡単だ. しかし,全角文字では,2バイト以上で1文字を表現していたり, 画面上で半角2文字分の幅を使ったりするので, 下の練習問題2のように, 1文字ずつとか1バイトずつに分解して処理することは難しい.
無理ではない.しかし,文字コードに関する高度な知識が必要. したがって,とりあえず, 文字列分解処理が必要な場合には,全角文字列を使わない ってことで妥協しよう. たとえば,今回の練習問題2では, 文字列を日本語に変えると動作保証対象外としてよい.
上級者向け情報: ワイド文字(日本語文字というか英語以外の文字)用のデータ型 wchar_t, マルチバイト列(Cの標準の文字列)とワイド文字列の変換関数 mbtowc(), etc. で検索してみよう.
hello-5 のプログラムで左右に移動しているとき, 文字列の一部が画面からハミ出すと表示が乱れてしまう. この対策を施せ.
ヒント:画面右側の範囲チェックでは, 文字列の末尾の座標を調べる必要があるだろう.
ヒント:addstr( ) ではなく, addch( ) または mvaddch( ) を利用するとよい. つまり,文字列を一度に出力するのではなく, 一文字ずつ位置を指定しながら出力するとよい. このとき,当然,各文字の出力位置が画面内に収まるようにすること.
PCに内蔵されているタイマを利用し, 時計アプリを作ってみよう.
基本プログラムとして,時刻を標準出力してみる.
clock-0.c:
#include <stdio.h> #include <time.h> // time(), localtime(), strftime() /* 現在の時刻文字列を生成する関数 buf:時刻文字列バッファ(参照渡し) n:文字列バッファのサイズ */ void GetTimeStr(char *buf, int n) { time_t t; // 現在の unix時刻が入るよ struct tm *tm; // 時刻要素構造体へのポインタが入るよ t = time(NULL); // 現在の unix時刻を取得 tm = localtime(&t); // unix時刻を時刻要素(年月日時分秒)へ分解 strftime(buf, n, "%H:%M:%S", tm); // 時刻文字列(時:分:秒)を生成 } #define BUFLEN 10 // 文字列バッファの配列サイズ int main(void) { char buf[BUFLEN]; // 時刻文字列が入るよ GetTimeStr(buf, BUFLEN); // 時刻文字列を取得 printf("%s\n", buf); // 時刻文字列を表示 return (0); }
新出の関数:(#include <time.h>)
フルスクリーン化してみる.
clock-1.c:
// #include <stdio.h>#include <ncurses.h> #include <string.h> #include <time.h> /* デジタル時計を表示する関数 s:時刻文字列 */ void DrawClock(char *s) { int x, y; // 表示位置 int w, h; // 画面サイズ getmaxyx(stdscr, h, w); // 画面サイズを取得 x = (w - strlen(s))/2; // 中央表示のための位置を算出 y = h/2; mvaddstr(y, x, s); // 時刻を表示 } ... void GetTimeStr(char *buf, int n) { ... } ... int main(void) { char buf[BUFLEN]; int key; // 入力キー文字が入るよ // 端末の初期化 initscr(); curs_set(0); // カーソルは表示しないよ noecho(); cbreak(); timeout(0); // キー入力は待たないよ // 時計の表示 while (1) { GetTimeStr(buf, BUFLEN); erase(); DrawClock(buf); // 時刻文字列を表示 refresh(); key = getch(); if (key == 'q') break; // [Q]キーで終了 } // 終了 endwin(); return (0); }
表示方法をお洒落(レトロな7セグメント表示器)にしてみる.
clock-2.c:
#include <ncurses.h> #include <ctype.h> #include <string.h> #include <time.h> #define SEG_W 4 // 7セグ1文字分の幅 #define SEG_H 5 // 7セグ1文字分の高さ /* コロンを描く関数 y, x:表示位置 */ void DrawColon(int y, int x) { mvaddch(y+1, x+1, '#'); mvaddch(y+3, x+1, '#'); } /* 7セグメントの文字を表示する関数 y, x:表示位置 seg:発光パターン文字列(7文字+終端記号) */ void Draw7SegChar(int y, int x, char *seg) { if (seg[0] == '1') mvaddstr(y+0, x+0, "###"); if (seg[1] == '1') { mvaddstr(y+0, x+0, "#"); mvaddstr(y+1, x+0, "#"); mvaddstr(y+2, x+0, "#"); } if (seg[2] == '1') { mvaddstr(y+0, x+2, "#"); mvaddstr(y+1, x+2, "#"); mvaddstr(y+2, x+2, "#"); } if (seg[3] == '1') mvaddstr(y+2, x+0, "###"); if (seg[4] == '1') { mvaddstr(y+2, x+0, "#"); mvaddstr(y+3, x+0, "#"); mvaddstr(y+4, x+0, "#"); } if (seg[5] == '1') { mvaddstr(y+2, x+2, "#"); mvaddstr(y+3, x+2, "#"); mvaddstr(y+4, x+2, "#"); } if (seg[6] == '1') mvaddstr(y+4, x+0, "###"); } /* セグメントの配置: 0 ━ 1| |2 3 ━ 4| |5 6 ━ */ /* 7セグメントのデジタル時計を描く関数 s:時刻文字列 */ void Draw7SegClock(char *s) { int x, y; // 表示位置 int w, h; // 画面サイズ static char *seg[] = { // 7セグの発光パターン文字列 "1110111", // 文字0 "0010010", // 文字1 "1011101", // 文字2 "1011011", // 文字3 "0111010", // 文字4 "1101011", // 文字5 "1101111", // 文字6 "1010010", // 文字7 "1111111", // 文字8 "1111011" // 文字9 }; getmaxyx(stdscr, h, w); // 画面サイズを取得 x = (w - strlen(s)*SEG_W)/2; // 中央表示のための位置を算出 y = (h - SEG_H)/2; while (*s != '\0') { if (isdigit(*s)) { Draw7SegChar(y, x, seg[*s - '0']); // 数字を描く } else { DrawColon(y, x); // コロンを描く } x += SEG_W; // 表示位置を次の桁へ s++; // フラグ文字列を次の桁へ } } ... void GetTimeStr(char *buf, int n) { ... } ... int main(void) { ... while (1) { ... erase(); Draw7SegClock(buf); // 時刻を7セグ表示 refresh(); ... } ... }
最も単純なゲームプログラムの例として,サイコロを作ってみよう.
dice.c:
#include <stdio.h> #include <stdlib.h> // srand(), rand(), RAND_MAX // #include <time.h> // time() // min 以上,max 以下の整数乱数 int Rand(int min, int max) { return (min + (int)((max - min + 1.0)*rand()/(RAND_MAX + 1.0))); // ↑ 最良版:可能な限り等確率な乱数. // ↓ 簡易版:等確率ではない(イカサマ),お遊び用ならこれでもOK. // return (min + rand()%(max - min + 1)); } int main(void) { int i; // srand(123); // 乱数のシャッフル(一定回数→ 再現性のある乱数) // srand(time(NULL)); // 乱数のシャッフル(時間的に変化→ 再現性のない乱数) for (i = 0; i < 3; i++) { printf("%d\n", Rand(1, 6)); // サイコロの目を表示 } return (0); }
まずは,このプログラムを複数回実行してみよう. 何度実行しても,同じ値が出てきてしまうだろう. コンピュータは実際にサイコロを振ったりはできないので, 既定の不規則的な数列から数値を順番に取り出しているだけなのだ.
そこで,シャッフルの処理が必要になる. ただし,シャッフルの回数が同じだと, これまた,同じ値になってしまう. srand(123) を有効化し, 引数の値を色々と変えて, 複数回実行してみよう.
結局,現在時刻でシャッフルするのが 常套手段となっている. srand(time(NULL)) を有効化してみよう. これで,本物のサイコロらしくなったハズだ.
新出の関数:(#include <stdlib.h>)
さて,乱数を2個使えば「丁半博打」とか, 3個使えば「スロットマシン」とか,開発できそうだ. curses と組み合わせて実現してみては?