Claude CodeでRen'Pyのビジュアルノベルを作る — lintとtestで確かめる
Claude CodeにRen'Pyの.rpyを書かせるとき、renpy.sh lintの終了コードを完了条件にする手順です。CLAUDE.mdの規約、Stop hook、testcaseの例を載せます。
ビジュアルノベルは「遊べた」ではなく「lintが通った」で止める
Ren'Pyのシナリオは、Claude Codeに書かせやすい種類のコードです。会話文と menu、jump の連なりなので、場面を増やす指示が通りやすく、差分も読みやすいからです。
ただし、分岐の多い物語ほど、書いた本人が全ルートを遊んで確かめることは難しくなります。Claudeに任せると「書けました」の報告だけが先に届き、存在しないラベルへの jump は、プレイヤーがその選択肢を選んだ瞬間に初めて見つかります。
そこで、完了条件を「Ren'Pyのコマンドラインから lint を実行し、エラーコードなしで終わること」に置きます。Ren'Pyには lint と自動テスト(test)がコマンドとして用意されているので、Claude Codeが自分で実行して結果を読めます。この記事では、次の順で組みます。
先に押さえる前提: renpy.shとプロジェクトの指定
Ren'PyのCLIは、SDKの実行ファイルにプロジェクトのディレクトリを渡す形です。Linux・macOSでは renpy.sh、Windowsでは同梱のPythonで renpy.py を実行します。
./renpy.sh [global options...] [<basedir>] [<command>] [command options...]<basedir> は、プロジェクトのベースディレクトリです。省略するとSDK自身(ランチャー)が対象になります。<command> を省略すると run になり、ゲームが起動します。
コマンドはバージョンによって変わることがある、とCLIのページに注記があります。手元のバージョンで使えるオプションは、次のコマンドで一覧できます。Claudeに規約を書かせる前に、一度実行しておく価値があります。
./renpy.sh --help
./renpy.sh <basedir> lint --helpここで使う実行ファイルの場所は、マシンごとに違います。Claudeに毎回探させないよう、SDKのパスは環境変数かCLAUDE.mdに書いておきます。この記事の例では、SDKの場所を RENPY_SDK という環境変数に入れる想定で書きます(Ren'Pyの仕様ではなく、この記事の例での名前です)。
また、--warp やShift+Dの開発者メニューのような開発者向けの機能は、ゲーム側で config.developer を True にしたときだけ動きます。
最小のシナリオ構成を決める
Claudeに書かせる前に、骨組みを人が決めておきます。ラベルの置き方が決まっていれば、参照切れを探す対象も絞れます。
label start: は、ゲーム開始時にRen'Pyが最初に移る特別なラベルです。ほかに、ロード時に呼ばれる after_load や、終了時に呼ばれる quit といった特別なラベルもあります。章ごとのラベルは自由に付けられます。
例えば、次のような骨組みです(分岐の形を示す例で、中身は仮のものです)。
label start:
"放課後の教室に、一人だけ残っていた。"
menu:
"声をかける":
jump talk_route
"帰る":
jump leave_route
label talk_route:
"彼女は振り向いて、小さく笑った。"
return
label leave_route:
"廊下の窓から、夕焼けが見えた。"
returnjump は、指定したラベルへ制御を移します。call と違って戻り先を積まないので、jump 先のラベル名を一文字でも間違えると、その選択肢は行き先を失います。
この「ラベル名の綴り」が、Claudeに分岐を増やさせるときの事故の中心です。menu の選択肢を足したのに行き先のラベルを書き忘れる、ラベルの名前を後から変えて jump が古い名前のまま残る、といった形で起きます。
lintをCLIで回して、終了コードを読む
ランチャーの「Check Script (Lint)」ボタンと同じ検査が、コマンドでも実行できます。
"$RENPY_SDK/renpy.sh" "$PWD" lint --error-code--error-code を付けると、lintでエラーがあったときに終了コード1で終わります。CIで失敗を検出するためのオプションですが、Claude Codeのhookにとっても同じ役割です。終了コードが0かどうかで、「止まってよいか」を機械的に決められます。
lintのほかのオプションは、次のとおりです。
| オプション | 何をするか |
|---|---|
--all-problems | 何をするか同じ種類の問題を、最初の10件だけでなく全件報告する |
--check-unclosed-tags | 何をするか閉じられていないテキストタグを報告する |
--reserved-parameters | 何をするかlabel・screen・ATLの文のパラメータで、予約された名前を使っていないか報告する |
--by-character | 何をするかキャラクターごとのブロック数・語数・文字数を報告する |
--no-orphan-tl | 何をするか元の言語に対応する文字列が無い、孤立した翻訳を報告しない |
<filename> | 何をするかレポートを標準出力でなく、指定したファイルに書く |
注意したいのは、--all-problems を付けないと、報告が最初の10件で打ち切られる点です。Claudeが10件直して再実行するたびに次の10件が出てくる、という往復になりかねません。修正の見通しを立てたい最初の一回は、--all-problems を付けて全体を出させます。
--check-unclosed-tags は、{i} や {color=...} のようなテキストタグの閉じ忘れを拾うためのものです。AIが書いた会話文は、強調のタグを開いたまま改行することがあります。台詞が多いプロジェクトでは、Claudeに渡すコマンドに最初から含めておくと安心です。
--by-character は、完了条件ではなくレビュー用です。キャラクターごとの台詞量が偏っていないかを、Claudeに集計させる用途に使えます。
lintは「何を」検出するのか
ここは、ラベル参照切れを完了条件にしたい場合に大事な点です。
開発者ツールのページには、Lintは「ゲームの潜在的なエラーや最適化不足を調べ、改善の方針を示す」ツールだと説明されています。あわせて、ゲームに関する情報や統計も出力される、とあります。一方で、検出する項目の一覧は、このページにも lint コマンドの節にも載っていません。
つまり、「存在しないラベルへの jump がlintで必ずエラーになる」とは、ドキュメントからは言い切れません。完了条件に据える前に、手元のSDKで一度確かめるのが確実です。
確かめ方は単純です。使い捨てのコピーで、わざと壊します。
# 作業コピーで、行き先のないjumpを1行足す
echo ' jump no_such_label' >> game/script.rpy
"$RENPY_SDK/renpy.sh" "$PWD" lint --error-code
echo "exit code: $?"終了コードが1になり、レポートに該当の行が出るなら、lintをラベル参照切れの検出に使えます。0で終わるなら、lintだけでは足りません。その場合は、後の節のtestcaseで、選択肢から行き先のラベルに着くことを個別に確かめます。
この確認は、Claudeにやらせても構いません。ただし、壊した行を必ず元に戻させます。確認用の変更を残さないよう、作業ブランチで行うか、変更前にコミットしておくのが無難です。
CLAUDE.mdに書く規約
完了条件は、まずCLAUDE.mdに書きます。CLAUDE.mdはお願いにすぎないので、後でhookで補強します。書き方の一般論はCLAUDE.mdの書き方にまとめています。
## Ren'Py シナリオ
- `.rpy` を変更したら、完了前に必ず次を実行する
`"$RENPY_SDK/renpy.sh" "$PWD" lint --error-code --all-problems`
- 終了コードが 0 でなければ、出力の先頭から読んで直し、再実行する
- `menu` の選択肢を足したら、`jump` / `call` の行き先ラベルも同じ変更で書く
- ラベル名を変えたら、`grep -rn "旧ラベル名" game/` で参照を全て探して直す
- lint のエラーを消すために、該当の行を削除・コメントアウトしない
- `game/` 以下の `.rpyc` は生成物。編集しない四番目の項目は、Claudeがラベルの改名を片側だけで済ませる事故への対策です。ラベルの参照は文字列の一致で探せるので、検索で拾わせます。
五番目は、エラー隠しの禁止です。lintを通すだけなら、問題の行を消すのがいちばん早いからです。物語の分岐が黙って減るので、規約として先に禁じておきます。
最後の項目の .rpyc は、Ren'Pyがコンパイルして作るファイルです。ビルド時の扱いは、後の「つまずき」で触れます。
Stop hookで、lintを通すまで止まれなくする
CLAUDE.mdだけでは、Claudeがlintの実行を忘れて終わる余地が残ります。確実にしたい場合は、Claude Codeのhooksで、Stop イベントにlintを掛けます。
Stop は、メインのエージェントが応答を終えたときに走るイベントです。ユーザーが中断したときは走りません。終了コード2で終わると、Claudeは止まらずに作業を続けます。同じ仕組みでQuartoのrenderを掛ける例はQuartoレポートの記事にあります。ここでは、同じ型をRen'Pyに当てはめます。
.claude/hooks/renpy-lint-check.sh の例です。.rpy に変更があるときだけlintを回します。
#!/bin/bash
# .rpy に変更がなければ何もしない
changed=$(git status --porcelain -- '*.rpy' | head -n 1) # コミット済みの変更は拾わない
[ -z "$changed" ] && exit 0
# SDK の場所は RENPY_SDK で渡す
if ! out=$("$RENPY_SDK/renpy.sh" "$CLAUDE_PROJECT_DIR" \
lint --error-code --all-problems 2>&1); then
echo "Ren'Py の lint が失敗しました。内容を読んで直してください。" >&2
echo "$out" | tail -n 40 >&2
exit 2
fi
exit 0このhookは作業ツリーの未コミットの変更で判定するので、Claudeが先にコミットしてしまうと、そのlintはすり抜けます。コミットまでClaudeに任せる運用なら、CLAUDE.mdに「コミット前にlintを通す」と足します。
標準エラー出力に書いた内容が、続行の理由としてClaudeに渡されます。出力が長いと文脈を圧迫するので、末尾40行に絞っています。--all-problems で件数が多くなりやすい分、ここは絞っておきたい箇所です。
chmod +x で実行権限を付け、.claude/settings.json に登録します。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/renpy-lint-check.sh"
}
]
}
]
}
}止まれない状態が続いたとき
lintが永遠に通らない状態になると、Claudeは止まれず作業を続けます。これには歯止めがあります。Stop hookが連続で8回、ターンを続行させた場合、Claude Codeは次のブロックを打ち切ってターンを終えます。Claudeがツールを呼ぶたびに、この連続回数は戻ります。
また、Stop hookに渡される入力には stop_hook_active が含まれます。Claude Codeがすでにstop hookの結果として続行している間は true になるので、解決しない条件でブロックし続けないための判断材料になります。スクリプトに分岐を足す場合は、この値を使います。
lintの失敗が、エンジン側の問題(SDKのパス違い・権限)で起きることもあります。その場合、Claudeがどれだけ .rpy を直しても通りません。hookを登録する前に、RENPY_SDK を設定した状態で、手動でスクリプトを実行して終了コードを確かめます。
SDKの実行に許可が要る環境では、コマンドの許可ルールも先に出しておきます。ルールの書き方は/permissionsの記事にあります。
testcaseで、選択肢から行き先のラベルに着くか確かめる
lintは、スクリプトを静的に調べるツールです。開発者ツールのページにも、lintは徹底したテストの代わりにはならないと書かれています。物語の分岐が実際に流れるかは、自動テスト(testcase)で確かめます。
testcaseは、Ren'Pyのスクリプトに書く、テスト専用の文です。label に似ていますが、中に書けるのはテスト用の文だけで、Ren'Pyの通常のコードとは混ぜられません。
ここで気をつけたいのは、行き先のラベルへ run Jump("talk_route") で直接飛ぶ書き方では、menu の jump 先の綴り違いを見つけられない点です。ラベルが存在することしか分からず、jump talk_rout と誤記していてもテストは通ります。start から始めて、実際に選択肢をクリックして進みます。
ラベルへの到達を確かめる条件文が、assert label <ラベル名> です。前回のテスト文の実行以降に、そのラベルへ到達したかを調べます。選択肢を選ぶ文は click "選択肢の文言"、メニューが出るまで進める文は advance until screen "choice" で、どちらも公式のQuick Startにある書き方です。
先の骨組みに合わせた例です。
testsuite routes:
before testcase:
run Jump("start")
testcase talk_route:
advance until screen "choice"
click "声をかける"
assert label talk_route
testcase leave_route:
advance until screen "choice"
click "帰る"
assert label leave_routebefore testcase は、各testcaseの前に実行されるフックです。menu の jump が壊れていれば、クリックしても行き先のラベルに着かず、assert label で失敗します。この形なら、lintで拾えなかった参照切れも、選択肢ごとに押して確かめられます。
実行は、ランチャーの「Run Testcases」ボタンか、コマンドラインの test です。
"$RENPY_SDK/renpy.sh" "$PWD" test routes<testcase> を省略すると、global という名前のテストスイートが実行されます。ランチャーのボタンは、testcaseが一つ以上定義されていて、ゲームを一度起動してからランチャーの「Refresh」を押すまで、表示されない場合があります。
一つ注意があります。testsuiteが終わっても、ゲームは自動では閉じず、プレイヤーの入力待ちに戻ります。hookやCIから回すときは、testsuiteの teardown に exit を書いて閉じます。
testsuite global:
teardown:
exittest コマンドが、失敗時にlintの --error-code のような終了コードを返すかは、ドキュメントに記載がありません。hookに組み込むなら、先ほどと同じく、わざと失敗するtestcaseを書いて、echo $? で確かめてから使います。
開発中にClaudeと一緒に使う操作
hookは終わりの関門ですが、書いている途中の確認には、Ren'Pyの開発者用の操作が使えます。人が画面を見ながら、Claudeの書いた箇所をその場で試す場面です。
- Shift+R: ゲームを保存し、スクリプトを再読込して、直前の位置から続ける。一度押すと自動リロードの状態になり、以後は外部エディタでの変更が反映される
- Shift+O: デバッグコンソール。
jump <label>で任意のラベルへ飛べる - Shift+D: 開発者メニュー
--warp <ファイル名:行番号>: 指定した行の直前へ、ゲームを遊ばずに移動する
自動リロードは、Claudeが .rpy を直すたびに、人が画面を見て確かめる流れと相性が良いです。ただし、ゲームの状態(変数や表示中の画像)はリロードを越えて保たれます。そのため、変更した文が代入や scene の場合は、ロールバックして文を再実行しないと変更の効果が見えません。
--warp には注意点があります。実行されるのは、目的の行までのパスの scene・show・hide だけで、Pythonのコードは実行されません。変数が未初期化のまま、ワープ先で変数を使ってクラッシュすることがあります。回避策として、after_warp というラベルを定義すると、ワープの後、ワープ先の文を実行する前に呼ばれます。ここで変数を初期化して return します。
特定の場面を見たいときは、--warp で起動する方法のほかに、起動済みのゲームでShift+Oのコンソールを開き、jump で飛ぶ方法もあります。
つまずきやすい点
.rpycを消さない、手で触らない: Ren'Pyは .rpy からコンパイルして .rpyc を作ります。CIなどでビルドするときは、この .rpyc を次のビルドに引き継ぐ必要があります。引き継がないと、セーブデータが読めないゲームになることがあります。リポジトリの管理方針(.gitignore に入れるか)を決めたうえで、CLAUDE.mdに書いておきます。
callにはfromを付ける: 配布後に更新を出す予定があるゲームでは、call に from 句を付けておくのが安全です。付けないと、call を含むファイルを編集したとき、呼び出し先の中で作られたセーブが壊れる危険があります。ビルド時に「Add from clauses to calls」の選択肢で自動追加もできます。Claudeに章を追加させるときは、call を書く場合に from を付ける規約を足しておきます。
ローカルラベルの綴り: ラベルには、グローバルとローカルがあります。ローカルラベルは先頭にピリオドを付けて書き、同じグローバルラベルの中でだけ直接参照できます。別のグローバルラベルの中から飛ぶときは、global_label.local_label の形で書きます。Claudeが章をまたいでローカルラベルへ飛ぶコードを書いたときは、この形になっているかを見ます。
lintの出力はファイルに書ける: レポートの出力先ファイルを渡すと、標準出力の代わりにそこへ書かれます。出力が長いプロジェクトでは、ファイルに書かせて、Claudeにはそのファイルを必要な範囲だけ読ませる運用もあります。
まとめ
Ren'Pyのシナリオ制作では、Claudeに任せる範囲が広がるほど、遊んで確かめる作業が追いつかなくなります。lintの終了コード0を完了条件にし、Stop hookでそれを強制すれば、少なくとも文法とスクリプトの検査は、人の手を借りずに回せます。
ただし、lintがラベル参照切れをどこまで拾うかは、手元のSDKで壊して確かめておく必要があります。拾えなかった場合は、start から選択肢をクリックして assert label に着くかを見るtestcaseで補います。この二段にしておけば、人が遊んで確かめるのは、台詞の出来や演出といった、機械では判断できない部分に絞れます。