シェルスクリプトでゲーム制作は可能か? - タイマ編

現状の結論: Bashじゃないと無理
シェルスクリプトでゲームを作る試みはいくつか有る。例えば、ChessbashはBashで書かれたチェスプログラムで、Unicodeを使って視認性の高い盤面を出力し、ある程度の"実用性"も確保している https://github.com/thelazt/chessbash
じゃぁアクションゲームはできるのかというのは当然出てくるところだが、

  • 入力: だいたいの端末はマウス入力をサポートしており、Linux等event deviceから直接取得できるOSもある
  • オーディオ: /dev/dsp が 8kHzのmono PCM出力をサポートしている
  • 描画: SIXELを使えばビットマップを出せる。極小フォントを使う手もあるが...

と、かなり環境は整っているように感じられる。
ところが一点だけどうやっても解決策が浮かばないのがタイマで、今のところ実用的なタイマはbashのようなシェルの独自拡張を使わないと不可能なのではないかと考えている。たぶんbashで実現したタイマか、perlか何かのワンライナーで実現したタイマの選択式とするのが最もポータビリティが高いのではないかと思う。
(もう一点、いわゆるnon-blocking I/Oも不可能なんじゃないかと思っているが、後述のようにタイマがあればそこからprintするなりsignal送るなりなんなりの方法で無理矢理入力をunblockさせることは簡単なはず。)
ちなみにPOSIX shellには配列すら無いため、常識的な速度でPCM合成を行うには多分loop unrollは必要と思っていて、一旦別の高級言語で書いておいてシェルスクリプトに変換するという過程はどうやっても出てくると考えている。(シェルスクリプトを生成するシェルスクリプトを作り、evalでfunction定義にすれば良いんじゃないかという気もするけど。)

タイマの必要性と求められる性質

ゲームに使用するタイマに求められる性質は:

  • "発火"間隔が十分に短い: 一般にゲームは1秒間に30 - 60回画面更新を行う必要があるため、1秒よりも十分に短い間隔で発火する必要がある。
  • 他の処理の影響を受けず一定間隔である: 画面更新間隔が一定でないのはプレイフィールに大きな影響を及ぼす。また、オーディオ生成のインターバルも一定でなければならない。
  • リスタート可能である(位相が取得または調整可能である): ゲームをバックグランドに廻したり、PCをサスペンド & レジュームさせるような場合に、タイマを再度同期させる必要がある。

シェルスクリプトではスレッドを作成できないため、タイマプロセスを作成し"一定間隔で文字をprintする"ことでタイマを作ることになる。
一番簡単そうなのはOS固有の時刻ソース、例えばLinux/Cygwinで言うところの /proc/uptime を監視し、変化があったタイミングでprintというもの。ただ、今回はこれを却下してbashの独自機能であるreadのタイムアウトを使っている。

× ビジーポーリング、sleep

