Help us understand the problem. What is going on with this article?

ノベルゲームを爆速で作るテクニック,ノベルゲームを作る方法

初めに

この記事では、ノベルゲームを爆速で書く方法を紹介しますが、この方法はあくまで僕(エンジニア歴0年)個人が描きやすいと思った方法ですので、実際にノベルゲームを書く際のコーディング規約に反している可能性があります。

今回はpython3で記述します。

(追記です:ユーザーの選択肢に全く同じ文字列を2個以上使うことはできません。)

やりたいこと

ノベルゲームを作ろうと思います。
ただし、ノベルゲームの条件管理や条件分岐を書いていくのは面倒なので、ストーリーに関するクエリをプログラムが入力で受け取り、自動でストーリーの分岐を管理してくれるプログラムを作りたいと思います。

また、クエリの入力も簡単にしたいので、元のお話の流れを崩さない順序で入力を受け取るようにしようと思います。

準備

今回のノベルゲームは、イベントの発生を根付き木で管理し、DFS(深さ優先探索)で探索します。

また、この記事内で書かれている"イベントi" , "状態i"とは、イベントの流れのうち、最も長くなる(すべてのイベントを達成する)様なストーリーのi番目に起こるイベントや状態です。もしそのイベントにGameOverが含まれたとしても、ストーリー生成の時点では強制的に続行します。

なお、この記事ではすべてのイベントに到達してからゴールする様なイベントの流れ(eventFlow)を、『Story』と呼ぶことにします。

以下にイベントの作成、および進行に必要な変数をまとめます。

  • isEventEnded : イベントiが終了したかどうか(DFSのDP配列の役割)

  • cpOutputs = [] : イベントiの状態でのOutput

  • eventFlow : DFSで探索する根付き木、二次元配列で辺を管理

  • userInputs = {} : userInputs[key] の値には、選択肢:keyが選択されてから移行する移行先のイベント番号を代入する

  • inputIndex = [] : イベントiに移行するための選択肢。userInputsの反対

  • isGameOver = [] : イベントiがゲームオーバーイベントかどうか (falseで初期化)

  • dist = [] : イベント0からイベントiまでの最短距離(デバッグ用)

  • eventNow : 今いるイベントが何番目のイベントか

  • eventNext : 次に追加するイベントが何番目のイベントか

これらの変数を、python3のクラスでまとめて宣言します。

class Story:
    num_max = 10000#制約

    isEventEnded = []
    #isEventEnded[i] == 1 なら イベントiは達成済み
    cpOutputs = []#cpの応答のリスト 、 cpOutputs[i] := イベントiの状態でのCpの応答
    #節点0を根とした木
    isGameOver = []#イベントiがゲームオーバーイベントかどうか
    #vector<vector<int> >型 , 一番重要。すべての状態の遷移はこのグラフが管理する 
    eventFlow = []#制約上の最大値を入れておくが、実際に使う個数はイベントの個数分

    userInputs = {}#userInputs[key] の値には、選択肢:keyが選択されてから移行する移行先のイベント番号を代入する

    inputIndex = []#inputIndex[i] :=状態iに移行するための選択肢。userInputsの反対

    dist = []#イベント0からイベントiまでの距離
    #dict.setdefault('key3', 'value3')
    def __init__(self):
        for i in range(self.num_max):
            self.eventFlow.append([])
            self.isGameOver.append(0) #デフォルトは ゲームオーバー=False
        self.userInputs.setdefault("reset",0)
        self.inputIndex.append("reset")


なお、eventNowとeventNextは動的な変数なのでこのクラスでは管理しません。

見通し

今回のテクニックで最も重要なことは、イベントの分岐を管理する根付き木であるeventFlowをDFSで探索して、イベントの発生および分岐を行うということです。
そのため、eventFlow(初期状態は空の配列の配列)にどの様にイベントを記憶させるかが大事になってきます。

以下の木は、根付き木をDFSで探索した場合の探索順を示すものです。

300px-Depth-first-tree.png

このような順でイベントを管理し、それをDFSで探索すればイベントを順番通りに網羅できます。

つまり、Story(前述参照)のi番目のイベントを、DFSがi番目に探索する節点に代入します。

なお、eventFlowは木における各節点の関係(辺の繋がり)を管理するものであり、『イベントiにおけるCPの発言』や『イベントiに到達するための選択肢』を管理するのはcpOutputsとinputIndexです。

また、eventFlowやcpOutputsなどで保管するイベントは、『単位イベント』とします。

『単位イベント』とは、『あるイベントに到達したら、必ずこの動作までは行いたい!』という流れのことです。今回の記事では、単位イベントはCPの発言とUSERの選択(応答)という流れにします。

クエリの形式

クエリは大きく分けて2種類あります。

  • ユーザーの選択肢(応答)を入力する (USERの選択肢追加モード)

  • CPの発言を入力する (CPの発言追加モード)

