Linux/shでゲームをつくる

シェルで書いたサンプルゲーム(2019-12-20)



とにかく、いつもshの文法を忘れるので
とりあえずこれ一個あれば概ね(おおむ)思い出せそうなサンプルスクリプト
が欲しくて作ってみました。
動作確認は Lubuntu 18.04 でやっています。

色々つめこんで解説が長いメモになっています。
PCとかで読むなら、このページをもう1つ別ウィンドウで開いて、
スクリプトと解説を並べながら読み進めてみて下さい [smile]

shスクリプト初心者の方は*1、以下のスクリプトを動かしてみて、
シェルでどんなことができるのか試したり、
自分で何か探す際の検索ワードのヒントになればと思います。

サンプルスクリプト

#!/bin/sh 
 
#
# catch flying stones!
#
# fork_process_looper():
#   print screen.
# main process:
#   wait Enter-key and send signal to fork_process_looper.
#
# start and input Enter-key.
# use "Ctrl c"(=SIGINT) to exit.
#

PATH=$PATH
export LANG=C

POSITION_X=12
POSITION_Y=1
STONE_ALIVE=0
STONE_SPEED=1
STONE_X=0
STONE_Y=0
SIG_COUNT=0
SCORE=0

print_back(){
 printf "\033[2J\033[H"
 echo "           |  1"
 echo "           |  2"
 echo "           |  3"
 echo "           |  4"
 echo "           |  5"
 echo "           |  6"
 echo "12345678901234  "
 echo "SCORE:$SCORE"
}

print_front(){
 if [ $STONE_ALIVE -ge 0 ] && [ $STONE_Y -ge 1 ] && [ $STONE_Y -le 6 ]; then
   printf "\033[${STONE_Y};${STONE_X}H*"
 fi
 printf "\033[${POSITION_Y};${POSITION_X}H+"
}

my_sleep(){
 U_SLEEP=`which usleep`
 if   [ ${U_SLEEP:="-"} != "-"  ]; then usleep 500000;
 elif [ -n "`which perl`" ]; then perl -e "select(undef,undef,undef,0.3);";
 elif [ -n "`which sleep`" ]; then sleep 1;
 else echo "Oh, no." >&2; exit 99; fi
}

position_up(){
 POSITION_Y=`expr $POSITION_Y - 3`
 if [ $POSITION_Y -lt 0 ]; then POSITION_Y=0; fi
}

FORK_PID=0
fork_process_looper(){
 trap position_up USR1 USR2
 while :; do
   
   POSITION_Y=`expr $POSITION_Y + 1`
   if   [ $POSITION_Y -gt 6 ];then POSITION_Y=6; 
   elif [ $POSITION_Y -lt 1 ];then POSITION_Y=1; 
   fi
   
   if [ $STONE_ALIVE -le 0 ]; then
     STONE_ALIVE=1
     ALMOST_RAMDOM=`date +%S%M%S`
     STONE_Y_1ST=`expr $ALMOST_RAMDOM % 3 + 3`
     ALMOST_RAMDOM=`date +%M%S%S`
     STONE_SPEED=`expr $ALMOST_RAMDOM % 2 + 2`
   else
     STONE_ALIVE=`expr $STONE_ALIVE + 1`
   fi
   
   case $STONE_ALIVE in
     1 | 2 | 3) ADJUST="-$STONE_ALIVE";;
     4) ADJUST="-2";;
     5) ADJUST="-1";;
     *) ADJUST=`expr $STONE_ALIVE - 6`;;
   esac
   
   STONE_X=`expr $STONE_ALIVE \* $STONE_SPEED`
   STONE_Y=`expr $STONE_Y_1ST  + $ADJUST`
   if [ $STONE_X -eq $POSITION_X ] && [ $STONE_Y -eq $POSITION_Y ]; then
     STONE_ALIVE=0
     SCORE=`expr $SCORE + 1`
   fi
   if [ $STONE_X -gt 14 ] || [ $STONE_Y -gt 6 ]; then
     STONE_ALIVE=0
   fi
   
   print_back
   print_front
   my_sleep
   
 done
}

