[7]GodotでInkシナリオ制御

Godot

前回の記事では、Inkシナリオを読み込み、最初の1行を表示させるところまで進みました。次は、実際にプログラムの中で制御していきましょう。具体的には、InkのWikiのガイドに従って、下記の処理を実装していきます。

_Ready()メソッドの処理に追加がたくさん入りますので、”ContinueStory()”メソッドを新たに追加してそこに処理を移動します。前回追記した行をコメントアウトして、新たな記述に変更します。コメントアウトとは、「//」で始まる行のことで、人間様がわかりやすいようにコメントをつけておく機能で、プログラムでは全く使われません。コメントアウトは、コメント化する=その行を無効にするという操作になります。

story.CurrentChoicesに読み取った選択肢が全て入っているので、その情報を使ってループ処理内で1つずつボタンオブジェクトを生成します。また、生成したボタンが押された場合のイベント処理を記述します。

forループの先頭で、GetChildren()して各childをQueueFree()しているのは、リソースの解放です。前回実行で、既にAddChild()して確保したリソースを解放するために必要になります。

最終的なコードは下記になりました。

using Godot;
using GodotInk;
using System;

public partial class VBoxContainer_FirstChat : VBoxContainer
{
[Export]
private InkStory story;

// Called when the node enters the scene tree for the first time.
public override void _Ready()
{
ContinueStory();
}

private void ContinueStory()
{
foreach (Node child in GetChildren())
child.QueueFree();

// GD.Print(story.Continue());
Label content = new() { Text = story.ContinueMaximally() };
AddChild(content);

foreach (InkChoice choice in story.CurrentChoices)
{
Button button = new() { Text = choice.Text };
button.Pressed += delegate
{
story.ChooseChoiceIndex(choice.Index);
ContinueStory();
};
AddChild(button);
}
}
// Called every frame. 'delta' is the elapsed time since the previous frame.
public override void _Process(double delta)
{
}
}

InkのWikiのガイドラインでは、上記のL.25が「inkChoice」となっていたのでそのままでは動きません。大文字の「InkChoice」に修正する必要があります。

いったんこれで実行してみましょう。

この動画では、無事ENDまでいってストーリー終了、となるので成功です。
ところが、何度かテストプレイを重ねると、下記のエラーで止まるケースがありました。

E 0:00:11:0615 void Ink.Runtime.Story.ContinueInternal(float): Ink.Runtime.StoryException: Ink had 1 error. It is strongly suggested that you assign an error handler to story.onError. The first issue was: RUNTIME ERROR: ran out of content. Do you need a '-> DONE' or '-> END'?
Ink.Runtime.StoryException

ランタイムエラーで、「ran out of content」といっています。これは、コンテンツが不足しています、という意味ですね。これの原因がわからなくていま2時間経過しました(泣)。
どういうケースでこのエラーが起こるかは完全にわかっています。この「FirstChat.ink」のシナリオでは、最初に森の分かれ道を選びます。右の道に進んでイベントに遭遇しますが、しかしイベントをスルーするような選択肢を選ぶと、再び最初の森の分かれ道に戻ります。最初の森の分かれ道では、当初右と左が選べたはずですが、もう右の選択肢が消えており、左にしか進めません。
左の道に進んでイベントに遭遇し、イベントを受け入れるような選択肢を選ぶとENDにたどり着けるのですが、そこでもイベントをスルーするような選択肢を選ぶと、再度最初の森の分かれ道に戻りますが、このとき、もう右の道も左の道も消費されてなくなってしまっており、このエラーが出ます。

ここまできて気づいたのは、「これバグじゃなくて仕様なんじゃね?」でした。
デバッグで上記のL.25の直前で、下記のようにしてstory.CurrentChoices(これはリストです)のリストの要素数(これが選択肢の数です)を出力してみたのですが、、、

GD.Print("CurrentChoices: " + story.CurrentChoices.Count);   ←この行を追加して調査
foreach (InkChoice choice in story.CurrentChoices)

予想通り、1度選択肢を選ぶ毎にここに飛んでくるのですが、その都度このリスト内の要素(=選択肢)は消費されていく(つまり減っていく)ことがわかりました。毎回 story.ContinueMaximally() を呼び出しているので、その都度要素が満タンに補充されていく印象でしたが、違いますね。

ダメ押しでChatGPTにも聞いてみましょう。

質問:CurrentChoicesのListのCountが、1度選択をすると数が1つ減ってしまっている。
これはなぜおこるのか?Inkの仕様どおりの動作なのか?


CurrentChoicesのリストのカウントが選択後に減ってしまうのは、Inkの仕様どおりの動作です。具体的には、プレイヤーが選択を行うたびに、その選択肢が消費されて次のストーリーに進むためCurrentChoicesリストのカウントが減ります。

詳細な仕組み

  1. story.CurrentChoices:
    • story.CurrentChoicesは、現在表示されている選択肢のリストを返します。プレイヤーが選択を行った後、ストーリーが進行し、その時点で利用できる選択肢が更新されます。
    • プレイヤーが選択を行うと、Inkはその選択肢を「消費」し、次のシーンに移るため、その選択肢は再度表示されなくなります。
  2. story.ChooseChoiceIndex():
    • story.ChooseChoiceIndex(choice.Index)を呼び出すと、その選択肢が消費され、ストーリーがその選択に基づいて進行します。これにより、CurrentChoicesが更新され、選択肢のリストは減少します。
    • 新しい選択肢が生成されるまで、CurrentChoicesのリストは空になるか、選択肢が減った状態になります。