このクエリのどちらを入力するかをプログラムが管理し、出力でUSERに伝えます。


『ユーザーの選択肢(応答)を入力する』クエリで選択肢を入力すると、今いる節点からの分岐先の節点が新たに追加生成されます。

『ユーザーの選択肢(応答)を入力する』クエリで入力された選択肢がinputIndexやuserInputsに追加されると同時に、『入力された選択肢によって派生する節点』がeventFlow[今いる節点] に追加されます。

  • eventFlow[ 今いる節点 ] に 『イベントi』を追加することによって、 節点同士の辺の状態を更新する

  • inputIndex[ イベントi ] = イベントiに移行するための選択肢

  • userInputs[ 選択肢 ] = 選んだ選択肢による移行先の節点


ややこしい :thinking:


なお、イベントiの一つ前の節点に戻って分岐を追加したい場合、『ユーザーの選択肢(応答)を入力する』クエリで' 0 'を入力することで、一つ前の節点に戻り、USERの選択肢追加モードのまま待機するようにします。
その場合、イベントiには二度と訪れることはありません。

また、『ユーザーの選択肢(応答)を入力する』クエリで' 1 'を入力すると、' 0 'の場合と同様に一つ前の節点に戻り、USERの選択肢追加モードのまま待機するのですが、isGameOver[ 今いる節点 ]をtrueにしてから一つ前の節点に戻るようにします。


つまり、ストーリー生成の段階でuserの選択肢に' 0 ' を入力すると一つ前の節点に戻り、次のイベント生成は一つ戻った節点からスタートします。
' 1 'を入力した場合は、今いるイベントにGameOverフラグを立てる + 上記の動作 を行います。

この入力によって、イベントの分岐を動的に設定できるようになり、ストーリーの進行順かつDFSの探索順を守ったままeventFlow などを更新できます。


例えば今現在の状態が3で、『ユーザーの選択肢(応答)を入力する』クエリでユーザーが新たに選択肢を追加したとします。

すると、節点3から節点4につながる辺が新たに生成され、eventFlowに追加されます。その後、今いる節点を節点4に更新し、『CPの発言を入力する』クエリの入力待ちモードになります。

節点4の『ユーザーの選択肢(応答)を入力する』クエリで'0'を入力すると、今いる節点を3に戻し、次の選択肢(節点)を追加するためのクエリの入力待ち状態になります。

300px-Depth-first-treeのコピー.png

次に追加する節点は節点5です。しかし、今いる節点が3であるためそこから次のイベントの番号が5であることを導き出すことができません。

よって、今いる節点の番号を管理する変数:eventNowとは別にもう1つ、次に追加する節点の番号を管理する変数:eventNextの必要性に気づきます。

今回のプログラムでは、新たに節点を追加する直前に、eventNextの値を+1する必要があります。
eventNextの値はイベントの個数でもあるので、新たにイベントを追加する直前に+1するのが直感的だと感じますが、eventNextとeventNowの更新方法は各自お好きなようにしてください。

変数の遷移は以下のとおりです。

  • eventNow = 3 , eventNext = 3
  • eventNextを+1 , 節点eventNextを追加 , eventNow = eventNext(イベント4に移る)
  • 前の節点に戻る , eventNow = 前の節点の番号(今回は3)

この状態で新たに節点を追加すると以下のようになります。

300px-Depth-first-tree.png

  • eventNow = 3 , eventNext = 4
  • eventNextを+1 , 節点eventNextを追加 , eventNow = eventNext(イベント5に移る)

説明下手でごめんなさいね
頭痛くなってきましたね


『CPの発言を入力する』クエリでは、イベントiにおけるCPの発言を入力します。
入力したプログラムは、cpOutputs[ 今いる節点 ] に代入します。

  • cpOutputs[ 今いる節点 ] = 今いる節点におけるイベントでのCPの発言

このクエリのどちらを入力するかを、プログラムがUSERに指定してUSERの入力を待ちます。


Storyの生成

300px-Depth-first-tree.png

さて、前述の通りeventFlowにこのグラフの順でイベントを代入していきます。

その手順を以下のpython3コードで示します。


Story_instance = Story()






