1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM・LLM活用Advent Calendar 2024

Day 13

生成AIとRen'Pyを使ってビジュアルノベルを作ってみましょう!

Last updated at Posted at 2024-12-19

はじめに

  • この記事はLLM・LLM活用Advent Calendar 13日目の記事です。
  • 生成AIを活用し、ゲームのストーリーやキャラクター、画像の生成を行い、それを基にビジュアルノベルを制作します。
  • 最後には、生成AIがユーザーの選択に応じて自動的かつ動的にゲームを制作する応用例についてもご紹介します。

ビジュアルノベルとはどういうゲームジャンルなのか?

画像はPS Vita版「Fate/stay night [Realta Nua]」
画像はPS Vita版「Fate/stay night [Realta Nua]」

画像は『かまいたちの夜X3』収録の「シュプール編」より
画像は『かまいたちの夜X3』収録の「シュプール編」より

  • ビジュアルノベルは、テキストに絵や音楽、選択肢などを組み合わせた独特の電子小説形式で、プレイヤーに物語を読むだけでなく、体験させることを目的としています。

  • このジャンルは1980年代後半から1990年代初頭に日本で誕生し、現在では世界中で人気を集めています。

人気ビジュアルノベル作品

Ren’Pyについて知ってました?

  • Ren'Pyは、世界中で数千人のクリエーターに愛用されている、ビジュアルノベル制作に特化したオープンソースのゲームエンジンです。

  • 簡単なスクリプト言語を使用しながら、多言語対応やマルチプラットフォーム展開が可能な点が特徴です。

Ren'Pyの主な特徴

Ren'Pyの主な特徴として、以下の点が挙げられます:

  • 脚本形式の簡単なスクリプト構文を採用し、初心者でも理解しやすい設計
  • 多言語対応が容易で、翻訳ファイルの作成だけでゲーム内テキストを切り替え可能
  • Windows、macOS、Linux、Android、iOSなど、複数のプラットフォームに対応
  • MITライセンスのもとで提供され、商用利用も自由
  • ストーリー分岐、セーブ・ロード機能、ロールバック機能など、ビジュアルノベルに必要な機能を標準搭載

これらの特徴により、Ren'Pyは初心者から上級者まで幅広い層のクリエーターに支持され、グローバル展開にも適したツールとなっています。

Ren'Py製の有名ゲーム

 Ren'Pyを使用して制作された有名なゲームには、以下のようなタイトルがあります:

  • ドキドキ文芸部!』- 日本でも人気を博した心理ホラーゲーム
  • 『Corpse Party D2: Zero Hope』- ホラーアドベンチャーゲームシリーズの一作
  • 『Digital: A Love Story』- レトロなコンピューター画面を模したビジュアルノベル
  • 『Analogue: A Hate Story』- SF設定のビジュアルノベル
  • 『Don't take it personally, babe, it just ain't your story』- 学園を舞台にしたストーリー

これらの作品は、Ren'Pyの柔軟性と表現力を活かし、独創的なゲーム体験を提供しています。多様なジャンルやスタイルのゲームが制作可能であることが、Ren'Pyの魅力の一つとなっています。

Ren’Pyを触ってみよう!

インストール

スクリプトファイルの構造

Ren'Pyのスクリプトファイルは.rpy拡張子を持ち、script.rpyがデフォルトのスクリプトファイルです。

基本構造

label start:
    "ここにストーリーを記述します。"
    return

主な文法要素

ラベル (Label)

  • ラベルはゲーム内の特定のポイント(例: シーン切り替え)を示し、labelキーワードで宣言します。
  • すべてのRen'Pyスクリプトはstartラベルから開始されます。
label start:
    "ゲームが始まりました。"
    jump ending  # 特定のラベルにジャンプ

label ending:
    "ゲームが終了しました。"
    return