cat <<HERE_DOCUMENT
Hit Enter-key.
Are you ready?
HERE_DOCUMENT

for ff in 3 2 1; do printf "$ff "; sleep 1; done
(fork_process_looper) &
FORK_PID=$!

#---from here, main process.---
ENTER_COUNT=0
my_read(){
 read INPUT_ENTER
 if [ ${FORK_PID:=0} -gt 0 ]; then
   ENTER_COUNT=`expr $ENTER_COUNT % 2 + 1`
   if [ $ENTER_COUNT -eq 1 ]; then kill -USR1 $FORK_PID
   else kill -USR2 $FORK_PID;
   fi
 fi
}

reset_and_exit(){
 EXIT_STATUS=$?
 stty sane
 printf "\033[?25h"
 printf "\033[10;0H"
 trap - 0
 if [ ${FORK_PID:=0} -gt 0 ]; then
   kill $FORK_PID
 fi
 echo "---exit($EXIT_STATUS)---"
 exit $EXIT_STATUS
}

trap reset_and_exit 0 INT QUIT TSTP TERM

stty -echo
printf "\033[?25l"

while :; do my_read; done

サンプルの解説

以下、解説です。

#!/bin/sh

このファイルを何で(=shで)実行するかの宣言になります。
それぞれの実行環境にあわせて正しいPATHを指定しましょう。
たとえば bash で書くならそのパスを、
「which bash」 とか 「find / -name "bash" -print」とかで確認して書きます。

この呪文の先頭「#!」ですが、Web検索で「unix マジックナンバー #!」みたいに探すと詳細な解説が得られます。
(先頭の数バイトがそのファイルが何者であるかを判定する材料になります)
fileコマンドを使ってこのスクリプトファイルを調べると「shell script」みたいに識別されると思いますが、
「 #!」のように先頭に一文字余分なスペースを入れてfileコマンドに渡すと、ただのtextファイルと識別されるのではないでしょうか。

もしスクリプトをデバッグしたい時はこの一行目にオプションをつけてみましょう。
「#!/bin/sh -x」とか「#!/bin/sh -xv」とか。
あるいはコマンドプロンプトから「sh -x このスクリプトファイル」で実行するとか。
指定できるオプションの詳細は man sh を参照。

とはいえ、今回のサンプルスクリプトでは "-x" とか付けても見づらいかもしれませんが...
全体ではなく、部分的にオプションを設定する場合は
スクリプト内の適当な行に「set -x」でONに、
「set +x」でOFFにすることができます。

そもそも /bin/sh を使う理由について。
同じshでも bashなり、tcshなりを使ったほうが使える機能や文法も多いのに、なぜ/bin/sh か?
もちろんその理由を知るのは作者のみですが...私の場合は、
単に好きだからOSにデフォルトで入っているものなら動く確率が高いから、です。
bash とか perlとかだと、別途インストールしないと入っていない可能性もあります。
そのため、/etc/init.d/ で使う起動スクリプトとかも「/bin/sh」が多いのだと思ってます。


# catch flying stones!