ああもう間違いないですね仕様です。InkのWikiの「7) Varying Choices」にも、これ仕様であるという記述があります。

ink/Documentation/WritingWithInk.md at master · inkle/ink · GitHub

ところで、この仕様にはどういう意味があるのでしょうか。

質問:この仕様は何のためにあるのですか?


Inkの仕様で、選択肢が一度選ばれると消費される仕組みは、インタラクティブな物語を自然に進行させるための重要な要素です。この仕様にはいくつかの目的があります。

1. ストーリーの進行に一貫性を持たせる

選択肢が一度選ばれると消費されることで、物語が進行したという感覚が得られます。これにより、プレイヤーが選んだ選択肢に基づいてストーリーが進展し、その結果が物語の中で反映されることを保証します。選択した後に同じ選択肢が再度表示されると、ストーリーが循環したり停滞したりする感覚が生まれてしまう可能性があります。

2. プレイヤーの決定の影響を強調する

選択肢が消費されることは、プレイヤーが下した決定が物語に不可逆的な影響を与えることを示しています。これは、プレイヤーが選択した結果に責任を感じ、物語の展開に対してより深い感情的な関与を持つようにするためです。

3. 複雑な分岐をサポートする

選択肢が消費される仕様により、Inkは複雑な分岐ストーリーを容易にサポートできます。一度選ばれた選択肢が消費されることで、物語の流れが次の分岐に進み、物語の分岐が無限に増えるのを防ぎます。これにより、プレイヤーの選択に応じてストーリーがより動的に変化し、選択肢ごとに異なる結末や新しい選択肢を提供できます。

4. 再帰やループを回避する

選択肢が消費されないと、プレイヤーが同じ選択肢を何度も選んでしまう可能性があり、物語が進行しない「ループ状態」に陥ることがあります。この仕様により、ストーリーはループを避け、常に新しい展開に向かって進むことが保証されます。

5. ユーザー体験の向上

プレイヤーに新しい選択肢を提示し続けることで、鮮度の高いユーザー体験が提供されます。プレイヤーが過去に選んだ選択肢を再度見ることなく、新しい状況やストーリーを探索できるため、飽きずにストーリーに没頭できます。

結論

この仕様は、プレイヤーの選択が物語に影響を与えることを強調し、インタラクティブな物語をスムーズに進行させるために必要なものです。選択肢が消費されることで、プレイヤーの決定に基づいたストーリーの進化を促し、同じ選択肢を再度選ぶことで進行が停滞しないようにする設計が意図されています。


ということで、これは厳然たる仕様ですね。理由を知った今は、これはバグどころがInkの素晴らしい長所の1つなんだろうなと思います。悪いのは、元の選択肢に戻るルートを作りながら、それに代わる新しい選択を用意していない自分(シナリオ)にある、というわけです。
プレイヤーにとってみれば、何度も同じ道を通るたびに同じ人物と同じ会話をするなんて興ざめですからね。その状態はそもそも起こらないようにする仕組みです。そしてそのイベントを消化してしまった場合に、もう先に進める選択肢がないことをクリエイターに知らせるためのRUNTIME ERROR、ということですね。

ではInkシナリオを少し修正しましょう。右と左の道があるのですが、真ん中の道を用意します。そして、そこを選んだら、オオアリクイに襲われて死んでしまうバッドエンドを用意します(オオアリクイは見た目と違って猛獣ですからね)。

クソゲーができると聞いてやってきました。

あなたクソゲー好きだったんですか。急に登場したと思ったら100%無駄な余白を消費していきますね。ええ、サンプルなんて全部クソゲーですよ(ひらきなおり)。というわけで、こんな感じで修正します。修正点を太字で示しています。

TITLE: "森の中での選択"
    -> Begin

=== Begin ===
= Start
あなたは深い森の中を歩いています。昼間だった空が、徐々に暗くなってきました。辺りは不気味な静寂に包まれています。遠くからかすかに川の音が聞こえてきますが、それ以外はすべてが静かです。
突然、道が二手に分かれています。右側の道は広く整備されており、木々が少なくなっています。左側は木々が鬱蒼としていて、足元がほとんど見えません。
どちらの道を進みますか?
* 右側の道を進む
    -> RightPath
* 左側の道を進む
    -> LeftPath
* 真ん中の道を進む
    -> MiddlePath

= MiddlePath
突然目の前にオオアリクイが現れました。オオアリクイはあなたに向かって大きく吠えました。
「ボタン1つでブログを自動で作成するなんて邪道なやつめ!成敗してくれる!」
人違いだ、といってあなたは逃げようとしましたが、それより先にオオアリクイの鋭い爪があなたを引き裂きました。
残念ながら、あなたの冒険はここで終了です。
    -> END

= RightPath
(以下省略)
     :

再度実行して正しく動くことを確認しましょう。真ん中を選択すると必ずENDに結実するので、右と左の道で何もせずスタートに戻ってきた場合でも、最後に真ん中を選択することで必ずENDを迎え、コンテンツ不足のランタイムエラーは起こらなくなります。

あとはInkの仕様に従ってシナリオを書けば、いくらでも壮大なストーリを構築できるようになりました。ただ、これだとただのテキストだけで味気ないです。いろいろ追加したいですが、まずは背景ですね。次の記事では、森の背景画像を追加する、に挑戦してみましょう。

それでは今日はこのへんで。

コメント

タイトルとURLをコピーしました