def make_set(S_main):

    eventNow = -1#現在のイベント状態
    eventNext = 0#次のイベント状態

    inputTurn = 0#0ならCPのoutputを入力し、1ならuserのinputを入力する

    depthNow = 0
    #eventNow == iのとき、S_main.dist[i] = depthNow
    #depthNowのことを 節点の深さという

    while(1):

        if(eventNow== -1):#一番最初の発話
            print("CpOutput:",end ="")
            Out= input()#CP側の発話をを入力して生成
            S_main.isEventEnded.append(0)
            S_main.cpOutputs.append(Out)


            eventNow = eventNext#次のイベント状態へ移行
            print("現在のイベント:" , end ="")
            print(S_main.cpOutputs[eventNow])
            S_main.dist.append(depthNow)#新たに生成されたeventのdist
            inputTurn = 1

            continue


        if(inputTurn == 1):
            print("UserInput:",end ="")
            inp= input()#ユーザー側の選択肢を入力して生成
            if(inp=="0"):#inp==0を、もう一つ浅い節点に戻る合図とする。ストーリー生成続行のため、1つ前に戻る
                depthNow-=1 #深さを-1
                if(eventNow==0):
                    break#これ以上戻れないとき、全ての入力が終了したと判断して終了
                else:
                    eventNow = S_main.eventFlow[eventNow][0] #eventNowはルートを確保するため、1つ前のevent状態に戻る
                    #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                    #(前の節点から今の節点に来る際、初めてeventFlow[eventNow]に要素が追加されるから)

                    print("現在のイベント:" , end ="")#一つ前のイベントに戻った場合、現在のイベントのCPの発言をもう一度表示し、
                    print(S_main.cpOutputs[eventNow])#追加するユーザーの選択肢を選びやすく(わかりやすく)する




            elif (inp == "1"):#inp == 1は、そのイベントがGameOverである合図とする。また、ストーリー生成続行のため、1つ前に戻る
                depthNow-=1 #深さを-1
                S_main.isGameOver[eventNow] = 1

                if(eventNow==0):
                    break#これ以上戻れないとき、全ての入力が終了したと判断して終了
                else:
                    eventNow = S_main.eventFlow[eventNow][0] #eventNowはルートを確保するため、1つ前のevent状態に戻る
                    #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                    #(前の節点から今の節点に来る際、初めてeventFlow[eventNow]に要素が追加されるから)

                    print("現在のイベント:" , end ="")#一つ前のイベントに戻った場合、現在のイベントのCPの発言をもう一度表示し、
                    print(S_main.cpOutputs[eventNow])#追加するユーザーの選択肢を選びやすく(わかりやすく)する


            else:
                #新しいイベントの生成

                depthNow+=1#深さを+1

                eventNext += 1#eventNext を更新し、 新しいイベント状態の番号にする


                S_main.dist.append(depthNow)#新たに生成されたeventのdist
                S_main.isEventEnded.append(0)#isEventEnded[eventNext]を追加
                S_main.userInputs.setdefault(inp, eventNext)#userInputsにeventNextに移行するような選択肢を追加
                S_main.inputIndex.append(inp)#inputIndex[i] :=状態iに移行するための選択肢
                S_main.eventFlow[eventNow].append(eventNext)
                S_main.eventFlow[eventNext].append(eventNow)#グラフの2点をつなげた

                eventNow = eventNext#新しいイベント状態へ移行

                inputTurn = 0


        else:#eventNow番のイベントでのCPの応答を入力
            print("CpOutput:",end ="")
            Out = input()

            S_main.cpOutputs.append(Out)
            print("現在のイベント:" , end ="")
            print(S_main.cpOutputs[eventNow])
            inputTurn = 1


    return (len(S_main.userInputs) > 1)#元のサイズより大きくなっていたら成功なのでTrue





このコードの解説は本質でないので詳しくはしませんが、前述の通りCPの発言を受け取るパートとUSERの選択肢を受け取るパートの2つに分けて書かれています。

while文で無限にストーリーを生成しますが、節点が0の状態でさらに前の状態( eventNow == -1 )の状態に戻るとストーリー生成終了です。

isGameOver 以外の配列には、新たな節点が追加されるタイミングで値をappend(push_back)してますが、新たに生成されるイベントの番号は0,1,2,3...と0から小さい順で1ずつ増加するので、各配列のindex:iはイベントiに対応しています。

少しややこしいですが、eventNow , eventNext の動的な変化も前述の規則性と同様です。

これで得た Story はクラスのインスタンスに保管されます。保管されたStoryインスタンスはpickleモジュールを用いてバイナリファイルで保存するなどで各自活用してください。

Storyの進行

Storyの進行はDFSを用いるので、イベント i が到達済みかどうかを管理するDP配列が必要になります。
今回はisEventEndedにDP配列の役割を任せます。

Storyの進行過程で行いたい動作は主に3つです。

  • 今いる節点から未到達節点に伸びている辺が存在するかの判定と、ゲーム終了フラグの判定...①

  • 選択肢に対応した分岐先への移動と、一つ前の節点への移動...②

  • 到達、未到達のDP配列の更新...③

これらの動作は本来であれば3つの関数を独立させて行うべきですが、今回は書き込む量が少ないので1つの関数でやってしまいます。

以下は3つの動作を実装したpython3のコードです。








