前回は、DEN将棋・DEN将棋Xのソフトウェア構成・shogi-serverの紹介・PSGIによるhttpゲートウェイdaemonについて記載しました。今回は続きで、daemonにより起動されるワーカープロセスと、棋譜ファイルについてです。
前半はPerlによる伝統的なforkとSocketモジュールを使ったTCP Socket通信プログラムのお話です(古い手法によるコードです)。後半はna2hiroさんが発案されたJKFという棋譜フォーマットとその保存・読み込みについて触れます。
4.ワーカープロセス
(1)PSGIとワーカープロセスの役割分担
ワーカープロセスは、PSGIによるdaemon起動時に決まった数(=席数)の子プロセスとしてIPC::Open2を使ってforkされます。この子プロセスが利用者(=ブラウザ)の席に相当します。簡単にPSGIとワーカープロセスの役割についてまとめると下記のようになります。
PSGIの役割
- メインの仕事はブラウザからのリクエストをさばくこと
- ブラウザのセッション管理
- ログインリクエストによるブラウザセッションとワーカープロセス番号(着席番号)との紐付け/空席管理
- 送信リクエストによりセッションID(着席番号)毎に対応するワーカープロセスにブラウザからのリクエスト情報を渡す。
- 定期的な受信リクエストによりセッションID(着席番号)毎に対応するワーカープロセスに溜まっているshogi-serverからの情報をブラウザにレスポンス情報として渡す。
ワーカープロセスの役割
- 親プロセスであるPSGIとshogi-serverとの情報の橋渡し
- 親プロセスとのIPC::Open2を使ったpipe通信
- CSA標準および拡張コマンドプロトコルによるshogi-serverとのTCP Socket通信
- 対局者の状態管理
(2)シーケンス図
前回の連関図のとおり、複数のソフトウェアが連携して動作するので、ワーカープロセスの役割についてなかなかイメージしにくいと思います。そこで、設計時作成した図のうち標準モードによる正常系のシーケンス図を掲載しておきます。最初の図はマッチメークするまで、2番目の図は対局開始から終局までです。これらの図を見るとブラウザ・PSGI・Client(ワーカープロセス)・shogi-serverのやり取りがわかると思います。
(3)shogi-serverへの接続
ここからがワーカープロセスの具体的説明です。まずはブラウザからのログインリクエストに対してshogi-serverにログインするまで。昔ながらのSocketモジュールを使用しています。
★1は標準入力(親プロセス=PSGI)から接続要求を待つループです。接続要求を受け取るとループを抜けます。また、このループではデータ要求コマンドと中断対局一覧要求コマンドも受け付けるようにしています。データ要求ではキューのデータを標準出力(親プロセス=PSGI)に出力してループに戻ります。最後にFINを付けるルールにしました。中断対局一覧要求では、後述する&get_buoy_listにより得た中断対局情報を標準出力に出力してループに戻ります。
★2は'ConnectRequest'に続いて取り出された、shogi-serverのホスト名とポート番号です。★3でホストがDNS正引き検索されますが、実際には固定でローカルアドレスを指定してきますのでIPアドレス構造体に変換されるだけです。エラー処理は不要ですがdieします。
★4では、IPアドレス構造体とポートNoを合わせたSOCKADDR 構造体に変換し、TCP Socket Streamを生成し、shogi-serverに接続しています。ここでもエラーだとdieしていますのでshogi-serverがいないとワーカープロセスはここで落ちます。daemonの起動順番はshogi-serverを先にしていますのでとりあえず問題ありません。
★5は、SOCKET出力と標準出力をバッファリングしないで指示があれば必ず全部吐き出す設定です。
★6は、この後4引数selectを使う準備として、この関数に渡すための、SOCKETとSTDINファイルハンドルのディスクリプタ番号ビット列を生成しています。
use warnings;
no warnings qw(once);
use Socket;
#----(省略)----
#接続要求待ち
while(1){ #無限ループ
$|=1;# これが重要
while(<STDIN>){ #★1
chomp;
@da1 = split(/:/,$_);
if($da1[0] eq 'ConnectRequest'){# 接続要求
last;
}elsif($_ eq 'getque'){# データ要求コマンド
for(@QueData){
print "$_\n";
}
print "FIN\n";
undef(@QueData);
next;
}elsif($_ eq 'getbuoylist'){
$blist = &get_buoy_list();
print "$blist\n";
next;
}else{
next;
}
}
my $host = $da1[1];# ★2
my $port = 4081;# ★2
# IP アドレスを構造体に変換
my $iaddr = inet_aton($host) or die "$host は存在しないホストです。\n";# ★3
# ソケット生成 ★4
my $sock_addr = pack_sockaddr_in($port, $iaddr);# SOCKADDR 構造体に変換
# 接続 ★4
socket(SOCKET, PF_INET, SOCK_STREAM, 0) or die "ソケットを生成できません。\n";# TCP IPなので
connect(SOCKET, $sock_addr) or die "$host のポート $portに接続できません。\n";
# SOCKETをバッファリングしない ★5
select(SOCKET); $|=1;
# STDOUTもバッファリングしない ★5
select(STDOUT); $|=1;
my($State,$InDa,$Rout,$Rin,$Ret,$RinSoc,$RoutSoc) = ();
$RinSoc = &set_bits(SOCKET); # ★6
$State=0;
$Rin = &set_bits(STDIN); # ビット列を生成 # ★6
(4)ログイン待ちループ
接続要求コマンドによりshogi-serverへ接続後は、ログイン待ちループに入ります。このループの最初に4引数selectにより標準入力からのデータが有るかどうかチェックします。10ms待っても何も来なければ、「対局開始準備待ち又はログアウト成功待ち処理」(&auth)後、ログアウト成功又は、対局開始状態になっていたらループを抜けます。他の状態であればループに戻ります。
標準入力(PSGI)からの受信データがあれば、終了・ログイン・ログアウト・受信データ要求・拡張GAMEの各コマンドにより条件分岐します。
終了コマンドを受信していればループを抜けて最初の接続要求待ちループに戻ります。ログインコマンドであれば対局開始準備待ち処理(&auth)を行った後対局開始状態になっていたらこのループを抜けて、対局ループに入ります。対局開始状態になっていなかったら、再びこのループの先頭に戻ります。ログアウトコマンドであればログアウトコマンドをshogi-serverに送信しログアウト成功待ち処理(&auth)後ログアウト成功状態だったら最初の接続要求待ちループに戻ります。ログアウト成功状態でなかったら再びこのループの先頭に戻ります。
while(1){
$Ret = select($Rout=$Rin, undef, undef, 0.01);#10ms標準入力から待つ
if($Ret){ # 入力データがやってきてたら
$_ = <STDIN>;
if(defined($_)){
chomp;
if($_ eq 'q'){# 終了コマンド
$State=0;# 0に戻す
close(SOCKET);
last;#抜ける
}elsif(/^LOGIN\s/){# ログイン
&send_da("$_",$dh);#
$LoginSt = $_;
&auth($dh,\$State,\$Sengo);#認証待ち
last if($State == 3); #対局開始なら抜ける
}elsif(/^LOGOUT/){# ログアウト
&send_da("$_",$dh);#
&auth($dh,\$State,\$Sengo);#ログアウト成功待ち
last if($State == 10); #ログアウト成功→接続要求待ちへ
}elsif($_ eq 'getque'){# 受信データ要求コマンド
for(@QueData){
print "$_\n";
}
print "FIN\n";
undef(@QueData);
}elsif(/^%%GAME/){#拡張GAMEコマンド
&send_da("$_",$dh);#
$GameSt = $_;
$State = 1;#続けてゲームサマリー受信待ち=認証待ち
&auth($dh,\$State,\$Sengo);#認証待ち処理
last if($State == 3); #対局開始なら抜ける
}else{
push(@QueData,'##ER_001');#ログイン待ち不正コマンド
}
}
}else{
&auth($dh,\$State,\$Sengo);#認証処理
last if(($State == 3) or ($State == 10)); #対局開始かログアウト成功なら抜ける
}
}
if($State == 3){#対局開始ならば
&rec_soc($Sengo);
}
}# 無限ループ終わり
(5)対局開始準備(ログイン送信後の認証処理)
このサブルーチンでは、同じく4引数selectを使用して、1msだけshogi-serverからのデータを待ちます。データが来ていなければ何もしないで戻ります。データが来ていた場合、下記の状態遷移・処理を行ってから戻ります。
- ログイン待ち状態でログインOK受信->%Loginをキューに保存し拡張Gameコマンド待ちへ
- 拡張Gameコマンド待ち状態でログアウトOK受信->切断
- ゲームサマリー受信待ち=認証待ち状態でBEGIN Game_Summary受信->END Game_Summaryを受信するまでの専用ループで次の処理
- 開始時刻・先手/後手ユーザー名・自分が先手か後手か・中断前の棋譜(再開対局の場合)
- 再開の時は中断棋譜が送られてくるので★2でそれをセットしキューへ保存
- END Game_Summary受信したらAGREEをshogi-serverに送信後、受信したSTART:に続く、対局開始日時分秒をセットし対局開始状態へ。この時再開対局ならば中断リストから対象対局情報を削除(★1)。
sub auth{#Login送信後の認証処理
my($dh,$State_ref,$Sengo_ref)=@_;
my($ret2,$da,@gameid,@wstart,$wy,$wm,$wd,$wh,$wmi,$ws);
my $State = $$State_ref;
my $Sengo = $$Sengo_ref;
$RoutSoc = '';
$RinSoc = &set_bits(SOCKET);
$ret2 = select($RoutSoc=$RinSoc, undef, undef, 0.001);#1ms Socket入力から待つ
if($ret2){# データがやってきてたら
$da = <SOCKET>;
if(defined($da)){
chomp($da);
if($State == 0){#ログイン待ち
if($da =~ /^LOGIN:.+\sOK/){
#--省略:ログインOKだったらOKをキューへ保存してGameコマンド待ちへそうで無ければ戻り--
}
}elsif($State == 11){ #Gameコマンド待ち
if($da =~ /^LOGOUT:completed/){#ログアウトOK
#--省略:ログアウトOKだったらOKをキューへ保存して切断して戻り--
}
}elsif($State == 1){ #ゲームサマリー受信待ち=認証待ち
if($da =~ /^BEGIN\sGame_Summary/){
#Beginの後は立て続けに来るので専用待ちループを設けないと
#受信間に合わない
while(<SOCKET>){
chomp;
&write_log("$_",$debugflg);
if(/^END\sGame_Summary/){
&send_da("AGREE\n",$dh);
$State=2; #Start待ち
while(<SOCKET>){#受信間に合わないのでSTART専用待ちループ
chomp;
if(/^START:/){
@wstart = split(/\+/,$_);
$State=3; #対局開始
#--- 開始日時分秒セットしてキューへ(省略)---
if([split(/\s/,$GameSt)]->[1] =~ /^buoy_(.+)/){ # ★1
&delete_buoy($1);#buoy登録ゲームの削除
}
last;
}
}
last;
}elsif(/^Game_ID/){#GameID
#--省略:GameIDだったら開始時刻をキューへ保存して戻り--
}elsif(/^Name\+:(.+)/){#先手ユーザー名
#--省略:先手ユーザー名だったら先手名をキューへ保存して戻り--
}elsif(/^Name\-:(.+)/){#後手ユーザー名
#--省略:後手ユーザー名だったら後手名をキューへ保存して戻り--
}elsif(/^Your_Turn:(.)/){
if($1 eq '+'){#先手
#--省略:自分が先手だったら先手通知をキューへ保存して戻り--
}elsif($1 eq '-'){#後手
#--省略:自分が後手だったら後手通知をキューへ保存して戻り--
}
}elsif(/^(\+|-)[0-9][0-9][1-9][1-9]/){#中断前の棋譜 ★2
push(@QueData,"%Kifu_$_");#棋譜通知
}
}
}elsif($da =~ /^##\[ERROR\]/){
push(@QueData,"##ERROR_COM");#ERROR_COM通知
$State = 11;# エラー受信したらGameコマンド待ちに戻る
}elsif($da =~ /^LOGOUT:completed/){#ログアウトOK
push(@QueData,"%Logout");#ログアウト&切断通知
$State=10;
close(SOCKET);
}
}
undef($da);
}
}
undef $ret2;
$$State_ref = $State;
$$Sengo_ref = $Sengo;
}
(6)対局
対局部分のみ抜き出した状態遷移図を下記に示します。
下記がこの部分を受け持つサブルーチンです。色々とっちらかってますが、4引数selectを使って、SOCKET(shogi-serverとの通信)からとSTDIN(PSGI)からのデータ到来チェックを同時に行っています。★1
どちらかからデータが来ていると4引数selectの戻り値はtrueとなるので、trueになってたら★2でSOCKETからデータが来たかどうか、★3でSTDINからデータが来たかどうかをチェックします。データが到来している場合、$RoutSocは右から数えてファイルディスクリプタ番号のビット位置に1が立っている数値になります。★2★3で使用している&to_binは、この数値を2進数表記の文字列に変換するサブルーチンです。この文字列をsubstr関数に渡すことにより、ファイルディスクリプタ番号の位置に1が立っているかどうか(=データがそのファイルハンドルに到来しているかどうか)を判定しています。
★5では、手番側が中断コマンドを送ってきた時の処理です。サーバーへSETBUOYコマンド送信して##[SETBUOY] +OK受信待ちへ遷移します。★4がshogi-serverから中断完了(##[SETBUOY] +OK)を受信した時の処理です。ここで中断対局リスト保存を行います(&write_buoy)。前回の記事でも触れましたがshogi-serverには中断対局リストを得るコマンドが無いんです。そこでログイン前に中断対局リスト取得リクエスト(/****_getbuoylist)をPSGIが受信した時に、ここで保存したCSVファイルを読み込んでGameNameや先手後手情報などをブラウザ側にレスポンスするわけです。実際にCSVファイルにアクセスする &write_buoy,&get_buoy_list,&delete_buoyについては省略します。
[注意!]下記コードは一部省略しています。
sub rec_soc{
my($sg)=@_;#先手後手
my($ret2,$da,$rin,$rout,$state);
if($sg eq '+'){
$state = -1;#上位からデータ待ち(送信待ち)
}else{
$state = 1;#受信待ち
}
my $set_buoy_ok = 0;
#ソケット受信処理
$RoutSoc = '';
$RinSoc = &set_bits(SOCKET,STDIN);
while(1){
$ret2 = select($RoutSoc=$RinSoc, undef, undef, 0.01);#10ms Socket入力から待つ ★1
if($ret2){# 受信データがやってきてたら
if(substr(&to_bin($RoutSoc),-1*(fileno(SOCKET)+1),1) eq '1'){# SOCKETから受信 ★2
$da = <SOCKET>;
if(defined($da)){
chomp($da);
if($state < 20){#ログアウト成功待ち以外なら
if($da =~ /\-[0-9][0-9][1-9][1-9]/){ #後手データが来たら
#--省略:PSGIからのデータ待ちへ&後手データをキューへ
}elsif($da =~ /\+[0-9][0-9][1-9][1-9]/){ #先手データが来たら
#--省略:PSGIからのデータ待ちへ&先手データをキューへ
}elsif($da =~ /%TORYO|#ILLEGAL_MOVE|#TIME_UP/){
push(@QueData,$da);# キューにセット
#RESIGN,WIN or LOSEは立て続けに来るので専用待ちループを設けないと
#受信間に合わない
while(<SOCKET>){
chomp;
#----(省略)-----
}
close(SOCKET);
last;
}elsif($da =~ /^##\[ERROR\]/){#中断失敗
#--省略:PSGIからのデータ待ちへ&エラーをキューへ
}elsif($da =~ /^##\[SETBUOY\]\s\+OK/){#中断成功 ★4
push(@QueData,"##SETBUOY_OK");# キューにセット
&write_buoy();
&send_da("LOGOUT",$dh);#
$state = 20;#ログアウト成功待ち
$set_buoy_ok = 1;
}elsif($da =~ /##\[CHAT\]/){
push(@QueData,$da);# キューにセット
}
}elsif($state == 20){ #ログアウト成功待ちなら
if($da =~ /^##\[ERROR\]/){
#--省略:ログアウト失敗をキューへ&クローズして接続待ちへ
}else{#LOGOUT成功
#--省略:ログアウト成功をキューへ&クローズして接続待ちへ
}
}
}
}
if(substr(&to_bin($RoutSoc),-1*(fileno(STDIN)+1),1) eq '1'){# STDINから受信 ★3
$_ = <STDIN>;
if(defined($_)){
chomp;
if($_ eq 'getque'){# 受信対局データ要求コマンド
for(@QueData){#キューデータを1個づつPSGIへ
print "$_\n";
}
print "FIN\n";
undef(@QueData);
}elsif($_ eq 'q'){
#----(省略)-----
}elsif(($sg eq '+') and (/^\+[0-9][0-9][1-9][1-9]/)){#自分の手だったら
&send_da("$_",$dh);#サーバーへ先手データを送信して後手データ受信待ちへ
$state = 1; #受信待ちへ
}elsif(($sg eq '-') and (/^\-[0-9][0-9][1-9][1-9]/)){#自分の手だったら
&send_da("$_",$dh);#サーバーへ後手データを送信して先手データ受信待ちへ
$state = 1; #受信待ちへ
}elsif(($sg eq '+') and (/^\-[0-9][0-9][1-9][1-9]/)){#先手なのに後手データだったら
push(@QueData,'##ER_003');#先手なのに後手データ
}elsif(($sg eq '-') and (/^\+[0-9][0-9][1-9][1-9]/)){#後手なのに先手データだったら
push(@QueData,'##ER_004');#後手なのに先手データ
}elsif(/^%%SETBUOY/){#中断による棋譜登録 ★5
&send_da("$_",$dh);#サーバーへSETBUOYコマンド送信して##[SETBUOY] +OK受信待ちへ
$state = 10; #OK待ちへ
}elsif(/^%%CHAT/){
&send_da("$_",$dh);#サーバーへChatコマンド送信
}else{
push(@QueData,'##ER_010');#不正コマンド
}
}
}
undef $ret2;
}
}#無限ループ
return; #接続待ちへ
}
5.棋譜ファイル
Perlの古い技術に基づいた記事はこのぐらいにして、最後にDEN将棋およびDEN将棋Xが採用した、最新の棋譜フォーマットであるJKFについてと、その棋譜ファイルの生成・保存・読み込み・棋譜変換機能の実装について記載します。
(1)JSON棋譜フォーマット(JKF)
JSON棋譜フォーマット(JKF)はna2hiroさんが発案された、web時代に即したテキスト型オブジェクト表現仕様であるJSON(JavaScriptObjectNotation)型で規定されており、大変合理的で効率の良い棋譜フォーマットです。今後将棋関連のソフトウェアが扱う標準のフォーマットになるべきだと個人的には思います。このフォーマット仕様は、GitHubのReadmeに記載されています。
(2)棋譜生成
JKF生成のために下記の3つの関数を作りました。na2hiroさんからは「JKFPlayer」を使うと正しいJKFを簡単に出力できるとアドバイスいただきましたが、ベースにしたコードが独自フォーマットであることと、既にかなり作り込んでしまっていたため残念ながら採用できず、下記3つに分けて生成するようにしています。
ヘッダー部分
マッチメーク後1回だけ呼ばれます。終局要因が定義されていない場合、表題を「DEN棋譜並べ」になるようにしています。
function create_jkfhead(kif_kind){
if((kif_kind)&&(kif_kind == 'set')){
Stdate = get_datetime();
Endate = Stdate;
JKF.header.開始日時 = Stdate;
JKF.header.終了日時 = Endate;
JKF.header.表題 = 'DEN棋譜並べ';
JKF.header.棋戦 = '***';
JKF.header.持ち時間 = '分';
JKF.header.先手 = 'A';
JKF.header.後手 = 'B';
}else{
JKF.header.開始日時 = Stdate;
JKF.header.終了日時 = Endate;
JKF.header.表題 = 'DEN将棋戦';
JKF.header.棋戦 = 'ユーザー対局';
JKF.header.持ち時間 = Mochitime+'分';
JKF.header.先手 = SenteName;
JKF.header.後手 = GoteName;
}
}
本体部分
手番ごとに都度呼ばれます。kifdaはDEN将棋およびDEN将棋Xの内部棋譜データ文字列です。「"14: 8,7飛成 (8,6) 飛 竜"」という感じになっています。これをコメント表記のようなJKFに変換してオブジェクト配列に追加します。sengoは先手/後手どちらの指し手かを示しています。
function push2move(kifda,sengo){//sameは未対応
/*---jkf---
{"move":{"from":{"x":7,"y":7},"to":{"x":7,"y":6},"color":0,"piece":"FU"}},
{"move":{"from":{"x":3,"y":3},"to":{"x":3,"y":4},"color":1,"piece":"FU"}},
{"move":{"from":{"x":8,"y":8},"to":{"x":2,"y":2},"color":0,"piece":"KA","capture":"KA","promote":false}},
{"move":{"from":{"x":3,"y":1},"to":{"x":2,"y":2},"color":1,"piece":"GI","capture":"KA","same":true}},
{"move":{"to":{"x":4,"y":5},"color":0,"piece":"KA"}},
*/
var komast = {
'香':'KY','桂':'KE','銀':'GI','金':'KI','玉':'OU','飛':'HI','角':'KA','歩':'FU',
'成香':'NY','成桂':'NK','成銀':'NG','竜':'RY','馬':'UM','と':'TO','圭':'NK','全':'NG','杏':'NY'};
var sg,mvst;
var retobj = {};
retobj.move = {};
retobj.move.to = {};
var st = kifda.split(' ');
retobj.move.to.x = parseInt(st[1].substring(0,1), 10);
retobj.move.to.y = parseInt(st[1].substring(2,3), 10);
retobj.move.color = parseInt(sengo,10);
if(st[2] != '(打)'){//打った時はfrom無し
retobj.move.from = {};
retobj.move.from.x = parseInt(st[2].substring(1,2), 10);
retobj.move.from.y = parseInt(st[2].substring(3,4), 10);
//最初の駒名から
retobj.move.piece = komast[st[3]];
}else{
//打った時は最後の駒名から
retobj.move.piece = komast[st[4]];
}
if(Capture != ''){
retobj.move.capture = Capture;
}
Capture = '';
if(Promote != undefined){
retobj.move.promote = Promote;//成り=turue,不成=false,どちらでもない→オブジェクト無
}
Promote = undefined;//未定義にする
MovesAr.push(retobj);//Movesオブジェクト配列に追加
}
終局部分
終局要因文字列を追加します。
function create_jkfend(st){
var sp = {};
sp.special = st;//終局要因
MovesAr.push(sp);//Movesオブジェクト配列に追加
JKF.moves = MovesAr;//JKFオブジェクトのmovesに配列全体を入力
}
(3)保存
保存は下記の関数で行っています。IEの場合はwindow.navigator.msSaveBlobを使うようにしています。
function SaveJKF(){
//---省略---
var outst = JSON.stringify(JKF,undefined,1);
if (requiredFeaturesSupported()) {
var blobObject = new Blob([outst]);
if(window.navigator.msSaveBlob){
window.navigator.msSaveBlob(blobObject, wdt + '_DENShogiKIF.jkf');
}else{
alert('ダウンロードフォルダーに保存します');
var a = document.createElement("a");
a.href = URL.createObjectURL(blobObject);
a.target = '_blank';
a.download = wdt + '_DENShogiKIF.jkf';
a.click();
URL.revokeObjectURL(a.href);
}
}
}
(4)読み込み
下記のようにラベルを用意しinput type=fileを透明実装しています。これでラベルをクリックして端末内のファイルを選択するとfileのonchangeイベントが発生します。
<label for="kfile" id="kfilelabel">
+棋譜ファイル選択
<input type="file" id="kfile" style="display:none;">
</label>
<BUTTON id="restore" onClick="jkf2den()">棋譜反映</BUTTON>
<BUTTON id="sg_change" onClick="change_sg()">盤面反転</BUTTON>
<input type = "text" id = "kfilename" />
htmlロード時に実行される$(function(){});の中に、下記のように記述しています。棋譜ファイルが選択されてonChangeイベントが発生すると下記のハンドラによりファイルが読み込まれます。読み込まれると続いてonLoadイベントが発生し、このハンドラ内でJSONにparseされるという仕組みです。
下記の記事が大変わかりやすく参考にさせていただきました。
ロカールにあるファイルをブラウザに読み込む
$(function(){
//・・・・・
var reader;
function onChange(event) {
$('#kfilename').val($('#kfile').prop('files')[0].name);
reader.readAsText(event.target.files[0]);
}
function onLoad(event) {
LoadedKIF = JSON.parse(event.target.result);
JKF = LoadedKIF;//JKFオブジェクト上書きコピー
MovesAr = JKF.moves;
}
reader = new FileReader();
reader.onload = onLoad;
$('input[type="file"]').on('change', onChange);
});
(5)棋譜変換機能
JKFは合理的でJavaScriptで扱うのに大変有効なフォーマットです。しかしながら現在最も主流なのはやはりKIFフォーマットです。スマートフォンでのコンピュータ将棋で私が愛用しているぴよ将棋もKIFフォーマットの棋譜ファイルを入出力します。そこで、KIFとJKFの相互コンバーターを作りました。DEN将棋・DEN将棋X対局画面の下方にもリンクがあります。これにより、DEN将棋Xで友人と対局した棋譜をぴよ将棋で読み込んで、コンピューターに局面検討させることが出来るので研究には大変便利です。
ファイルの読み込み/保存は今まで紹介したコードとほぼ同様なので説明を省略します。以下KIFファイルとJKFファイルの相互変換について記述します。まず下記はプログラム初期化部分です。htmlファイルでjkfplayer.jsをインポートしてあります。
var JKF,LoadedKIF,MovesAr,player;
var KifDataCodeAr = [];//変換したコードを1バイトづつ要素とする配列
var wdata;
var JKFPlayer = require("JKFPlayer");
イベントハンドラ
対局プログラムでは使いませんでしたが、変換プログラムではJKFPlayerを使いました。parseKIFメソッドを使うと簡単にKIFデータからJKFデータに変換できます。ただこのメソッドにデータを渡す前に、unicodeに変換しておく必要があるため少し工夫しています。このunicodeへの変換は、棋譜ファイルロードイベントハンドラ内で行っています。
下記により、JKFファイルかKIFファイルを読み込んだ時点で、onChangeイベントハンドラ->onLoadイベントハンドラが実行されます。onChangeイベントハンドラでは、JKFファイルの場合reader.readAsTextによりテキストデータとして読み込まれ、KIFファイルの場合reader.readAsBinaryStringでバイナリデータとして読み込まれます。KIFファイルの場合readAsBinaryStringを使うのは、onLoadイベントハンドラ内で使用するEncoding.codeToStringが失敗するからです。
onLoadイベントハンドラでは、読み込みデータが「{」で始まっていればJKFファイル、そうでなければKIFファイルと判断しています。JKFファイルの場合ここではJSONにParseして変数に入れているだけです。KIFファイルへの変換は、保存ボタンを押した後の処理で行います。KIFファイルの場合は、読み込んだ文字列データを配列に変換後、Encoding.codeToStringでunicodeに変換して、JKFPlayer.parseKIFでJKFに変換するところまで行っています。
$(function(){
var reader;
function onChange(event) {
if(event.target.id == 'jkffile'){
if((($('#jkffile').prop('files')[0].name).split('.')[1] == 'jkf')||(($('#jkffile').prop('files')[0].name).split('.')[1] == 'JKF')){
$('#jkffilename').val($('#jkffile').prop('files')[0].name);
reader.readAsText(event.target.files[0]);
}else{
alert("ファイルの種類が異なります");
}
}else if(event.target.id == 'kfile'){
if((($('#kfile').prop('files')[0].name).split('.')[1] == 'kif')||(($('#kfile').prop('files')[0].name).split('.')[1] == 'KIF')){
$('#kfilename').val($('#kfile').prop('files')[0].name);
reader.readAsBinaryString(event.target.files[0]);//readAsBinaryStringを使わないとCodeToStringがうまくいかない
}else{
alert("ファイルの種類が異なります");
}
}
}
function onLoad(event) {
if(event.target.result.match(/^\{/)){
LoadedKIF = JSON.parse(event.target.result);
JKF = LoadedKIF;//JKFオブジェクト上書きコピー
MovesAr = JKF.moves;
}else{
// 文字列を配列に変換
var str2array = function(str) {
var array = [],i,il=str.length;
for(i=0;i<il;i++) array.push(str.charCodeAt(i));
return array;
};
var str_array = str2array(event.target.result);
var uniArray = Encoding.convert(str_array, 'UNICODE' , 'SJIS'); //UNICODEに変換
var unikif = Encoding.codeToString(uniArray);
player = JKFPlayer.parseKIF(unikif);
}
}
reader = new FileReader();
reader.onload = onLoad;
$('input[type="file"]').on('change', onChange);
});
保存
JKFファイルへの保存は、既にonLoadイベントハンドラ内でJKFデータに変換しているため、前述した対局プログラムの棋譜保存機能同様の保存処理を記述しているだけですのでここでは省略します。
下記のコードは、「KIF形式で保存」ボタンを押した時のonClickイベントハンドラです。このハンドラが呼ばれた時はまだJKFなので、ここでKIFに変換します。おおまかな流れとしては下記の通りです。
- ヘッダー生成
- JKFのmoves配列要素(1つの指し手データを表現)ごとにKIFフォーマットへ変換
- ヘッダーをSJISに変換
- 指し手データ配列要素をSJISに変換
- ファイルに保存
function jkf2kif(){//JKF→KIF形式へ
if(!LoadedKIF){
return;
}
// 文字列を配列に変換
var str2array = function(str) {
var array = [],i,il=str.length;
for(i=0;i<il;i++) array.push(str.charCodeAt(i));
return array;
};
var kifhead = [];
kifhead[0] = '開始日時:' + LoadedKIF.header.開始日時;
kifhead[1] = '終了日時:' + LoadedKIF.header.終了日時;
kifhead[2] = '棋戦:' + LoadedKIF.header.棋戦;
kifhead[3] = '場所:' + LoadedKIF.header.表題;
kifhead[4] = '手合割:平手';
kifhead[5] = '先手:' + LoadedKIF.header.先手;
kifhead[6] = '後手:' + LoadedKIF.header.後手;
kifhead[7] = '手数----指手---------消費時間--';
//ここから実際の棋譜データセット
var komaCSA = {
'KY':'香','KE':'桂','GI':'銀','KI':'金','OU':'王','HI':'飛','KA':'角','FU':'歩',
'NY':'成香','NK':'成桂','NG':'成銀','RY':'竜','UM':'馬','TO':'と'};
var komaCSA_nari = {
'香':'成香','桂':'成桂','銀':'成銀','飛':'竜','角':'馬','歩':'と'};
var kifar = [];
var sp = LoadedKIF.moves[LoadedKIF.moves.length - 1].special;//未使用
kifar[LoadedKIF.moves.length - 1] = String(LoadedKIF.moves.length - 1) + ' 投了 (00:00/00:00:00)';
var preX = 0;
var preY = 0;
var i;
for(i=1;i <= LoadedKIF.moves.length-2;i++){
if((preX == LoadedKIF.moves[i].move.to.x) && (preY == LoadedKIF.moves[i].move.to.y)){
if(LoadedKIF.moves[i].move.promote == true){//成った
kifar[i] = String(i) + ' 同' + komaCSA[LoadedKIF.moves[i].move.piece] + '成';
}else{
kifar[i] = String(i) + ' 同 ' + komaCSA[LoadedKIF.moves[i].move.piece];
}
kifar[i] += '(' + LoadedKIF.moves[i].move.from.x + LoadedKIF.moves[i].move.from.y + ')' + ' (00:00/00:00:00)';
}else{
kifar[i] = String(i) + ' ' + conv2zennum(LoadedKIF.moves[i].move.to.x) + conv2zenkan(LoadedKIF.moves[i].move.to.y);
kifar[i] += komaCSA[LoadedKIF.moves[i].move.piece];
if(!(LoadedKIF.moves[i].move.from)){
kifar[i] += '打';
kifar[i] += ' (00:00/00:00:00)';
}else{
if(LoadedKIF.moves[i].move.promote == true){//成った
kifar[i] += '成';
}else if(LoadedKIF.moves[i].move.promote == false){//不成
//kifar[i] += '不成'; //不成を付けないのが決まり20180709
}
kifar[i] += '(' + LoadedKIF.moves[i].move.from.x + LoadedKIF.moves[i].move.from.y + ')' + ' (00:00/00:00:00)';
}
}
preX = LoadedKIF.moves[i].move.to.x;
preY = LoadedKIF.moves[i].move.to.y;
}
var str_array = [];
var sjisArray = [];
for(i=0;i <= kifhead.length-1;i++){
str_array = str2array(kifhead[i] + "\r\n");
sjisArray = Encoding.convert(str_array, 'SJIS', 'UNICODE');//SJISに変換
Array.prototype.push.apply(KifDataCodeAr, sjisArray);
}
for(i=1;i <= kifar.length-1;i++){
str_array = str2array(kifar[i] + "\r\n");//1文字ずつ配列に入れる
sjisArray = Encoding.convert(str_array, 'SJIS', 'UNICODE');//SJISに変換
Array.prototype.push.apply(KifDataCodeAr, sjisArray);
}
var newfname = $('#jkffile').prop('files')[0].name;
var wss = newfname.split('.');
SaveKIF(wss[0] + '.kif');
}
以上でshogi-serverとPSGIを使って作った対局専用Web将棋盤の技術的紹介は終わりです。お気づきの方も多いと思いますが、本システムは、ブラウザからのポーリングなどというかったるい事をせずとも、最近ではWebSocketというhttpでの双方向通信の技術を使えばもっとスマートなものなるでしょう。今のところ将来の課題としておきます。