コメント行です。
shでは#から右がコメントになります。
試しに「echo a b c; echo a # c」のようにechoコマンドを実行すると、
"c"が表示されないのが確認できると思います。
(「echo "a # c"」のようにクウォートで囲めば#より右も表示されます)
scriptコマンドやらアプリのログ機能やらで何か作業メモを取っているときは
「#なにかのめも」みたいに書けばコマンドプロンプト上でもメモを残せます。

余談ですが、rootのコマンドプロンプトが "#" になっているのが多かったりしますが、
"#"で始まっていれば、うっかりコピペミスしてもコメント扱いされて助かったりする時もまれにあります。

このサンプルスクリプトの話に戻って、
コメントとして、それっぽい英文で書いたとおり、
このシェルスクリプトは「飛んできた石にぶつかるゲーム」(?)です*2
起動して、Enterキーで主人公?を操作して遊んで下さい。


PATH=$PATH

PATHはコマンドを実行する際に、どこからどの順番で探すかを定義する変数です。
ここでは少し、セキュリティ的なメモをします。

今回は何も指定していませんが(PATH=$PATH)
本来は必要に応じて「PATH=/usr/bin:/sbin:/bin」のように利用する変数をきっちり上書きします...安全面を考慮するために。
理由として、
たとえばシェルスクリプト内で rmコマンドを使っていて、
かつ、偽の rm を/usr/local/bin/ とか ./ とかに置いたり、
またはあなたの bashrc に関数やら aliasやらで rmを上書きしていたりしたならば、
シェルスクリプトを実行するとその偽 rm が発動してしまったりします*3
(そしてこれをどうにかrootが使うシェルに忍び込ませる...と)
そこで、
安全面が重視されるスクリプトならば
PATHを再設定したり、
変数をunsetやらunaliasやらで消去したり、
"/bin/rm" のようにコマンドはフルPATH指定したり、
"\rm" のように"\"でエスケープしてオリジナルのコマンドを呼ぶようにしたり
工夫する必要があります。
一方で、
本当はユーザが自分で正しいPATHを設定したのに、スクリプト内でPATHを上書きしてしまった結果、
コマンドが(PATHから)見つかりません状態になることもあるので、
そのへんは、用途の問題になります...

...で、結局どうすれば良いの?
ずばり答えが知りたい方は、セキュアプログラミングとか
shellスクリプトの(入門ではなく)詳細な解説本をご覧ください。*4
大事な話は体系的に知っておいたほうが良い気がするので...一度は本に目を通してみても損は無いと思います。


export LANG=C

環境変数を設定する時はexportコマンドを使います。
たとえば "ls -l" や dateコマンドの出力を「DAY=`date`」のように拾ったときに
意図せず日本語になるのを防ぐために、LANGを指定しています。

ここではLANGしか使っていませんが、
ロケールの変数は LC_ で始まるものがいくつかあって、
そちらの宣言が優先されるのでご注意を。詳しくは man locale 参照。

余談ですが、
コマンドプロンプトはシェル変数PS1です。
画面出力をそのまま他のアプリの入力として使う場合は
(例:TeraTermマクロでプロンプトをwaitする時など)
あらかじめPS1を使いやすいのに変えたりしますね。


POSITION_X=12

あとで使う変数を定義します。
初期化と言うよりもコードの可読性と言うか、
自分がどんな変数を使っているのかを一箇所で整理しておけば、
あとでコードを読んでも思い出しやすいかな〜?、というのが目的です。

ちなみに、もしこのサンプルスクリプトを改変して遊ぶ場合は、
この12という数字は重要になります。
(STONE_SPEEDの公倍数で、STONE_Xと確実に重なる位置を指定する)


print_back()

まずシェル関数について説明します。
もしshell関数に馴染みの無い方は、いまお使いのshにそのままこの11行をコピペで読み込んで、
(csh系をお使いの方は、一旦shに切り替えてから、この関数をベタッと貼って)
そのまま print_back コマンドとして Enterしてみると、実行結果を確認できると思います。
引数を渡した場合は、関数内でシェル変数 $1とか $2とかを使えば拾えます。
私の場合は単純なコマンドはaliasで、
引数が必要だったり複数行に渡るコマンドはシェル関数で、
あらかじめ自分の .bashrc やらの起動スクリプトに定義しています。

関数を使う時は「あらかじめ」宣言する必要があるので、
シェルスクリプト内では順番に気をつけましょう(使用する場所よりも常に前)*5

宣言したシェル関数の内容は、変数とおなじくsetコマンドで確認できます。


printf "\033[2J\033[H"

制御シーケンスです。
(制御シーケンスっていうのが何なのかについてはこちらを参照
ここでは画面消去([2J)とカーソル位置リセット([H)を使っています。
なんとなく自分が慣れている制御シーケンス(ED、CUP)を使ってみましたが、
画面消去はclearコマンド、カーソル関連はtputコマンド等でも実行できます、たぶん。

echo だと改行無しにする場合にオプションが要るので(-n)、
C言語erだった私は printf を多用するクセがあります。
改行やタブなど特殊な文字を指定したり(「printf "A\tBB\nCCC\tDDDD\n\n"」)
数字や文字をパディングして桁数揃えたり(「printf '%010d\n%10s\n' 99 hello」)
printf はいろいろ便利です、が、書式をよく忘れます [worried]


print_front(){
 if [ $STONE_ALIVE -ge 0 ] && [ $STONE_Y -ge 1 ] && [ $STONE_Y -le 6 ]; then
   printf "\033[${STONE_Y};${STONE_X}H*"
 fi
 printf "\033[${POSITION_Y};${POSITION_X}H+"
}

まずprintfの方ですが、
制御シーケンスでカーソル移動してから(\033[縦位置;横位置H)
文字「*」や「+」をプリントしています。
変数を括弧({})でくくっていますが、これがないと、
「$POSITION_XH」ではHも変数名になるし、
「$POSITION_X H」では要らないスペースが入ってしまうので、
あえて括弧つきで変数指定します。

そして if 文について、
全ての条件が真であれば、printf 文を実行しています。
「[ 条件 ];」は「test 条件;」と同じ意味になります。詳細は man test を参照。
他のプログラミング言語と同じノリで
「[条件]」とスペースを空けずに括弧でくくるとエラーになるので気をつけましょう。
例文のように一行でif文を済ませるなら "; then" のようにセミコロン(;)を入れますが、
改行を入れて(もっと読みやすく)書くならセミコロンは不要です。
そして最後は fi で閉じます。if と fi がセットです。

ge は「以上」で、その値も含みます(「〜よりも大きい」だとgt)
しつこいですが、オプションの詳細は man test を参照 [smile]
ge とか lt とかは何の略なのかを覚えてしまったほうが
(greater equal、less than)
その値を含むのか(equal)、含まないのか(than)で迷わないと思います。

条件を複数並べる時、and の場合は &&、 or の場合は || です。
testコマンド内([括弧]の内側)で条件を並べるときは -a や -o を使います。
if文以外でも、「(echo "aa" | grep "aa") && echo hit」のように
(grepが成功したらhitと表示。逆に失敗を拾いたいなら || を使う)
真偽にあわせて連続実行するときにも条件接続が利用できます。

余談ですが、
他のスクリプト言語、たとえば perl とかで
「open ファイル名 or die;」
(=ファイルを開くか死か。open()が偽だとdie()を実行する*6
みたいな「or 条件」を初めて見た時は、笑ってしまいました...


my_sleep(){
 if   [ 条件 ]; then コマンド
 elif [ 条件 ]; then コマンド
 else コマンド; fi
}

コマンドの有無でsleepさせる方法を変える為のシェル関数ですが、
if, elif, else で3つに分岐させています。
(どーでもいいですけど、elifって、言語によってelsifになったり、else ifになったり、ややこしいですよね)

ちなみに、このサンプルスクリプトについて、
ローカルのPC内ではなくリモートの端末に対して実行するならば、
sleep間隔をミリ秒まで縮めると大量のトラフィックが発生する恐れがあるので、
そこは適度に調整して下さい。


U_SLEEP=`which usleep`
if   [ ${U_SLEEP:="-"} != "-"  ]; then usleep 500000;

usleepはマイクロ秒でスリープするコマンドなのですが、
これがあれば使って、なければ次の elifへ分岐しています。

「${変数:="デフォルト値"}」は変数が未定義の際にデフォルト値を設定する方法で、
他にも何通りか方法があります(:=とか-=とか+=とか...詳細は man sh 参照)
ここで usleepが存在しない時に、もっと簡単に
「if [ $N_SLEEP != "" ]」と指定してしまった場合は、
「if [ != "" ]」と変数が一個消滅して文法エラーになります。
次のwhich sleep も変数が空だった場合のエラーを回避するための記述例になります。


elif [ -n "`which sleep`" ]; then sleep 1;

usleepが無い場合は、sleepの有無を調べます。
「"`which コマンド`"」とダブルクウォートで囲むことで、
コマンドが無かった場合にnullではなく、長さ0の文字列に変えています。
興味のある方は「if [ -n `which x` ]; then echo nonzero; fi」という「やりたい意図と違った結果」をお試しください。
(-n オプションの意味は man test を参照。「長さ0では無い」ときに真)

変数がnullだったり、空文字だったり、
スペースが有ったり無かったり、ダブルクウォートで囲ったり囲まなかったり、
それぞれ挙動が変わってややこしいですよね。慣れ、でしょうか?
たとえば複数人でシェルプログラミングしないといけない時は
コーディング規約を作って、安全なルールに縛っていると思います。
(常にダブルクウォートで囲む、変数は必ず初期化する、とか)

話をサンプルスクリプトに戻して、
今回はわかり易い usleep/sleep を例に書きましたが、
私はいつも perl -e "select(undef,undef,undef,0.2);" でミリ秒指定しています。
sleep 1 だと物足りない方はこの方法も分岐に加えてみて下さい。


else echo "Oh, no." >&2; exit 99; fi

sleepできないならexitしましょうという分岐です。
echoであえてエラー出力(>&2)にリダイレクトしています。
趣味ではなくお仕事で作るスクリプトは、標準出力とエラー出力は意識して使い分けたほうが無難です。
コマンドの出力をパイプ(|)等で拾う場合とか、出力の優先度とか、
標準出力(STDOUT)とエラー出力(STDERR)の違いで、想定外の動作になる場合があります。
(たとえば大量の出力が発生した場合に標準出力は破棄されたり、バッファが貯まるまで出力を待ったりします)

exitは正常終了なら0、エラーなら適当な数を指定すると良いかと。
終了ステータスについては後述の reset_and_exit シェル関数でふれます。


position_up(){
 POSITION_Y=`expr $POSITION_Y - 3`
 if [ $POSITION_Y -lt 0 ]; then POSITION_Y=0; fi
}

ゲーム内で主人公(+)の座標を3つ上にずらす処理です。
(後述の処理と合わせて、キーボードのEnterキーで上に移動します)

数字の計算をしたいときは expr コマンドが利用できます。
詳細は man expr 参照。四則演算に + - % / * など使えますが、
「*」は「\*」のようにエスケープするのをお忘れなく。
その出力結果をバッククォート(`)で変数に入れています。

if文ではPOSITION_Yが画面外にはみ出さないように座標の下限を設定しています。


FORK_PID=0
fork_process_looper(){
 trap position_up USR1 USR2
 while :; do
   #-- いろいろやる --
 done
}

後述の「(fork_process_looper) &」で
子プロセスとしてループし続ける処理です。
trap で SIGUSR1 と SIGUSR2 を受けたら position_up します。
trap の話については後述の reset_and_exit シェル変数でふれます。

while 条件; do 処理; done
で条件が真であるかぎり処理を繰り返します。
whileと逆に「偽である限り処理」だと until を使います。
コロン(:)は真を返すので、今回「while :;」は無限ループです。
whileはシェル組み込みのコマンドなので、詳細は man sh を参照。
break やら continue やらの説明もここ(man sh)で確認できます。

参考まで、他のwhileの記述例としては
「ls -l | while read V1ST V2ND ETC; do echo $V1ST; done」
みたいに、read とセットで使ったりします。
このようにパイプから read する場合(この例だと ls -l )
read の引数が1つならそこに全部、
上記のように3つなら(V1ST V2ND ETC、名前はてきとうです)
シェル変数IFSで指定した区切り文字(デフォルトだとスペース、タブ、改行)を元に、
1つめ、2つめ、残りの全て(=ETC)をそれぞれの引数へ格納できます。

read については後述の my_readシェル関数でもふれます。

サンプルスクリプトの話題に戻って、
以下、少し長い fork_process_looper の説明になりますが、
case文以外はこのゲームの仕様の話なので読み飛ばして下さい。


  POSITION_Y=`expr $POSITION_Y + 1`
  if   [ $POSITION_Y -gt 6 ];then POSITION_Y=6;
  elif [ $POSITION_Y -lt 1 ];then POSITION_Y=1;
  fi

主人公(+)の位置をループの度に1つ下げます。
(Enterキーで3つ上にジャンプして、ループ中に1つずつ下に落ちる)
移動範囲は1〜6に限定しています。


  if [ $STONE_ALIVE -le 0 ]; then
    STONE_ALIVE=1
    ALMOST_RAMDOM=`date +%S%M%S`
    STONE_Y_1ST=`expr $ALMOST_RAMDOM % 3 + 3`
    ALMOST_RAMDOM=`date +%M%S%S`
    STONE_SPEED=`expr $ALMOST_RAMDOM % 2 + 2`
  else
    STONE_ALIVE=`expr $STONE_ALIVE + 1`
  fi

石ころを初期化します。
石の飛ぶスタート地点 STONE_Y_1ST と飛来速度 STONE_SPEED を
ほぼランダムに(雑に)決めています。


  case $STONE_ALIVE in
    1 | 2 | 3) ADJUST="-$STONE_ALIVE";;
    4) ADJUST="-2";;
    5) ADJUST="-1";;
    *) ADJUST=`expr $STONE_ALIVE - 6`;;
  esac

case文です。
他のプログラミング言語(Cとか)だとswitch文のイメージでしょうか。
case で指定したものと ")" で指定したものを比較します。
例の「1 | 2 | 3)」は、1か2か3のどれかに一致した場合、
「4)」は、"4" だった場合
「*」はワイルドカードとして使えます。
セミコロンを2つ打っている(;;)のはタイプミスではありませんよ [worried]

このサンプルスクリプトでは
石を放物線っぽく投げるために、縦の位置調整ADJUSTを作ります。
石の経過時間 STONE_ALIVE にあわせて、
Y座標を -1, -2, -3, -2, -1, 0, +1 ...のように入れています。


  STONE_X=`expr $STONE_ALIVE \* $STONE_SPEED`
  STONE_Y=`expr $STONE_Y_1ST  + $ADJUST`
  if [ $STONE_X -eq $POSITION_X ] && [ $STONE_Y -eq $POSITION_Y ]; then
    STONE_ALIVE=0
    SCORE=`expr $SCORE + 1`
  fi
  if [ $STONE_X -gt 14 ] || [ $STONE_Y -gt 6 ]; then
    STONE_ALIVE=0
  fi

石の座標を確定します。
石ころと主人公の座標が一致した場合はSCOREを加算、
石ころが画面外に出たら経過時間STONE_ALIVEを初期化します。


  print_back
  print_front
  my_sleep

前述の各シェル関数のメモを参照。
背景画面を描画、石ころと主人公を描画して、
スリープして、ループを繰り返します。


cat <<HERE_DOCUMENT
Hit Enter-key.
Are you ready?
HERE_DOCUMENT

ヒアドキュメントです。
"<<" につづく文字列を終わりの目印として(ここではHERE_DOCUMENT)
その行までのすべての文字列を入力値として
コマンド(ここではcat)に渡します。
終わりの目印は、左にスペースは不可なので、
エディタ等で開始位置を自動インデントしている場合はご注意を。

なにか固定の文章を書く時に、echo や printf を並べるよりも楽ですね。
つまり、
一番最初の print_back シェル関数で
なんでヒアドキュメント使わなかったんだろう?って話です
...まぁ、いいか。


for ff in 3 2 1; do printf "$ff "; sleep 1; done

for文で、3カウントして気分を盛り上げます [smile]

「for 変数 in 値1 [値2...];」 で値1,2...を順次取り出して、
「do 処理 done」 の中で1つずつ実行していきます。
for はシェルの組み込みコマンドなので、詳細は man sh を参照。

他のfor文の例としては
「for n in `ls -1tr`;do echo $n; done」
(-1は数字のイチ、エルではないです。trは時系列昇順)
のように、lsコマンドの出力をバッククォートで直接渡して
ファイルを古い順に echo したりできます。


(fork_process_looper) &
FORK_PID=$!

コマンドやシェル関数などをバッググラウンド起動(&)して、
その起動直後に $! を使って、プロセスIDを拾っています。
(なお、自分自身のプロセスIDを拾う場合は $$ を使います)
今回はべつに括弧でくくる必要は無かったんですが、
コマンドが複数ある時は「(sleep 1; sleep 1; sleep 1) &」 のように指定できます。

参考まで、
コマンドプロンプトからの実行で & を使う場合は、
実行中のコマンドは「jobs」で確認、 そこで確認したジョブ番号を使って、
「fg 番号」でフォアグラウンドへ、「bg 番号」でバックグラウンド起動できます。
実行に時間がかかるコマンドをバックグラウンドで動かすには、
(例えば私の場合は「tripwire --init」でデータ更新するときなどは)
コマンドを起動、パスワード入力などをやって実行開始となったタイミングで、
一旦 Ctrl s でサスペンドして、
「bg %%」のように、いま止めたプロセスをバッググラウンド起動したりしてます。

話をこのサンプルスクリプト例に戻して、
ここまでの記述は主に
子プロセスとしてバックグラウンド起動する fork_process_looper の為の処理、
ここから下の記述は
ユーザからのキーボード入力を受け付けるメイン処理になります。


ENTER_COUNT=0
my_read(){
 read INPUT_ENTER
 if [ ${FORK_PID:=0} -gt 0 ]; then
   ENTER_COUNT=`expr $ENTER_COUNT % 2 + 1`
   if [ $ENTER_COUNT -eq 1 ]; then kill -USR1 $FORK_PID
   else kill -USR2 $FORK_PID;
   fi
 fi

ユーザからの入力を受け付けて、そのタイミングで各変数を更新します。

read はシェル組み込みのコマンドなので、詳細は man sh で確認できます。
キーボードからの文字列と改行(Enter)の入力をここで待ちます
...が、今回は拾った文字列(INPUT_ENTER)は使っていません。
Enterキーを押して遊ぶスクリプトです。

read の入出力の設定を色々変える場合は
手がかりの1つとしてsttyコマンドがあります。
今回はreadの前後で stty -echo を設定して、stty sane で戻しています。
(キーボード入力した内容は画面非表示(-echo)に変えている)

このサンプルスクリプトでは、FORK_PID で子プロセスのIDを取得して、
そこ目がけてシグナルを投げるのですが(kill)、
投げた回数をカウントして(ENTER_COUNT)
SIGUSR1 と SIGUSR2 を交互に投げています。
同じシグナルを連続で投げても、たぶん仕様上、受け取ってもらえないからです。
(シグナルはそもそも連続でいくつも投げるような機能では無い、たぶん)
処理の流れとしては、
ユーザからのEnterキー入力を契機(トリガー)に、USR1 や USR2 を投げ、
fork_process_looper がそれを trap で受け取って、
そのタイミングで主人公を上にジャンプさせています。


reset_and_exit(){
 EXIT_STATUS=$?
 stty sane
 printf "\033[?25h"
 printf "\033[10;0H"
 trap - 0
 if [ ${FORK_PID:=0} -gt 0 ]; then
   kill $FORK_PID
 fi
 echo "---exit---"
 exit $EXIT_STATUS
}

終了時に呼ぶ予定のシェル関数です。状態を元に戻して終了します。

最初に $? 変数で直前の終了ステータスを拾って、最後のexit でそれを使います。
このサンプルスクリプトの終了直後に「echo $?」することで終了ステータスを確認できます。
起動直後に Ctrl c したり、ゲーム最中に Ctrl c したり、
シェルの途中に exit コマンド指定したりで、
それぞれ $? の数字が変わるのが確認できると思います。

終了ステータスは各コマンドや実行環境に依存します。
たとえば grepコマンドは見つからなかった場合 1 です(man grep より)
正常終了だと0を返す場合が多く、
if文で終了ステータス0 は真とみなされます。
つまり、「if [ `echo a | grep a` ]; then echo true; fi」で試せます。
この数字(0以外)、
何かの不可解なプロセス終了をログから追わねばならない時の手がかりとしては

  • 数字の128(2進数で 1000 0000。最上位ビットをONした状態)
  • シグナルの番号(kill -l とか man kill で見れます)

の組み合わせになる場合がある、というのは知っておくと良いかもしれません。
このサンプルスクリプトを開始直後に Ctrl c した時は、
自分の環境だと 130(128 + SIGINT) になりました。
終了ステータスが130だったり139だったりだと、
ぱっと見てもシグナル番号だって気づきませんよね...
シグナルについては、この次の trap の説明時にもふれます。

話をサンプルスクリプトに戻します。
終了ステータスを拾った後は、各種設定を元通りに戻しています。
他の場所でエコーをOFFにしているので、ONへ
(echoではなくsaneにしたのは単に手抜きです。詳細は man stty 参照)
おなじくカーソルを非表示から表示に(printf "\033[?25h")、
カーソル位置を10行目0桁に移したのは("\033[10;0H")なんとなくです、無くても可。
FORK_PID には子プロセスを起動した場合にプロセスIDが入るので、それも止めます(kill)
シグナルハンドラをリセットして(trap - 0)いるのは、
このすぐ後の「exit $EXIT_STATUS」を拾わないためです。


trap reset_and_exit 0 INT QUIT TSTP TERM

前述のreset_and_exitをシグナルハンドラとして登録しておきます。
trapを使って、指定したシグナル群(ここでは 0 INT QUIT TSTP TERM)
を受けた時に行う動作(ここでは reset_and_exit)
を指定できます。つまりシグナルをハンドリングしています...

もしシグナルという言葉が初耳であれば
Ctrl c でプロセスを中断したことはあるでしょうか。あれはSIGINTです。
(Signal Interrupt. 割り込みや中断するときに使う*7
Ctrl z でプロセスをサスペンドするとSIGTSTPでしょうか。
(Terminal Stop)
kill コマンドは名前が物騒ですが(=殺)、これがシグナルを飛ばすコマンドです。
シグナルの一覧はkill -l で見ることができます。
Unix/Linuxではシグナルは重要なので、どんなものがあるかは一度は目を通しておくと良いかと。

というわけで、シグナルを適当に拾ってます。
SIGKILL(=9)とかは強制終了なので、たぶん指定したところで拾えないのでご注意を。
0は、シグナルではなくふつうにexitなどで終了した場合を拾うときに指定します。
trapはシェルの組み込みコマンドなので、詳細は man sh を参照。


stty -echo
printf "\033[?25l"

メインの処理に進む前の、最後の下準備。
キーボードから入力された文字を非表示に(stty -echo)
そして制御シーケンスでカーソルも非表示に("\033[?25l")しています。
これらの設定変更と対になるのが前述の reset_and_exit です。


while :; do my_read; done

ここがメインの処理になります。
whileループで永遠にキー入力を待ち続けます。


以上です。お疲れ様でした [smile]

サンプルのセンスはさておき、シェルでもいろいろできるな〜
という一例になればと思います。

シェル関数とかは自分用の設定を用意しておくと
(よく使うコマンドを ~/.bashrc とかに仕込んでおくと)
なにかと便利ですよね。


*1 プログラミング一年生だった当時の自分が読んだら胃もたれする内容だと思いますが...あまり頑張らずに、遊びとして楽しんで頂ければ
*2 こういうゲームを作って遊ぶなら、例えばテトリス風のコードとかも、Webで探すといろんなプログラミング言語でサンプルを公開してくれていますよね
*3 偽ものではないのですが、alias rm='rm -i' みたいに定義されていてスクリプトが rm実行時に止まってしまった、というのはありました
*4 入門本はもちろん必要ですが、目的に合わせた粒度の本が必要なので、これから始める方は複数読む予定でいましょう...まずは目次から
*5 たとえばJavaとかは宣言する順番は前後しても実行できるので、コードの可読性を優先して好みの順番で書けますよね
*6 「フフフ...好きな方を選ばせてやる」とか言われて、私は主人公キャラではないのでdieするパターン
*7 このINTはinteger(整数)じゃないよ、と新人だった頃の自分に教えてあげたい