def main(S_main):
    eventNow = 0 #現在の状態
    achieveNum = 0#isEventEndedを埋めた数

    while(achieveNum < len(S_main.isEventEnded)):

        #----------------------------------------------------------------------
        #------------------------------ゲームオーバーイベント----------------------
        if(S_main.isGameOver[eventNow] == 1):
            print("CP:",end = "")
            print(S_main.cpOutputs[eventNow])#CPの発言
            print("GameOver")
            break

        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------






        #------------------------------------------------------------------------
        #--------------------------先にすすめるかどうか-----------------------------
        #-今いる場所が葉の場合はCPの発言を表示して前の節点に戻り、whileの冒頭からやり直し--
        #-------------------------------------先に進めるならCPの発言を表示-----------
        #---------先にすすめない場合は一つ前の節点に戻ってwhileの冒頭からやり直し---------



        if(eventNow == 0):#ゲームが続行不能な場合かどうかを判定
            AbleToGoAhead = 0
            for i in range(len(S_main.eventFlow[eventNow])):
                if(S_main.isEventEnded[S_main.eventFlow[eventNow][i]] == 0 ) and S_main.eventFlow[eventNow][i] > eventNow:
                    AbleToGoAhead += 1
                    break
                else:
                    continue


            #-------------------判定---------------------------
            #print("Able from {}:{}".format(eventNow,AbleToGoAhead))
            if(AbleToGoAhead>=1):#続行可能な場合

                #------------------------------CPの発言を表示して続行---------------------
                print("CP:",end = "")
                print(S_main.cpOutputs[eventNow])
                #----------------------------------------------------------------------

            else: #続行不可能な場合
                achieveNum+=1#達成数を+1する
                S_main.isEventEnded[eventNow]=1
                eventNow = -1#
                print("ゲームを終了します。")
                break


        else:

            if(len(S_main.eventFlow[eventNow])== 1):#今いる場所が葉で、次の点がない場合
                #----------------------CPの発言を表示して次のループへ----------------------
                print("CP:",end = "")
                print(S_main.cpOutputs[eventNow])

                S_main.isEventEnded[eventNow]=1#今いる地点を達成済みにする
                achieveNum+=1#達成数を+1する
                eventBefore = eventNow #さっき(1つ前に)いた場所
                eventNow = S_main.eventFlow[eventNow][0]#一つ上の節点に戻る
                #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                #今いる地点からさっきいた場所にDFSで行く場合の経路をすべて到達済みにする
                for i in range(eventNow + 1 , eventBefore):
                    if(S_main.isEventEnded[i]!=1):#子孫のうち、達成していない部分を達成済みにする
                        S_main.isEventEnded[i]=1
                        achieveNum+=1#達成数を+1する
                continue

                #----------------------------------------------------------------------


            else:#まだ続きがあるかもしれない場合

                AbleToGoAhead = 0
                for i in range(len(S_main.eventFlow[eventNow])):
                    #自分よりもしたの節点で未到達なものがあるかどうか
                    if(S_main.isEventEnded[S_main.eventFlow[eventNow][i]] == 0 ) and (S_main.eventFlow[eventNow][i] > eventNow):
                        AbleToGoAhead += 1
                        break
                    else:
                        pass

                #判定
                #print("Able from {}:{}".format(eventNow,AbleToGoAhead))
                if(AbleToGoAhead == 0 ):#自分より下の未到達地点はない

                    S_main.isEventEnded[eventNow]=1#今いる地点を達成済みにする
                    achieveNum+=1#達成数を+1する
                    eventBefore = eventNow #さっき(1つ前に)いた場所
                    eventNow = S_main.eventFlow[eventNow][0]#一つ上の節点に戻る
                    #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                    #今いる地点からさっきいた場所にDFSで行く場合の経路をすべて到達済みにする
                    for i in range(eventNow + 1 , eventBefore):
                        if(S_main.isEventEnded[i]!=1):#子孫のうち、達成していない部分を達成済みにする
                            S_main.isEventEnded[i]=1
                            achieveNum+=1#達成数を+1する
                    continue

                else:
                    #------------------------------CPの発言を表示して続行---------------------
                    print("CP:",end = "")
                    print(S_main.cpOutputs[eventNow])
                    #----------------------------------------------------------------------


            #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
            #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
            #----------------------------------------------------------------------------------------------------------------------------------------------------------------------










        #----------------------------------------------------------------------
        #----------------------------DFSによる遷移と判定-------------------------
        #----------------------------------------------------------------------


        for i in range(len(S_main.eventFlow[eventNow])):
            if(S_main.eventFlow[eventNow][i] > eventNow):#今のイベントよりも先のイベントの場合、選択肢としてあげられる
                print("・",end = "")
                print(S_main.inputIndex[S_main.eventFlow[eventNow][i]])#選択肢

        inp = input()#userの選択

        if(inp in S_main.userInputs):
            if (S_main.isEventEnded[S_main.userInputs[inp]] != 1) and (S_main.userInputs[inp] in S_main.eventFlow[eventNow]) and (S_main.userInputs[inp] > eventNow):
            #遷移先が達成済みでないかつ、
            #遷移先に隣接している(これは、選択肢には表示されてないがuserInputs内には存在するものが入力されたときのために設定)ならば遷移可能
                eventNow = S_main.userInputs[inp]
                continue
            else:#達成済みだった または選択肢にはないがuserInputsにはある発言をした場合
                print("その選択で良い結果が得られるとは思いません。")
                continue
        else:
            print("用意されたレールの上を走ることは、ゴールへの近道ですよ。")
            continue

        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------