OSは /proc/uptime やdateコマンドのような時刻表示手法をもっていることが多いため、これをひたすら呼んで変化点でprintとするのが一番簡単そうな気はする。が、これをやってしまうとタイマープロセスがCPUを埋めてしまうため、本来のゲームコードが動作させられなくなってしまう。
POSIXはniceのようなユーティリティ( http://pubs.opengroup.org/onlinepubs/9699919799/utilities/nice.html )を提供しているが、タイマプロセスのniceを上げてしまうと他の処理にタイマプロセスが割り込まれることでタイマの発火そのものが行えなくなってしまう可能性がある。
じゃぁsleepコマンドを挟めば良いじゃん と、思うかもしれないが、sleepは最短で1秒の待ちしか提供しない。

A non-negative decimal integer specifying the number of seconds for which to suspend execution.

× PCM再生によるブロック

では /dev/dsp が一定ペースで書き込みバイトを消費するのを使うのはどうか?例えば、Cygwinで30秒ぶん、つまり 8000 バイトで1秒を30個 /dev/dsp に出力するとかなり正確に30秒掛かることがわかる。

$ dd if=/dev/zero of=/dev/dsp bs=8000 count=30
30+0 レコード入力
30+0 レコード出力
240000 bytes (240 kB, 234 KiB) copied, 30.1313 s, 8.0 kB/s

... しかし、この方法には微妙な罠があって、

  1. /dev/dspの処理ブロックサイズに依存する: /dev/dspはある一定のバイトを受信しないと処理を開始しないため、このブロックサイズ単位でしかタイマとして機能しない。
  2. 本来のオーディオ出力に干渉する: 上記コマンドラインを音楽なりなんなりを再生させながら実行するとボリュームが低下するのがわかる。計時のための音声と本来の音楽のミキシングが行われてしまう。

特に前者が深刻で、

  Implementation Notes
  1. Audio structures are malloced just before the first read or
     write to /dev/dsp. The actual buffer size is determined at that time,
     such that one buffer holds about 125ms of audio data.

125ms = 1/8 秒、つまり、この方法では最高でも 8Hz のタイマしか作れないことになる。これはゲームのタイマとしては少々精度が足りない。もちろん、/dev/dspをioctlで制御すればこの辺のパラメタは変更可能な可能性が高いが、どっちにせよシェルスクリプトからは直接ioctlを発行することはできない。
後者の問題も、本来のBGMで計時すれば良いじゃんと思われるかもしれないが、Cygwinの場合1秒程度のバッファを持っているためこれがそのままオーディオのレイテンシになってしまう。このため、常識的なレイテンシを保ちながらインタラクティブオーディオを出力するためには、一定ペースでPCMを生成して書き込みつづけるくらいしかなく、ブロックするまでひたすら書き込むという手法は取れない。

○? bashのread拡張を使用する

というわけで、bashの拡張を使うしか無いんじゃないか。。

#!/bin/bash
while true; do 
    read -t 0.02 ; printf "C\n" ; done

bashのreadは小数のタイムアウトを取れることを使用して、そのタイムアウトをtickとして出力するタイマにしてみた。timeoutに小数が使えるのはbash4以降(2009 -)に限られる。このため、それなりに最近のbashを要求することになる。

y.  The `-t' option to the `read' builtin now supports fractional timeout
    values.
#!/bin/bash
function ticker_wait { # ★ これもbashism
    local MM=;
    read -u 4 MM
}

# Bashism
exec 4< <(./ticker.sh)

while true; do 
    COUNT=0
    printf -v NOW0 "%(%s)T" -1 # ★ これもbashism
    while true; do
        printf -v NOW1 "%(%s)T" -1
        ticker_wait;
        COUNT=$(($COUNT+1))
        if [[ $NOW1 != $NOW0 ]] ; then break; fi
    done
    echo $COUNT
done

measure.shは計測用のコードで、1秒間のtick数を出力する。出力は大体48 - 49で安定しており、この精度であればゲーム用のタイマとしても使用可能なのではないかと思う。

$ ./measure.sh
7
49
48
49

ちなみにprintfによる時刻取得もbashism(bashの独自拡張の利用)で、そもそもPOSIX shellでは外部コマンド/ファイルに依存しない時刻取得自体不可能なのではないだろうか。。

read timeoutの精度

(あとで真面目に書く)
この手のread timeoutを移植性の高いタイマとして使う手法は比較的よく見られる手法で、C言語ではselect()がWindowsを含めたOSに大抵存在することを使用してselect()をポータブルなsleepとして使ったりもする。
ただし、タイムアウトの精度は基本的によろしくないことが多い。
bashのread timeoutはfalarm()関数で実装されており、これはsetitimer()に依存している https://git.savannah.gnu.org/cgit/bash.git/tree/lib/sh/ufuncs.c?id=b0776d8c49ab4310fa056ce1033985996c5b9807#n48 。setitimer()の無いプラットフォームではalarmにfallbackし、これは1秒の精度しかない。
システムが1秒未満のタイムアウトをサポートしていたとしても、タイマの起床がtick(HZ)の単位に丸められるプラットフォームも有る。このため、この手の手法で実現されるタイマは"1000Hz程度のスケジュールタイミングにアラインされ、多少のジッタを常に含む"というのを前提に考える必要がある。

pingタイムアウト、他

他の没アイデアとしては、pingコマンドのタイムアウトを使う(BSDpingは1秒未満のintervalを使えるはず)というものも有ったが、どっちにせよ絶対時刻の取得もできないので実験していない。
2CPU以上のシステムを仮定するなら、ビジーポーリングは選択肢になるかもしれない。CPUを占有してしまうこと以外にはこれといって問題は無いように思える。仮に1秒未満の変化が取れなかったとしても、ディレイループの長さを動的に調整するといった方法で必要なタイミングを抽出することは可能なのではないかと思う。
busyboxをはじめ、実はsleepコマンドはミリ秒精度のsleepをサポートしていることが多い。このため、sleepコマンドループもそれほど悪くはない(ただし重い)選択肢になるかもしれない。