ダイアログ (Dialogue)

  • ダイアログは引用符(")で囲んで記述し、セリフを画面に表示します。

  • キャラクター名を指定してセリフを表示することも可能です。

"こんにちは、Ren'Pyへようこそ!"

キャラクター定義後のダイアログ

define e = Character("エミリー")

label start:
    e "こんにちは!私はエミリーです。"
    return

シーンの切り替え (Scene)

  • sceneキーワードを使って背景を設定したり、シーンを切り替えたりします。

  • withキーワードで切り替え効果を指定できます。

scene bg room
with fade

キャラクター定義 (Define)

  • defineキーワードでキャラクターを定義し、名前や色、スタイルを設定します。
define s = Character("サヨ", color="#00FF00")

選択肢 (Menu)

  • menuを使用してプレイヤーに選択肢を提供します。
menu:
    "選択肢1":
        "あなたは選択肢1を選びました。"
    "選択肢2":
        "あなたは選択肢2を選びました。"

音楽と効果音

  • playstopキーワードを使用して音楽や効果音を制御します。
play music "bgm.ogg"
stop music

画像の表示

  • showを使って画像を画面に表示し、hideで削除します。
show character happy
hide character

変数と条件文

  • 変数を設定し、条件文(ifelifelse)を使用できます。
$ points = 0

if points > 5:
    "あなたは高得点を獲得しました!"
else:
    "もっとポイントを集めてください。"

実行の流れ

  1. label start:から開始します。

  2. スクリプトに記載された順番に実行されます。

  3. 特定のラベルに移動する場合はjumpを使用します。

  4. ゲームを終了する場合はreturnを使用します。

基本文法のまとめ

キーワード 役割
label ラベルの宣言
scene 背景画像の設定と切り替え
show キャラクターまたは画像の表示
hide キャラクターまたは画像の非表示
menu 選択肢の提供
play 音楽または効果音の再生
stop 音楽または効果音の停止
$ 変数またはPythonコードの実行
return ゲーム終了または現在のラベルの終了

生成AIを使って簡単なゲームを作ってみよう!

ストーリーの生成

  • ストーリー生成用のプロンプト
美少女恋愛シミュレーションのストーリーをRen'Pyスクリプト形式で作成してください。以下の条件を満たすよう、豊富で詳細なストーリーを構成してください。

## 1. 登場人物
- 主人公である男女2人を登場させる。
- 各キャラクターの性格、背景、目標を具体的に設定し、キャラクターに深みを持たせる。

## 2. ストーリー構成
- **起承転結**がはっきりと分かるようにストーリーを展開する。
  - **起**: 主人公同士の出会いと日常の始まり。
  - **承**: お互いへの感情の発展と葛藤要素の導入。
  - **転**: 緊張感あふれる事件や秘密が明らかになり、クライマックスを形成する。
  - **結**: 衝撃的などんでん返しでストーリーを締めくくる。

## 3. どんでん返しの要素
- ストーリーの最後に予想外の**衝撃的などんでん返し**を含める。
- どんでん返しはストーリー全体の流れと矛盾しないようにしつつ、プレイヤーに強い印象を与えるものにする。

## 4. 没入感と緊張感
- プレイヤーがストーリーに没入できるように、**詳細な描写**や**感情表現**を豊かにする。
- 会話と選択肢を活用して緊張感を高め、プレイヤーの参加感を向上させる。

## 5. Ren'Pyスクリプト形式
- 実際にRen'Pyエンジンで実行可能な**スクリプト形式**で作成する。
- **ラベル(label)**、**キャラクター定義(define)**、**ダイアログ(dialogue)**、**選択肢(menu)**などを適切に活用する。
- 背景画像、音楽、効果音などの**マルチメディア要素**も含め、リアリティのある演出を行う。

## 6. 分量と詳細さ
- ストーリーを**非常に長く作成**し、十分なボリュームを確保する。
- 各シーンや状況について詳細な説明や会話を盛り込む。
  • 生成されたRen'Pyスクリプト
define mc = Character("主人公")
define maya = Character("マヤ", color="#FF69B4")
define akira = Character("アキラ", color="#87CEFA")

label start:
    scene bg school_day
    with fade

    mc "新しい学校に転校してきた初日、僕の胸は期待と不安でいっぱいだった。"
    mc "クラスメイトたちは僕をちらりと見てすぐに会話に戻る。静かな教室にいるのが少し気まずい。"

    # マヤの登場
    show maya normal at left
    with fade

    maya "ねえ、新しい生徒さん?"
    mc "(クラスで一番目立つ子が話しかけてきた。彼女の名前はマヤ...かな?)"
    mc "あ、そうだよ。今日からよろしくお願いします。"
    maya "私はマヤ。なんでも聞いてね!"
    mc "ありがとう、助かるよ。"

    maya "ねえ、せっかくだし、放課後どこかに寄っていかない?"
    mc "(彼女の明るい笑顔に、断る理由なんて思いつかなかった。)"

    # シーン転換: 屋上
    scene bg rooftop_day
    with fade

    mc "放課後、マヤと一緒に学校の屋上に来た。"
    mc "眺めのいい場所で、風が気持ちいい。"

    maya "ここ、いいでしょ?私の秘密の場所なの。"
    mc "(彼女の声には少しの寂しさが混じっているように聞こえた。)"
    mc "いい場所だね。でも、どうして秘密なんだ?"
    maya "...いろいろあるのよ。"
    maya "でも、あなたになら少し話してもいいかな。"

    # 選択肢: マヤの秘密を聞くか否か
    menu:
        "彼女の秘密を聞く":
            jump hear_secret
        "深く聞かない":
            jump avoid_secret

label hear_secret:
    maya "実はね..."
    maya "私、昔から普通の人とは少し違うの。"
    maya "それが原因で、たくさんの人とすれ違ってきた。"

    mc "(彼女の表情は複雑だ...彼女が抱える孤独に、僕は何と言えばいいのか分からない。)"
    mc "違いがあっても、それは君の特別な部分じゃないかな?"
    maya "…ありがとう。そう言ってくれる人、初めてかも。"
    jump story_progress

label avoid_secret:
    mc "そんなに大切な場所なら、無理に話さなくてもいいよ。"
    maya "ふふ、ありがとう。でも、いつか話すかも。"
    jump story_progress

label story_progress:
    # 新キャラクター登場: アキラ
    scene bg classroom
    with fade

    show akira normal at right
    with moveinright

    mc "翌日、新しいクラスメイトと自己紹介を交わす中で、もう一人の存在が目に入った。"
    akira "君が昨日転校してきた人か。"
    mc "ああ、そうだよ。君は...?"
    akira "俺はアキラ。このクラスにいる限り、よろしくな。"

    mc "(アキラの声は冷たく、どこか挑戦的だ。)"
    mc "(彼とマヤは親しい関係のようだけど、何か隠しているような気がする。)"

    # 選択肢: アキラと距離を縮めるか
    menu:
        "アキラと友好的に接する":
            jump befriend_akira
        "アキラを警戒する":
            jump distrust_akira

label befriend_akira:
    mc "アキラ、よかったら放課後どこか行かないか?"
    akira "俺に?...まあ、時間があればな。"
    mc "(少しずつ彼の態度が柔らかくなっていくのを感じた。)"
    jump further_story

label distrust_akira:
    mc "(彼の態度はどうも信用できない。必要以上に関わらない方が良さそうだ。)"
    akira "(くすりと笑う)...まあ、これからだな。"
    jump further_story

label further_story:
    # 緊張感のある事件の発生
    scene bg park_night
    with fade

    mc "数日後、学校の帰り道、公園でマヤとアキラの口論に遭遇した。"
    mc "(彼らの言い争いは激しく、ただ事ではない。)"
    maya "あなたには関係ない!"
    akira "そう思うか?全部話すべきだと思うけどな。"

    mc "(彼らの秘密とは何なんだ?)"
    mc "一体何が起きているんだ!"

    maya "あなたにはまだ話せないわ。でも、真実を知る覚悟があるなら..."

    # 選択肢: 真実を受け入れるか
    menu:
        "真実を受け入れる":
            jump accept_truth
        "現状を維持する":
            jump remain_in_bliss

label accept_truth:
    scene bg digital
    with fade

    mc "目の前の風景が崩れていく。"
    mc "...僕が見ていたこの世界は何だったんだ?"
    mc "(すべてが仮想現実だったと知った時、僕は...。)"
    return

label remain_in_bliss:
    scene bg park_night
    with fade

    mc "僕はこの世界で生きていきたい。たとえそれが偽りのものであっても。"
    maya "ありがとう、あなたがいてくれて嬉しい。"
    mc "(それでも、この場所が僕の選んだ現実だ。)"
    return

キャラクター画像と背景画像の生成

  • 使用ツール

  • キャラクター画像生成用のプロンプト
Stable Diffusionでキャラクター画像を生成するためのプロンプトを作成してください。

1. 各キャラクターが一貫性のあるスタイルを持つようにプロンプトを作成する
2. スクリプトに記載された状態(例:通常、笑顔など)ごとに生成する
3. 英語で記述する
4. プロンプトのみを返す
キャラクター プロンプト 生成された画像 生成された画像(背景削除)
Maya A beautiful anime-style girl with long pink hair and bright, expressive eyes, wearing a school uniform with a blazer and skirt. She has a kind and approachable aura, standing in a classroom background. Her expression is neutral but slightly curious. Detailed and clean line art with vibrant colors. maya1.jpeg maya2.png
Akira A handsome anime-style boy with short, messy black hair and sharp, confident eyes, wearing a school uniform with a tie and blazer. He has a calm and reserved aura, standing in a classroom background. His expression is neutral but slightly thoughtful. Detailed and clean line art with bold contrast. akira1.jpeg akira2.png
  • 背景画像の生成
Stable Diffusionで背景画像を生成するためのプロンプトを作成してください。
背景 プロンプト 生成された画像
School Classroom (Daytime) A spacious and bright anime-style school classroom, with large windows letting in warm sunlight. Wooden desks and chairs are neatly arranged in rows, and a green chalkboard is on the front wall. The walls are light beige, and there are small decorations like a clock and posters. Clean line art and vibrant colors, with a cheerful and inviting atmosphere. school_classroom.png
School Rooftop (Daytime) An anime-style school rooftop with a clear blue sky and a few white clouds. The area is surrounded by a metal safety fence, and there are small details like a door leading back inside and a few potted plants in the corner. The sunlight creates soft shadows, and the scene feels peaceful and serene. Detailed and clean line art with vibrant colors. school_rooftop.png
City Park (Night) A serene anime-style park at night, with soft moonlight filtering through the trees. The area features a small path lined with benches and streetlights casting a warm, golden glow. The surrounding trees and bushes are dark green, and there is a faint mist in the air. The atmosphere is quiet and slightly mysterious, with clean and detailed line art. city_park.png
Digital World A futuristic anime-style digital world, with a glowing grid floor and vibrant, holographic projections of abstract shapes floating in the air. The scene is illuminated by neon lights in shades of blue, pink, and purple. The background fades into an infinite void, creating a sense of vastness. The atmosphere is surreal and immersive, with clean and intricate details. digital_world.png

デモプレイ動画

  • 「現実の欠片(Fragments of Reality)」

FragmentsofReality-ezgif.com-video-to-gif-converter.gif

生成AIをダイナミックに生成してみよう!

やりたいこと

  • ユーザーの選択をコンテキストで管理しながら、自動で次のストーリーと画像が生成されるようにしたい

実装済みのコード

init python:
    # 必要なライブラリのインポート
    import urllib.request
    import json
    import ssl
    import os
    import time
    import base64

    class StoryGenerator:
        """
        OpenAI APIを使用してストーリーと画像を生成するクラス
        """
        def __init__(self):
            """
            StoryGeneratorの初期化
            - コンテキスト履歴の初期化
            - API認証情報の設定
            - SSLコンテキストの設定
            """
            self.context = []
            self.API_KEY = "YOUR_API_KEY"
            self.headers = {
                "Authorization": f"Bearer {self.API_KEY}",
                "Content-Type": "application/json"
            }
            self.ssl_context = ssl.create_default_context()
            self.ssl_context.check_hostname = False
            self.ssl_context.verify_mode = ssl.CERT_NONE

        def generate_image(self, prompt):
            """
            DALL-E 3を使用して画像を生成する
            
            Args:
                prompt (str): 画像生成のためのプロンプト
                
            Returns:
                str: 生成された画像のファイルパス、またはエラーメッセージ
            """
            image_dir = os.path.join(renpy.config.gamedir, "images")

            # DALL-E 3のリクエストデータを設定
            data = {
                "model": "dall-e-3", 
                "prompt": f"ANIME STYLE, {prompt}",
                "n": 1,
                "size": "1024x1024",
                "quality": "hd"
            }

            try:
                # OpenAI APIに画像生成リクエストを送信
                req = urllib.request.Request(
                    "https://api.openai.com/v1/images/generations",
                    data=json.dumps(data).encode('utf-8'),
                    headers=self.headers,
                    method='POST'
                )

                with urllib.request.urlopen(req, context=self.ssl_context) as response:
                    result = json.loads(response.read().decode('utf-8'))

                    if 'data' in result and len(result['data']) > 0:
                        image_url = result['data'][0]['url']

                        # 生成された画像をローカルに保存
                        timestamp = int(time.time())
                        filename = f"generated_image_{timestamp}.png"
                        filepath = os.path.join(image_dir, filename)
                        return_filepath = os.path.join("images", filename)

                        image_req = urllib.request.Request(image_url)
                        with urllib.request.urlopen(image_req, context=self.ssl_context) as img_response:
                            with open(filepath, 'wb') as f:
                                f.write(img_response.read())
                        return return_filepath
            except Exception as e:
                error_msg = f"画像生成エラー: {str(e)}"
                return error_msg

        def generate_story(self, current_situation, player_choice):
            """
            GPT-4を使用してストーリーを生成する
            
            Args:
                current_situation (str): 現在のシーンの状況
                player_choice (str): プレイヤーが選択した選択肢
                
            Returns:
                tuple: (ストーリー情報の辞書, 生成された画像のパス)
            """
            # コンテキスト履歴を更新
            self.context.append({
                "situation": current_situation,
                "choice": player_choice
            })
            context_history = self.context[-3:]  # 最新3つのコンテキストのみ保持

            # GPT-4へのプロンプトを設定
            messages = [
                {
                    "role": "system",
                    "content": "あなたは魅力的なビジュアルノベルを作るストーリーテラーです。JSON形式で次の展開と選択肢を提供してください。"
                },
                {
                    "role": "user",
                    "content": f"""
                    前回の状況: {json.dumps(context_history, ensure_ascii=False)}
                    現在の状況: {current_situation}
                    プレイヤーの選択: {player_choice}

                    以下のJSON形式で応答してください:
                    {{
                        "narrative": "次のシーンの描写、200文字以内",
                        "choices": ["選択肢1", "選択肢2", "選択肢3"],
                        "prompt": "次のシーンに基づくDALL-E 3画像生成プロンプト、英語で記述"
                    }}
                    """
                }
            ]

            data = {
                "model": "gpt-4o",
                "messages": messages
            }

            # OpenAI APIにリクエストを送信
            req = urllib.request.Request(
                "https://api.openai.com/v1/chat/completions",
                data=json.dumps(data).encode('utf-8'),
                headers=self.headers,
                method='POST'
            )

            try:
                with urllib.request.urlopen(req, context=self.ssl_context) as response:
                    api_response = json.loads(response.read().decode('utf-8'))
                    content = api_response["choices"][0]["message"]["content"]
                    result = self._parse_response(content)
                    image = self.generate_image(result["prompt"])
                    return result, image
            except Exception as e:
                return {
                    "narrative": f"ストーリーの取得に失敗しました: {str(e)}",
                    "choices": ["再試行"],
                }, None

        def _parse_response(self, response):
            """
            APIレスポンスからJSONデータを抽出して解析する
            
            Args:
                response (str): APIからのレスポンス文字列
                
            Returns:
                dict: パースされたJSONデータ、またはエラー情報
            """
            try:
                start = response.find('{')
                end = response.rfind('}') + 1
                json_str = response[start:end]
                return json.loads(json_str)
            except Exception as e:
                return {
                    "narrative": f"申し訳ありません。一時的なエラーが発生しました: {str(e)}",
                    "choices": ["再試行"],
                }, None

    # StoryGeneratorのインスタンスを作成
    story_generator = StoryGenerator()

# ナレーターキャラクターを定義
define narrator = Character('ナレーター', color="#c8ffc8")

# ゲーム開始
label start:
    # 初期状態の設定
    $ current_situation = "あなたは不思議な冒険を始めようとしています。"
    $ player_choice = "冒険を始める"

    # 最初の選択肢を表示
    menu:
        narrator "[current_situation]"

        "[player_choice]":
            pass

    # メインゲームループ
    while True:
        # ストーリーと画像を生成
        $ response, image = story_generator.generate_story(current_situation, player_choice)

        # シーンの更新
        scene black
        if image:
            show expression "[image]" at center

        # ナレーションの表示
        narrator "[response['narrative']]"

        # 選択肢を事前に変数に保存
        $ choices = response['choices']

        # プレイヤーの選択肢を表示
        menu:
            "次の行動を選択してください"

            "[choices[0]]":
                $ player_choice = choices[0]
                $ current_situation = response['narrative']

            "[choices[1]]":
                $ player_choice = choices[1]
                $ current_situation = response['narrative']

            "[choices[2]]":
                $ player_choice = choices[2]
                $ current_situation = response['narrative']

デモプレイ動画

DynamicStoryGenerator-ezgif.com-video-to-gif-converter.gif

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?