このコードも本質でないので解説は軽めにしておきますが、基本動作の①,②,③だけ解説します。

動作①

eventFlow[ i ] には,イベント i から派生するイベントの番号が保管されていることと、イベント i よりも後に起こるイベント x は全て x > i を満たすことを利用します。

eventFlow[ eventNow ] を for 文で調べ、未到達かつ eventFlow[ eventNow ][j] > eventNowであるような j が存在すれば進行可能フラグを立てます。
ただし、isGameOver[ eventNow ] フラグが立っているならば、どこにも進行せずにゲームを終了します。

また、eventNow が 0 の場合とそれ以外の場合で動作が異なります。

  • eventNow が 0 の場合:前の節点に戻ることができないので、進行不能ならその時点でゲームを終了する
  • eventNow が 0 以外の場合:前の節点に戻って、他のルートの進行可能ルートを探すことができる

進行可能と判断された場合、cpOutputs[ eventNow ]が表示された後、eventFlow[ eventNow ] に保存されている行き先に対応したUSERの選択肢を全て、 inputIndex から取り出して表示する。

動作②

USERから入力された選択肢を inp とすると、inp が反映されるには次の2つの条件をクリアしなければなりません。

  • inp が userInputsディクショナリーのKeyとして存在する
  • (userInputs[inp] in eventFlow[eventNow]) and (userInputs[inp] > eventNow)

1つ目の条件は、USERの入力が、CPの想定する範囲内の入力であるかどうかということです。

2つ目の条件は、選択肢 inp による遷移先がeventNowと隣接しているかつ、ストーリー進行上でeventNowよりも後に起こるイベントであるということです。

この二つの条件をクリアしている場合はeventNowを更新して、条件をクリアしていない場合はエラーメッセージを出力し、もう一度入力待ち状態になります。

動作③

DFSの探索が今いる節点よりも浅い節点に戻る条件は、今いる節点から、さらに深くにいくルートが全て到達済みであるということです。

よって、節点 x から一つ前のノードに戻る際、isEventEnded[ x ] だけtrueにしても不十分です。

ここで、

  • イベント番号の順番はストーリー進行の順番通り

という性質を思い出すと、『節点 x が到達済み』 => 『節点 x の1つ前の節点から派生する、イベント番号がx以下のイベントは全て到達済み扱いして良い』 ということに気付きます。

300px-Depth-first-tree.png

例えばグラフg = { 3 - 4 , 3 - 5 }を考えます。

イベントを3 -> 5と進めたとき、節点 5 は行き止まりなので、節点 5 のみを到達済みにして節点 3 に戻ります。

しかし、もしその後の選択肢として 節点 4 が選ばれたとすると、イベント進行順が 3 -> 5 -> 4 になってしまうので、Story に反します。
よって、節点 5 のみを到達済みにするのではなく、節点 5 を到達済みにした後、節点 4 も到達済みにする必要があります。

ここで、 イベント i -> イベント i + x -> イベント i の遷移を終えた後は、イベント i+1 ~ イベント x を全て到達済みにして良いことに気付きます。


以上の3つの動作を繰り返したのち、最終的に0を含めた全ての地点が到達済みの場合はゲームクリア、それ以外の場合で終了した場合は GameOverの判定になります。

ゲームクリアかどうかの判定をbool値としてreturn すると、他のプログラムや、別のゲーム進行に応用することができます。

例えば、今回のプログラムでは 『単位イベント』 を 『USERとCPのやりとり』にしましたが、Meta_Storyの『単位イベント』として Story_i を持つこともできます。

いざ実行

ここまで長らくお付き合いいただきありがとうございました。

最後にコードと実行結果を貼ってシメにしようと思います。

今回のクエリのテストデータはこちらです。

あ、目の前に変なおじさんがいる
あいさつ
スラマッパギと言われた
0
殴る
痛いらしい
さらに殴る
警察を呼ばれた
1
ごめん
いいよと言われた
0
0
無視
追いかけられた
逃げる
捕まる
1
交番
しまってる
銃を奪う
犯罪
1
さらに逃げる
疲れておってこない
0
0
0
0

そして、コードはこちらです。








# coding: UTF-8
import pickle




