55
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

VimAdvent Calendar 2014

Day 6

Vim script + Lua で rogue.vim を作った話

Posted at

この記事は Vim Advent Calendar 2014 の6日目の記事です。

rogue.vim について

データ分離版ローグ・クローンII (rogue_s) をVimプラグインに移植した rogue.vim を作りました。
(ローグとはなんぞやという人は http://ja.wikipedia.org/wiki/%E3%83%AD%E3%83%BC%E3%82%B0 あたりを参照してください。)

image

rogue.vimのインストール方法は、普通のプラグインと同じ方法です。
GitHubかvim.orgからダウンロードしたzipファイルを展開してruntimepathの通ったディレクトリ($HOME/.vim$VIM/vimfilesなど)にコピーするか、
何らかのプラグインマネージャーでインストールしてください。

https://github.com/katono/rogue.vim
http://www.vim.org/scripts/script.php?script_id=5017

実行にはVimのif_luaが有効になっている必要があります。
(kaoriyaさん配布のWindows版Vimの場合は最初から有効になっています)
if_luaさえ有効になっていれば、GVimでもCUIのVimでもWindowsでもLinuxでも遊べます。(試したことないけど、たぶんMac OS Xでも)

:Rogue でゲーム開始、Sコマンドでセーブファイルに保存して中断、:RogueRestore でセーブファイルの続きからゲーム再開です。
詳しくは、:h rogueでヘルプを参照してください。
ヘルプには、オリジナルに付属していたmanページやローグのガイドへのリンクもあります。

rogue.vimはrogue_sの完全移植を目指しました。
アルゴリズムはほぼすべてそのままの移植なので、乱数生成、ダンジョン生成や難易度などもオリジナルのrogue_sとまったく同じです。
日本語メッセージの場合はわざわざ全角数字を使うのも同じです。

データ(メッセージ)分離版の移植なので、mesgファイルをコピペしてアイテムやモンスターの名前やメッセージを好きなように変えることができます。
image
メッセージファイルは g:rogue#message で指定します。
日本語以外のメッセージの場合は、 g:rogue#japanese に0を指定します。
過去に作成されたrogue_s用のメッセージファイルもいくつか公開されているので、文字エンコーディングをUTF-8に変更すれば、内容はそのままで使うことができます。

Vimの背景色の設定が、キャラクタのデフォルトのハイライトと同じになっていると見えなくなるので、キャラクタのハイライト設定が必要になります。
私のGVimは青色背景で、床のデフォルト色と同じなので、床の色を茶色に変更しています。

.vimrc
hi rogueFloor	ctermfg=blue	guifg=brown

オリジナル(rogue_s)との違い

  • rogue_sは、「データ(メッセージ)分離版」となっていますが、一部のメッセージが直にソースに埋め込まれていて、完全にメッセージが分離されていません。("=スペースを押してください="など)
    rogue.vimではすべてのメッセージをmesgファイルに分離し、文字エンコーディングをUTF-8に変更しました。

  • rogue_sでは、英語のメッセージを使用するにはコンパイルオプションを変更してビルドし直さなければなりません。
    rogue.vimでは、日本語のVimなら g:rogue#japanese を0にするだけで英語のメッセージに切り替わります。デフォルトのメッセージはv:langが日本語(ja)かどうかで自動的に日本語/英語が切り替わるようになっています。海外のVimmerにもインストールしたらメッセージの設定などせずに即遊べるようにと配慮したつもりです。

  • rogue_sでは、ドラゴンの炎や魔法のミサイルの杖のアニメーション演出の処理はソース内にはあるものの、一瞬で画面が更新されてしまうため、演出が見られません。
    rogue.vimではディレイを入れて演出が見られるようにしました。
    image

  • rogue_sでは環境変数やoコマンドでオプション指定ができますが、rogue.vimではVimのグローバル変数でオプションを指定します。

  • rogue.vimでは即ゲームをサスペンドするコマンドを追加しました。背後で足音がしたら素早くCTRL-Zを押しましょう。通称「ボス来たコマンド」。

開発のきっかけ

10年近く前に、sokoban.vimやtetris.vimを知って、軽くショックを受けたのがきっかけでした。
以前からキャラクタベースのゲームになんとなく憧れのようなものがあり、しかもVimで作ればマルチプラットフォームだし、GUIでもCUIでも動く!と思いました。

Vim(vi)のカーソル移動を練習するためにローグをやれという話はよく聞きますが、「それ、Vimでできるよ」と言えたら面白いのでVimでローグを作ろうと思いました。

ただ、ローグを1から作るのはたいへんなので、Cのソースがあるデータ分離版ローグ・クローンIIを移植しようと思いました。

作り始めたのが3年くらい前で、その時はVim scriptだけで移植していました。
ダンジョンを自動生成してVimに描画するところまで移植できたんですが、ものすごく遅かった。迷路のあるマップだと、再帰を使っているせいか、10秒くらいかかりました。
これでは全部移植しても遅くてまともに遊べないかもしれないと思ったのと、CからVim scriptに移植するのがつらくなってやめてしまい、しばらく放置していました。

その後、LuaJITがめちゃくちゃ速いという理由でkaoriyaさんのVimがLuaJITをバンドルするようになりました。
これでLuaを使ったプラグインを使うためのハードルはかなり下がったと思い、それを選択するのもありかなと思いました。
ずっとLuaを勉強したかったので、試しにVim scriptで実装していたダンジョン自動生成処理をLuaで書き変えてみたら、一瞬で処理が終わるほど速くなったので、そのままLuaで移植を続けることにしました。

ある程度動くようになるまでは移植作業でも楽しいんですが、その後だんだん飽きてきます。
飽きて中断すると、もう二度と再開しないだろうなと思って一気にやってしまおうと思いました。
Luaで再開したのが今年の4月で、9月にリリースしました。

VimからLua、LuaからVimを使う

rogue.vimはほとんどLuaで作りました。
Vim scriptは、:Rogueコマンドの定義とLuaを呼び出すmain関数とシンタックスハイライト定義だけです。
それら以外はすべて*.luaでLuaのコードで実装しました。

if_luaの使い方は、:h luaで調べられます。
Vim scriptのrogue#rogue#main()関数で、:luafileですべての*.luaを読み込んでから、:lua Rogue.main()でLuaのmain関数を呼ぶようにしました。
LuaからVimの機能は、vim.command()vim.eval()で使えます。
rogue.vimでは、Vimのsetline():redrawを画面描画に、getchar()nr2char()をキー入力などに使っています。

Vim上のLuaのグローバル関数/変数について

Vim上のLuaで開発していて気付いたことは、プラグイン内で追加したLuaのグローバル関数や変数が、そのプラグインの実行が終了しても起動中のVimにずっと残っているということでした。
他のLuaを使ったプラグインと名前が衝突する恐れがあるので、むやみにグローバル変数を追加できません。

そこでRogueというグローバルなテーブルをひとつだけ作って、rogue.vimで使用するグローバル関数や変数は全部その中に入れるようにしました。

最初、Rogue.play_level()みたいにグローバル関数や変数全部にRogueを付けて書いていたのですがめんどくさくなりました。
そこで、ファイルの先頭で、

local g = Rogue

として、gRogueの別名とすることで、グローバル関数をg.play_level()と呼べるようになりました。
gはローカル変数なので_G(Luaのグローバル名前空間)に追加されません。

ところで、Luaではローカル変数には明示的にlocalを付けなればなりません。
ローカル変数にうっかりlocalを付け忘れるとグローバル変数になってしまいます。
ゲーム開始前と終了後の_Gの状態を比較して予期しないグローバル変数が増えていないかチェックしました。

また、Luaは標準ライブラリは単なるテーブルなので容易に変更することができるのですが、これも他のプラグインに影響するのでNGです。
極端な例だと、:lua string = nilとするとそのVimではLuaの文字列ライブラリが一切使えなくなってしまいます。

Luaで整数の割り算

Luaでは通常、数値型は浮動小数点数となっています。
なので整数の割り算(余りを切り捨て)のつもりで10 / 33となることを期待しても、

:lua print(10 / 3)
3.3333333333

となってしまいます。
なので、整数用の余りを切り捨てる割り算関数を作りました。

function g.int_div(dividend, divisor)
    return math.floor(dividend / divisor)
end

ちなみに、Lua5.3では、//演算子で整数用割り算ができるようになる予定だそうです。
http://www.lua.org/work/doc/manual.html#3.4.1
もっと早くからあっても良さそうなのに、なんで今まで追加されなかったんでしょうか。
%の剰余は使えるのに。

Luaでbit演算

rogue.vimで使用するLuaのバージョンは5.1以降を前提としました。
5.2やLuaJITではbit演算とgotoが使えますが、5.1では使えません。
goto回避はなんとかなるとして、bit演算の代用となる方法を考えました。

元のCコードでbitのフラグを使っているところは、Luaのテーブルのキーで代用しました。(そのキーの値は真であれば何でもいい)
例えば、ダンジョンの[row][col]の座標にモンスターが重なっているかどうかの判定で、元コードが

c
if (dungeon[row][col] & MONSTER) {
    ...
}

となっているところは、

lua
if g.dungeon[row][col][g.MONSTER] then
    ...
end

と書けるようにしました。

元コードで右シフトをしているところは、整数の割り算で代用しました。

ひとつだけbit演算のXORを使う必要があったので、bitモジュールかbit32があればbxor関数を使い、なければLuaで実装したbxor関数を使うようにしました。

-- require("bit")が成功すればbit_existsに真を返し、bitにロードされたモジュールを返す
local bit_exists, bit = pcall(require, "bit")
if bit_exists then
    -- bitモジュールがある(LuaJITなど)
    g.bxor = bit.bxor
elseif _VERSION >= 'Lua 5.2' then
    -- bit32がある
    g.bxor = bit32.bxor
else
    -- Lua実装のbxor
    g.bxor = function(x, y)
        local n = 0
        local ret = 0
        repeat
            local bit_x = x % 2
            local bit_y = y % 2
            if bit_x ~= bit_y then
                ret = ret + 2 ^ n
            end
            x = g.int_div(x, 2)
            y = g.int_div(y, 2)
            n = n + 1
        until x == 0 and y == 0
        return ret
    end
end

なお、Lua5.3では、bit演算子(&|等)が追加され、bit32は非推奨(廃止?)になる予定だそうです。
bit演算子ももっと早くからあっても良かったのに、と思いました。
bit32かわいそう。

Luaの配列や文字列のインデックスは1オリジン

Luaは好きだけど、これがあまり好きじゃないところですね。

最初からLuaで開発するのだったら1オリジンで作ったほうがいいけど、元ソースはCなので配列のインデックスは0オリジンです。無理にすべて1オリジンに書きかえるとバグを入れそうだったので、

local t = { [0] = 1, 2, 3, 4 }

のように無理やり0オリジンの配列(テーブル)にしました。
ただし、[0]を使うと、ipairs(t)とか#tの長さとかの結果が思うようにならなくてハマりました。
以下のように、[0]の分が抜けてしまいます。

:lua t = { [0] = 1, 2, 3, 4 }
:lua for i, v in ipairs(t) do print(i, v) end
1  2
2  3
3  4
:lua print(#t)
3

[0]を使った場合は、ipairsを使わず、以下のようにしなければなりません。要素数も自分で管理する必要があります。

:lua for i = 0, 3 do print(i, t[i]) end
0  1
1  2
2  3
3  4

デバッグ

淡々とCからLuaへ移植していって、すんなり動くわけはなく、デバッグをしなければなりません。
デバッガがないので、printデバッグしかないのですが、Lua標準関数のprint()だけでは難しいです。
そのため、デバッグを便利にするための機能をいくつか作りました。ソースはdebug.luaです。
移植ばかりの作業は飽きてくるので、デバッグ機能を充実させていく作業は、Luaのデバッグライブラリの使い方を調べたりして楽しかったです。

オブジェクトの内容を出力

テーブルをprint()に渡して表示しようとしても、"table: 0xXXXXXXXX"みたいに表示されるだけで内容がわからず不便です。
そこで、Rubyのpメソッドみたいに、わかりやすくオブジェクトの内容を出力する関数g.p()を作りました。
例えば、hogeというテーブルの内容を出力するには、g.p("hoge") と書くと

hoge = {
  [1] = 1,
  foo = "bar",
  baz = {
    aa = 2,
    bb = "cc",
  },
}

のように出力します。
本当は変数をクオートでくくらず g.p(hoge) と書けるようにしたかったのですが、C言語のプリプロセッサの#で文字列化みたいなことをする方法がわからなくて断念しました。

このテーブルの出力はそのままLuaのテーブル定義として扱えるようにシリアライズしたものなので、セーブデータ保存・復元処理にも使いました。

ファイルとして保存したテーブルの定義をLuaのテーブルに復元(デシリアライズ)する方法は、

local tbl_str = fp:read("*a") -- "{ foo = 'bar' }"のようなテーブル定義の文字列
local tbl = assert(loadstring('return ' .. tbl_str))() -- Lua5.1の場合はloadstring、5.2の場合はloadを使う。
print(tbl.foo) -- 'bar'

デバッグログ

print()はVimのメッセージエリア(Vimの:echoと同じ)に表示されるので、画面を再描画するとすぐ消えてしまいます。なのでログファイルにprintf形式で出力する関数g.log(fmt, ...)を作りました。
Vimのconfirm()を使ってメッセージボックスで出力する関数も作りましたが、こちらはほとんど使いませんでした。

ブレークポイント(もどき)

デバッガのブレークポイントのように実行中に処理を止めて対話的にプロンプトを出して、変数の中身を見たり書き換えたりする関数g.breakpoint()を作りました。
Lua標準関数のdebug.debug()では、ローカル変数の値が取得できなかったのでこれを作りました。

ブレークポイントといっても、ステップ実行ができるわけではありませんが、これのおかげでデバッグが捗りました。

g.breakpoint()の使い方は、printデバッグと同じように、処理を止めたいソースの行に挿入します。
プログラムを実行して、その行に来たときに処理が止まりBreakpoint:というプロンプトが表示されます。
グローバル変数名やローカル変数名を入力すると、その変数の内容が表示されます。
localと入力すると全ローカル変数の内容が表示されます。
変数名 = 値と入力すると、その変数の値を書き換えることができます。
何も入力せずにEnterでプロンプトを抜けます。

この関数を作ったとき、ローカル変数へのアクセスに苦労しました。
ローカル変数は、Lua標準関数のdebug.getlocal()で取得できます。
しかし、関数定義の外側で定義されているローカル変数はdebug.getlocal()で取得できません。
このローカル変数は、C言語の関数外static変数のように使っていたのですが、Luaではクロージャでキャプチャされる外部ローカル変数という扱いで、上位値と呼ぶそうです。
上位値はdebug.getupvalue()で取得できます。
ただし、debug.getupvalue()で取得するには、その関数内で一度でもアクセスしないと上位値として登録されないようです。

これを参考にしました。 http://www.lua.org/pil/23.1.2.html

実行時エラー処理

Luaでは、テーブルの未定義のキーの値はnilになるので、キーをtypoしたりすると、実行時に予期しないことが起こります。
実行時エラーでプラグインがクラッシュする場合はまだバグを見つけやすいです。
クラッシュしたらVimの:messagesでエラーログが確認できます。
これだけではバグの原因がわかる場合もありますが、クラッシュした時のもっと詳細な情報が欲しくなります。
そこで、Lua標準関数のxpcall()を使いました。

xpcall(func, error_handler)は擬似コードで書くと、

try {
    func()
} catch {
    error_handler(e)
}

のような意味です。func()の中でエラーが発生するかLua標準関数のerror(e)を呼ぶと、一気にfuncから抜けてerror_handler(e)が呼ばれます。eはエラーメッセージです。

エラーが発生した場合は、エラーメッセージ、スタックトレース、表示中のゲーム画面、全ローカル変数、Rogueオブジェクトのダンプをデバッグログに出力するようにしました。
これでクラッシュしたときのバグを突き止めやすくなりました。
また、この機能を使ってゲームを終了するための関数g.exit()を作りました。Luaのos.exit()を使うとVimごと終了してしまいます。

カバレッジ機能

カバレッジと言ってもパーセンテージを計っておらず、実行したソース行のカウントだけの機能で、作ったけど結局ほとんど使いませんでした。
これはLuaのデバッグライブラリのdebug.sethook()の使ってみたかったために作ったようなものです。

debug.sethook()を使えば、Luaのコード一行実行する毎にhookに設定した関数が呼び出せます。
これを使ってLuaDebugger.vimが作れるかなあと妄想しています。(作れるとは言ってない)

シンタックスハイライト

シンタックスハイライトはVim scriptだけで行っています。
まず、プレーヤー、モンスター、アイテム、壁、床のキャラクタのハイライトはそのままsyn matchで設定しました。

例えば壁の場合は、

syn match rogue_WallH	"-"
syn match rogue_WallV	"|"
syn match rogue_Door	"+"
syn match rogue_Tunnel	"#"
hi rogueWall	ctermfg=cyan	guifg=cyan
hi def link rogue_WallH		rogueWall
hi def link rogue_WallV		rogueWall
hi def link rogue_Door		rogueWall
hi def link rogue_Tunnel	rogueWall

しかし、これだけでは該当するキャラクタがメッセージに含まれる場合もハイライトされてしまいます。

そこで、メッセージを描画するときは先頭に$$を追加して、concealで$$を隠すようにしました。syn match$$から後ろのメッセージはNormalのハイライトになるようにしました。
($$はメッセージ内にまず存在しない文字列だと思うので、$$をconcealの目印にしました。)

死んだときの墓石描画やスコア画面などで任意の文字を任意の色にハイライトする場合も同じようにconcealを使いました。
例えば、(g(ほげ(g(と描画すると、(g(が非表示になり、ほげが緑になります。

以下のような設定です。

setl conceallevel=3
setl concealcursor=n

syn match rogue_Message	"\$\$.*" contains=rogue_ConcealedMessage,rogue_Green
syn match rogue_ConcealedMessage	"\$\$" contained conceal
hi def link rogue_Message	Normal

syn match rogue_Green	"(g(.\{-}(g(" contains=rogue_ConcealedGreen
syn match rogue_ConcealedGreen	"(g(" contained conceal
hi rogue_Green	ctermfg=green	guifg=green

文字化け対策

オリジナルのrogue_sのメッセージファイルの文字エンコーディングはEUC-JPなのですが、rogue.vimではUTF-8にしました。
WindowsのVimなど、encodingオプションが'cp932'だったりすると、UTF-8のファイルを読んで表示すると文字化けします。
なので、ゲーム中はencodingを'utf-8'にしておいて、ゲーム終了時に元に戻すというようにしてみました。
これでうまくいった、と思ったら、WindowsのCUI版のVimでは文字化けしてしまいました。
これはVimの問題というより、コマンドプロンプトの設定ががcp932になっているためのようです。

そこで、ファイル関連(メッセージファイル、セーブファイル、スコアファイル)のエンコーディングはすべてUTF-8で保存するようにし、ゲーム中のencodingの設定は元のままにしました。
元からencodingが'utf-8'の場合は何もする必要ありません。

元のencodingが'utf-8'でない(例えば'cp932')場合は、

  • ファイルを保存する時は、保存する文字列をiconv()でUTF-8に変換する。
  • ファイルを読み出す時は、一時的にencodingを'utf-8'にし、読み出した文字列をiconv()でUTF-8からcp932に変換し、encodingを'cp932'に戻す。

iconv()の前にencodingを変換前のエンコーディングに合わせておかないと、文字化けした文字列リテラルがiconv()に渡されて問題を起こすことがあります。
例えば、encodingが'cp932'のままで、UTF-8の文字列str='つえ'を以下のようにiconv()でcp932に変換しようとすると、

str = vim.eval("iconv('" .. str .. "', 'utf-8', s:save_encoding)")

image

このようにエラーが発生します。cp932として扱われたUTF-8の'つえ'がシングルクオートで括られていないと判定されてしまったようです。
この例のようなやり方でvim.eval()でLuaの文字列をVimに渡すと文字列リテラルになるので、文字列リテラルを正しく扱えるようにencodingを設定しておく必要がある、というわけです。

これでVimのencodingオプションは'utf-8'でも'cp932'でも何でも問題ないかと思います。

実は、現在のGitHubリポジトリのソースは、セーブファイルとスコアファイルの読み込み時にencodingを'utf-8'にしていませんでした。(この記事を書いていて気付いた)
エラーは起きていませんが、後で直します。

検討中の追加機能

  • イージーモードのオプション。
    難易度がオリジナルと全く同じで結構難しいので、もう少し簡単なモードがあってもいいかなと思っています。難易度高めの硬派なローグもいいですが、難しいという理由で遊んでもらえなかったら悲しいので。
  • スコアのツイートとかWebでランキング等。

終わりに

rogue.vimは長年作りたいと思っていたものなので、リリースできてよかったです。
ちょっと暇がある時にでも遊んでもらえるとうれしいです。

VimプラグインをLuaで作ってみた感想・まとめ

  • Luaは楽しい
  • Vim scriptより書きやすい
  • 実行速度がVim scriptより断然速い
  • Luaの標準ライブラリが少ない
    • ライブラリやサンプルコードなどは http://lua-users.org/wiki/ で探せる
    • Vimの関数やvital.vimなどのラッパーを作ってもいいかも
  • デバッグするためのライブラリがないとデバッグが難しい
    • 今回作ったデバッグ機能はライブラリ化したい
    • Vim用Luaデバッガが欲しい
  • グローバル名前空間_Gを荒らさないように注意
    • 特にlocalの付け忘れでグローバルにしないように注意
  • もっとLuaで書かれたプラグインが増えるといいなあ
55
50
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?