class Story:
    num_max = 10000#制約

    isEventEnded = []
    #isEventEnded[i] == 1 なら イベントiは達成済み
    cpOutputs = []#cpの応答のリスト 、 cpOutputs[i] := イベントiの状態でのCpの応答
    #節点0を根とした木
    isGameOver = []#イベントiがゲームオーバーイベントかどうか
    #vector<vector<int> >型 , 一番重要。すべての状態の遷移はこのグラフが管理する 
    eventFlow = []#制約上の最大値を入れておくが、実際に使う個数はイベントの個数分

    userInputs = {}#userInputs[key] の値には、選択肢:keyが選択されてから移行する移行先のイベント番号を代入する

    inputIndex = []#inputIndex[i] :=状態iに移行するための選択肢。userInputsの反対

    dist = []#イベント0からイベントiまでの距離
    #dict.setdefault('key3', 'value3')
    def __init__(self):
        for i in range(self.num_max):
            self.eventFlow.append([])
            self.isGameOver.append(0) #デフォルトは ゲームオーバー=False
        self.userInputs.setdefault("reset",0)
        self.inputIndex.append("reset")




Story_instance = Story()






def make_set(S_main):

    eventNow = -1#現在のイベント状態
    eventNext = 0#次のイベント状態

    inputTurn = 0#0ならCPのoutputを入力し、1ならuserのinputを入力する

    depthNow = 0
    #eventNow == iのとき、S_main.dist[i] = depthNow
    #depthNowのことを 節点の深さという

    while(1):

        if(eventNow== -1):#一番最初の発話
            print("CpOutput:",end ="")
            Out= input()#CP側の発話をを入力して生成
            S_main.isEventEnded.append(0)
            S_main.cpOutputs.append(Out)


            eventNow = eventNext#次のイベント状態へ移行
            print("現在のイベント:" , end ="")
            print(S_main.cpOutputs[eventNow])
            S_main.dist.append(depthNow)#新たに生成されたeventのdist
            inputTurn = 1

            continue


        if(inputTurn == 1):
            print("UserInput:",end ="")
            inp= input()#ユーザー側の選択肢を入力して生成
            if(inp=="0"):#inp==0を、もう一つ浅い節点に戻る合図とする。ストーリー生成続行のため、1つ前に戻る
                depthNow-=1 #深さを-1
                if(eventNow==0):
                    break#これ以上戻れないとき、全ての入力が終了したと判断して終了
                else:
                    eventNow = S_main.eventFlow[eventNow][0] #eventNowはルートを確保するため、1つ前のevent状態に戻る
                    #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                    #(前の節点から今の節点に来る際、初めてeventFlow[eventNow]に要素が追加されるから)

                    print("現在のイベント:" , end ="")#一つ前のイベントに戻った場合、現在のイベントのCPの発言をもう一度表示し、
                    print(S_main.cpOutputs[eventNow])#追加するユーザーの選択肢を選びやすく(わかりやすく)する




            elif (inp == "1"):#inp == 1は、そのイベントがGameOverである合図とする。また、ストーリー生成続行のため、1つ前に戻る
                depthNow-=1 #深さを-1
                S_main.isGameOver[eventNow] = 1

                if(eventNow==0):
                    break#これ以上戻れないとき、全ての入力が終了したと判断して終了
                else:
                    eventNow = S_main.eventFlow[eventNow][0] #eventNowはルートを確保するため、1つ前のevent状態に戻る
                    #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                    #(前の節点から今の節点に来る際、初めてeventFlow[eventNow]に要素が追加されるから)

                    print("現在のイベント:" , end ="")#一つ前のイベントに戻った場合、現在のイベントのCPの発言をもう一度表示し、
                    print(S_main.cpOutputs[eventNow])#追加するユーザーの選択肢を選びやすく(わかりやすく)する


            else:
                #新しいイベントの生成

                depthNow+=1#深さを+1

                eventNext += 1#eventNext を更新し、 新しいイベント状態の番号にする


                S_main.dist.append(depthNow)#新たに生成されたeventのdist
                S_main.isEventEnded.append(0)#isEventEnded[eventNext]を追加
                S_main.userInputs.setdefault(inp, eventNext)#userInputsにeventNextに移行するような選択肢を追加
                S_main.inputIndex.append(inp)#inputIndex[i] :=状態iに移行するための選択肢
                S_main.eventFlow[eventNow].append(eventNext)
                S_main.eventFlow[eventNext].append(eventNow)#グラフの2点をつなげた

                eventNow = eventNext#新しいイベント状態へ移行

                inputTurn = 0


        else:#eventNow番のイベントでのCPの応答を入力
            print("CpOutput:",end ="")
            Out = input()

            S_main.cpOutputs.append(Out)
            print("現在のイベント:" , end ="")
            print(S_main.cpOutputs[eventNow])
            inputTurn = 1


    return (len(S_main.userInputs) > 1)#元のサイズより大きくなっていたら成功なのでTrue









def main(S_main):
    eventNow = 0 #現在の状態
    achieveNum = 0#isEventEndedを埋めた数

    while(achieveNum < len(S_main.isEventEnded)):

        #----------------------------------------------------------------------
        #------------------------------ゲームオーバーイベント----------------------
        if(S_main.isGameOver[eventNow] == 1):
            print("CP:",end = "")
            print(S_main.cpOutputs[eventNow])#CPの発言
            print("GameOver")
            break

        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------






        #------------------------------------------------------------------------
        #--------------------------先にすすめるかどうか-----------------------------
        #-今いる場所が葉の場合はCPの発言を表示して前の節点に戻り、whileの冒頭からやり直し--
        #-------------------------------------先に進めるならCPの発言を表示-----------
        #---------先にすすめない場合は一つ前の節点に戻ってwhileの冒頭からやり直し---------



        if(eventNow == 0):#ゲームが続行不能な場合かどうかを判定
            AbleToGoAhead = 0
            for i in range(len(S_main.eventFlow[eventNow])):
                if(S_main.isEventEnded[S_main.eventFlow[eventNow][i]] == 0 ) and S_main.eventFlow[eventNow][i] > eventNow:
                    AbleToGoAhead += 1
                    break
                else:
                    continue


            #-------------------判定---------------------------
            #print("Able from {}:{}".format(eventNow,AbleToGoAhead))
            if(AbleToGoAhead>=1):#続行可能な場合

                #------------------------------CPの発言を表示して続行---------------------
                print("CP:",end = "")
                print(S_main.cpOutputs[eventNow])
                #----------------------------------------------------------------------

            else: #続行不可能な場合
                achieveNum+=1#達成数を+1する
                S_main.isEventEnded[eventNow]=1
                eventNow = -1#
                print("ゲームを終了します。")
                break


        else:

            if(len(S_main.eventFlow[eventNow])== 1):#今いる場所が葉で、次の点がない場合
                #----------------------CPの発言を表示して次のループへ----------------------
                print("CP:",end = "")
                print(S_main.cpOutputs[eventNow])

                S_main.isEventEnded[eventNow]=1#今いる地点を達成済みにする
                achieveNum+=1#達成数を+1する
                eventBefore = eventNow #さっき(1つ前に)いた場所
                eventNow = S_main.eventFlow[eventNow][0]#一つ上の節点に戻る
                #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                #今いる地点からさっきいた場所にDFSで行く場合の経路をすべて到達済みにする
                for i in range(eventNow + 1 , eventBefore):
                    if(S_main.isEventEnded[i]!=1):#子孫のうち、達成していない部分を達成済みにする
                        S_main.isEventEnded[i]=1
                        achieveNum+=1#達成数を+1する
                continue

                #----------------------------------------------------------------------


            else:#まだ続きがあるかもしれない場合

                AbleToGoAhead = 0
                for i in range(len(S_main.eventFlow[eventNow])):
                    #自分よりもしたの節点で未到達なものがあるかどうか
                    if(S_main.isEventEnded[S_main.eventFlow[eventNow][i]] == 0 ) and (S_main.eventFlow[eventNow][i] > eventNow):
                        AbleToGoAhead += 1
                        break
                    else:
                        pass

                #判定
                #print("Able from {}:{}".format(eventNow,AbleToGoAhead))
                if(AbleToGoAhead == 0 ):#自分より下の未到達地点はない

                    S_main.isEventEnded[eventNow]=1#今いる地点を達成済みにする
                    achieveNum+=1#達成数を+1する
                    eventBefore = eventNow #さっき(1つ前に)いた場所
                    eventNow = S_main.eventFlow[eventNow][0]#一つ上の節点に戻る
                    #eventFlow[eventNow][0]には、必ずその一つ前の節点の番号が入っている
                    #今いる地点からさっきいた場所にDFSで行く場合の経路をすべて到達済みにする
                    for i in range(eventNow + 1 , eventBefore):
                        if(S_main.isEventEnded[i]!=1):#子孫のうち、達成していない部分を達成済みにする
                            S_main.isEventEnded[i]=1
                            achieveNum+=1#達成数を+1する
                    continue

                else:
                    #------------------------------CPの発言を表示して続行---------------------
                    print("CP:",end = "")
                    print(S_main.cpOutputs[eventNow])
                    #----------------------------------------------------------------------


            #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
            #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
            #----------------------------------------------------------------------------------------------------------------------------------------------------------------------










        #----------------------------------------------------------------------
        #----------------------------DFSによる遷移と判定-------------------------
        #----------------------------------------------------------------------


        for i in range(len(S_main.eventFlow[eventNow])):
            if(S_main.eventFlow[eventNow][i] > eventNow):#今のイベントよりも先のイベントの場合、選択肢としてあげられる
                print("・",end = "")
                print(S_main.inputIndex[S_main.eventFlow[eventNow][i]])#選択肢

        inp = input()#userの選択

        if(inp in S_main.userInputs):
            if (S_main.isEventEnded[S_main.userInputs[inp]] != 1) and (S_main.userInputs[inp] in S_main.eventFlow[eventNow]) and (S_main.userInputs[inp] > eventNow):
            #遷移先が達成済みでないかつ、
            #遷移先に隣接している(これは、選択肢には表示されてないがuserInputs内には存在するものが入力されたときのために設定)ならば遷移可能
                eventNow = S_main.userInputs[inp]
                continue
            else:#達成済みだった または選択肢にはないがuserInputsにはある発言をした場合
                print("その選択で良い結果が得られるとは思いません。")
                continue
        else:
            print("用意されたレールの上を走ることは、ゴールへの近道ですよ。")
            continue

        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------
        #----------------------------------------------------------------------------------------------------------------------------------------------------------------------




if __name__ == '__main__':

    make_set(Story_instance)



    print("each dist")
    for i in range(len(Story_instance.dist)):
        print(Story_instance.dist[i])

    main(Story_instance)












最後に、肝心の実行結果です。

ストーリーデータをInputした結果:

CpOutput:あ、目の前に変なおじさんがいる
現在のイベント:あ、目の前に変なおじさんがいる
UserInput:あいさつ
CpOutput:スラマッパギと言われた
現在のイベント:スラマッパギと言われた
UserInput:0
現在のイベント:あ、目の前に変なおじさんがいる
UserInput:殴る
CpOutput:痛いらしい
現在のイベント:痛いらしい
UserInput:さらに殴る
CpOutput:警察を呼ばれた
現在のイベント:警察を呼ばれた
UserInput:1
現在のイベント:痛いらしい
UserInput:ごめん
CpOutput:いいよと言われた
現在のイベント:いいよと言われた
UserInput:0
現在のイベント:痛いらしい
UserInput:0
現在のイベント:あ、目の前に変なおじさんがいる
UserInput:無視
CpOutput:追いかけられた
現在のイベント:追いかけられた
UserInput:逃げる
CpOutput:捕まる
現在のイベント:捕まる
UserInput:1
現在のイベント:追いかけられた
UserInput:交番
CpOutput:しまってる
現在のイベント:しまってる
UserInput:銃を奪う
CpOutput:犯罪
現在のイベント:犯罪
UserInput:1
現在のイベント:しまってる
UserInput:さらに逃げる
CpOutput:疲れておってこない
現在のイベント:疲れておってこない
UserInput:0
現在のイベント:しまってる
UserInput:0
現在のイベント:追いかけられた
UserInput:0
現在のイベント:あ、目の前に変なおじさんがいる
UserInput:0
each dist
0
1
1
2
2
1
2
2
3
3
CP:あ、目の前に変なおじさんがいる
・あいさつ
・殴る
・無視


先ほどのテストデータを一行ずつ入力した結果です。
テストデータは複数行まとめて入力しても大丈夫です。
テストデータを入力し終わったら以下の状態に移り変わります。

CPのOutputと,選択肢をInputした結果:

CP:あ、目の前に変なおじさんがいる
・あいさつ
・殴る
・無視
あいさつ
CP:スラマッパギと言われた
CP:あ、目の前に変なおじさんがいる
・あいさつ
・殴る
・無視
殴る
CP:痛いらしい
・さらに殴る
・ごめん
ごめん
CP:いいよと言われた
CP:あ、目の前に変なおじさんがいる
・あいさつ
・殴る
・無視
無視
CP:追いかけられた
・逃げる
・交番
交番
CP:しまってる
・銃を奪う
・さらに逃げる
銃を奪う
CP:犯罪
GameOver

こちらがストーリー進行です。

入力してる途中で気づきましたが、交番がしまってる -> 銃を奪うって変ですね...まあ窓ガラス割って侵入したということにしておきましょう。

終わりに

長くなってしまいましたが、これで今回のアルゴリズム紹介は終わりです。
お付き合いありがとうございました。

このテクニックだと、いちいち条件分岐したり、グラフに手動でイベントを追加するのに比べて格段に楽になると思います。

ただ、少し気になっているのですが、このテクニックって僕が知らないだけで実はメジャーなテクニックなのではないかと考え、やきもきしています。

冒頭で述べたとおり、僕はエンジニア歴0年なのでコーディング規約や一般のエンジニアが用いるテクニックを何一つ知りません。

もしこの記事を読んだ方の中にそちら方面に明るい方がいらっしゃったらご一報お待ちしています。

その他、訂正、質問なども僕のTwitterのDMの方にまで気軽にお問い合わせください。

Twitter : https://twitter.com/NokonoKotlin

ここまで読んでいただきありがとうございました!テリマカシー(インドネシア語でありがとう)!!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away