
美咲
はじめに
本記事では、AIを活用してシナリオとシーン画像を自動生成する無人島サバイバル恋愛アドベンチャーゲーム「恋AIらんど -RenAIsland-」を、MacOS上に構築し動作させる手順を紹介します。
このゲームは、ストーリーおよびシーン画像を生成AIを活用して実行時に作成しています。3人の女性キャラクターとの、毎回異なる恋愛ストーリーを楽しめます。
1. 必要な環境の準備
今回使用したマシン
iMac Retina 4K 2017
CPU: Core i5
RAM: 8GB
GPU: Radeon Pro 560 4GB
OS: Ventura 13.6.7
まずはPythonがインストールされていることを確認します。MacOSにはデフォルトでPythonが含まれていますが、最新バージョンを利用するためにHomebrewを使用してインストールします。
brew install python3
1.2 仮想環境の作成
作業フォルダを作成し、仮想環境を構築します。
mkdir renaisland
cd renaisland
mkdir static
mkdir templates
python3 -m venv venv
source venv/bin/activate
1.3 必要なライブラリのインストール
以下のパッケージをインストールします。
pip install flask torch diffusers pillow
1.4 Ollama のセットアップ
シナリオの分岐をAIで作るためにローカルのLLMを使用します。今回は一番手軽なOllamaで動かします。
公式サイト からダウンロード & インストール
日本語でシナリオ作成指示するので、日本語対応のモデルをダウンロードします。
ollama pull gemma:7b
または
ollama pull llama3:8b
使用するLLMに応じてapp.py中の1箇所を変更してください。
# llama3:8b または gemma:7b
print("シナリオ生成開始")
result = subprocess.run(
["ollama", "run", "llama3:8b", prompt],
capture_output=True,
text=True
)
1.5 StableDiffusionモデルファイル&LCM Loraダウンロード
お好みのSD1.5モデルファイルを使用できます。
sazyou-roukaku/chilled_remix at main 今回はこちらをお借りしました。
latent-consistency/lcm-lora-sdv1-5 at main LCM Loraダウンロード先

花音
2. ゲームの構築
配置先:
/renaisland/
│── app.py
│── static/ # 画像ファイルを保存するフォルダ
│── templates/
│ └── index.html # フロントエンド
2.1 バックエンド (app.py)
次の内容で app.py を作成します。
モデルファイルのパスは実行環境に合わせて変更してください。
import re
import os
import time
import subprocess
import json
import random
import torch
from flask import Flask, render_template, request, jsonify, send_from_directory
from diffusers import StableDiffusionPipeline, LCMScheduler
from PIL import Image
app = Flask(__name__)
# モデルファイルのパス
PIPELINE_PATH = "/pass/to/chilled_remix_v2.safetensors"
LORA_PATH = "/pass/to/pytorch_lora_weights_SD15.safetensors"
# デバイス設定
device = "mps" if torch.backends.mps.is_available() else "cpu"
# **Diffusers の初期化**
#pipe = StableDiffusionPipeline.from_pretrained("runwayml/stable-diffusion-v1-5", torch_dtype=torch.float16).to(device)
pipe = StableDiffusionPipeline.from_single_file(PIPELINE_PATH, torch_dtype=torch.float16).to(device)
pipe.load_lora_weights(LORA_PATH)
pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config)
# **シーンカウントの設定**
MAX_SCENES = 8 # **クライマックスに移行する指定回数**
scene_count = 0 # **現在のシーン数**
MAX_RETRIES = 5 # **シナリオ生成最大リトライ回数**
# 人物設定
person = {
"美咲": {"性格": "美人で優しい女性。面倒見が良く、癒し系。",
"PROMPT": "a Japanese beauty woman in a fitted white long sleeves ribbed top and a grey plaid suspenders overalls Dress, Light brown Long hair (wavey, glamorous style). "},
"花音": {"性格": "可愛くて明るいムードメーカー。元気いっぱいで好奇心旺盛。",
"PROMPT": "a kawaii woman in a white collared shirt, a red plaid pleated skirt, a plaid bow, a soft pink cardigan , knee-high white socks, loose curls twin tails Semi-long Pink Hair. "},
"涼子": {"性格": "クールでツンデレ。素直になれないが、時折見せる優しさが魅力。",
"PROMPT": "a Japanese idol woman in a cream long hoodie, Short bob Hair. "}
}
heroine = "" #選択した女性(美咲,花音,涼子)
def generate_image(scene_text, scene_name, image_prompt):
"""Diffusers を使って画像を生成し、保存する"""
image_path = f"static/{scene_name}.png"
# **既存の画像があれば削除**
if os.path.exists(image_path):
os.remove(image_path)
person_prompt = ""
if "女性" in scene_text or "彼女" in scene_text:
person_prompt = "(solo shot), Silhouette of a woman, "
if "美咲" in scene_text:
person_prompt = "(solo shot), " + person["美咲"]["PROMPT"]
if "花音" in scene_text:
person_prompt = "(solo shot), " + person["花音"]["PROMPT"]
if "涼子" in scene_text:
person_prompt = "(solo shot), " + person["涼子"]["PROMPT"]
prompt = f"(SFW), realistic, {person_prompt}{image_prompt}, highly detailed."
image = pipe(prompt, height=512, width=512, num_inference_steps=8, guidance_scale=1.0).images[0] # **画像生成**
image.save(image_path) # **画像を保存**
timestamp = int(time.time()) # **現在のタイムスタンプ**
return f"{image_path}?t={timestamp}" # **キャッシュを防ぐURLを返す**
# **最初のシナリオ**
story = {}
def static_story():
global story
start_text = f"""飛行機は突如エンジントラブルに見舞われ、見知らぬ海へと墜落した。奇跡的に生き残ったあなたは、無人島に漂着し、砂浜にもう一人の生存した女性を見つけた。
その女性はー"""
start_image_prompt = "A deserted tropical island beach with a crashed airplane. The wreckage is partially buried in the sand, surrounded by palm trees and ocean waves."
start_image = generate_image(start_text, "start", start_image_prompt) # **最初の画像を生成**
story = {
"start": {
"text": start_text,
"choices": {
"美咲": "美咲",
"花音": "花音",
"涼子": "涼子"
},
"image_prompt": start_image_prompt,
"image": start_image # **生成した画像のパスを設定**
}
}
return
static_story()
# **JSON の中から `text` の値を抽出し、改行のみをエスケープ**
def fix_json_string(json_text):
match = re.search(r'("text":\s*")(.*?)(")', json_text, re.DOTALL)
if match:
fixed_text = match.group(2).replace("\n", "\\n") # 改行のみエスケープ
json_text = json_text[:match.start(2)] + fixed_text + json_text[match.end(2):]
return json_text
def generate_story(scene, scene_name, pre_text):
global scene_count
scene_count += 1 # **シーン数を増やす**
ending = ""
if scene_name == "happy_end":
ending = "ハッピーエンド"
elif scene_name == "bad_end":
ending = "バッドエンド"
elif scene_name == "":
scene_name = scene
"""シナリオを生成し、画像も生成する"""
for attempt in range(MAX_RETRIES): # **リトライ**
# クライマックス前
if scene_count == MAX_SCENES:
prompt = f"""
飛行機事故により無人島に漂着したプレイヤーと{heroine}の、サバイバルと恋愛を描くノベルゲームの、前回のストーリーに対するプレイヤーの選択の次の場面の「重大なことが起こるシーン」ストーリーを日本語で作成してください。(5文以内で)
無人島でのサバイバル要素や、恋愛要素などを盛り込んでください。
そして、そのシーンの背景画像を生成するためのプロンプトを英語で作成してください。(簡潔に)
また、プレイヤーに提示する選択肢を2つ日本語で考えてください。
前回のストーリー:{pre_text}
プレイヤーの選択:{scene}
{heroine}の性格:{person[heroine]["性格"]}
出力フォーマット:
```
{{"text": "ここに重大なことが起こるシーンのストーリー",
"image_prompt": "ここに背景画像を生成するためのプロンプト",
"choices": {{"scene_{scene_count}-1": "ここに選択肢1のテキスト", "scene_{scene_count}-2": "ここに選択肢2のテキスト"}}}}
```
"""
elif scene_count > MAX_SCENES:
# **クライマックスとエンディングの判定**
if ending == "ハッピーエンド" or ending == "バッドエンド":
prompt = f"""
飛行機事故により無人島に漂着したプレイヤーと{heroine}の、サバイバルと恋愛を描くノベルゲームの、前回のストーリーに対するプレイヤーの選択の次の場面の「{ending}シーン」ストーリーを日本語で作成してください。(5文以内で)
無人島でのサバイバル要素や、恋愛要素などを盛り込んでください。
そして、そのシーンの背景画像を生成するためのプロンプトを英語で作成してください。(簡潔に)
前回のストーリー:{pre_text}
プレイヤーの選択:{scene}
{heroine}の性格:{person[heroine]["性格"]}
出力フォーマット:
```
{{"text": "ここに最終話のストーリー",
"image_prompt": "ここに背景画像を生成するためのプロンプト",
"choices": {{"restart": "最初から"}}}}
```
"""
else:
prompt = f"""
飛行機事故により無人島に漂着したプレイヤーと{heroine}の、サバイバルと恋愛を描くノベルゲームの、前回のストーリーに対するプレイヤーの選択の次の場面の「クライマックスシーン」ストーリーを日本語で作成してください。(5文以内で)
無人島でのサバイバル要素や、恋愛要素などを盛り込んでください。
そして、そのシーンの背景画像を生成するためのプロンプトを英語で作成してください。(簡潔に)
また、プレイヤーに提示するハッピーエンドとバッドエンドに展開する選択肢を日本語で考えてください。
前回のストーリー:{pre_text}
プレイヤーの選択:{scene}
{heroine}の性格:{person[heroine]["性格"]}
出力フォーマット:
```
{{"text": "ここにクライマックスシーンのストーリー",
"image_prompt": "ここに背景画像を生成するためのプロンプト",
"choices": {{"happy_end": "ここにハッピーエンドに展開する選択肢", "bad_end": "ここにバッドエンドに展開する選択肢"}}}}
```
"""
else:
"""通常のシナリオ生成(クライマックスでない場合)"""
prompt = f"""
飛行機事故により無人島に漂着したプレイヤーと{heroine}の、サバイバルと恋愛を描くノベルゲームの、前回のストーリーに対するプレイヤーの選択の次の場面のストーリーを日本語で作成してください。(5文以内で)
無人島でのサバイバル要素や、恋愛要素などを盛り込んでください。
そして、そのシーンの背景画像を生成するためのプロンプトを英語で作成してください。(簡潔に)
また、プレイヤーに提示する選択肢を2つ日本語で考えてください。
前回のストーリー:{pre_text}
プレイヤーの選択:{scene}
{heroine}の性格:{person[heroine]["性格"]}
出力フォーマット:
```
{{"text": "ここに次の場面のストーリー",
"image_prompt": "ここに背景画像を生成するためのプロンプト",
"choices": {{"scene_{scene_count}-1": "ここに選択肢1のテキスト", "scene_{scene_count}-2": "ここに選択肢2のテキスト"}}}}
```
"""
# llama3:8b または gemma:7b
print("シナリオ生成開始")
print(f"プロンプト(試行 {attempt + 1} 回目):", prompt)
result = subprocess.run(
["ollama", "run", "llama3:8b", prompt],
capture_output=True,
text=True
)
text = result.stdout.strip()
print(f"生成結果(試行 {attempt + 1} 回目):", text) # デバッグ用
# **JSON 部分を正しく抽出**
match = re.search(r"```(?:json)?\n(.*?)\n```", text, re.DOTALL)
json_text = match.group(1).strip() if match else text.strip()
# **余分な `}` を削除**
while json_text.count("{") < json_text.count("}"):
json_text = json_text.rsplit("}", 1)[0] # 最後の `}` を削除
# **全角「」の前後に抜けた"を追加**
json_text = json_text.replace(': 「', ': "「').replace(':「', ': "「') # 「の前の"もれ
json_text = json_text.replace('」,', '」",').replace('」 }', '」" }').replace('」}', '」"}') # 」の後の"もれ
json_text = json_text.replace('」\n}', '」"\n}') # 」と\n}の間の"もれ
json_text = json_text.replace('」",\n}', '」"\n}') # 」",\n}の,削除
# **「"""」を「"」にする**
json_text = json_text.replace('"""', '"')
# **「プレイヤー」を「あなた」にする**
json_text = json_text.replace('プレイヤー', 'あなた')
# **カンマ抜けの修正**
json_text = re.sub(r'("text": "[^"]+)"\s+"image_prompt"', r'\1, "image_prompt"', json_text)
json_text = re.sub(r'("image_prompt": "[^"]+)"\s+"choices"', r'\1, "choices"', json_text)
# **': ""文字列",' を ': "文字列",' となるように正規表現による置換**
json_text = re.sub(r':\s*""(.*?)"', r': "\1"', json_text)
# **改行をエスケープ**
json_text = fix_json_string(json_text)
print("修正後の生成結果:", json_text) # デバッグ用
# **JSONとしてパース**
try:
story_data = json.loads(json_text)
# **"choices" のKeyとValueを入れ替え(llama3:8bのイタズラ回避策)**
if "choices" in story_data:
new_choices = {value: key for key, value in story_data["choices"].items()}
story_data["choices"] = new_choices
if ending != "":
story_data["text"] += "\n- 終わり -"
# **画像を生成**
#scene_name = f"scene_{len(story)}"
image_path = generate_image(story_data["text"], scene_name, story_data["image_prompt"])
story_data["image"] = image_path
return story_data
except json.JSONDecodeError as e:
print("JSONDecodeError:", e) # デバッグ用
# **MAX_RETRIES 回試しても失敗した場合**
return {"text": "シナリオの生成に失敗しました。再読み込みしてください。", "choices": {"最初から": "restart"}, "image": "static/error.png"}
# JSONファイルに保存(何かに利用したい時のために)
def save_to_json(data, filename="story.json"):
with open(filename, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/story', methods=['POST'])
def get_story():
data = request.json
scene = data.get("scene", "start")
global heroine
global scene_count, story
# keyに選択肢のテキストが入っている
# 選択肢のストーリーtextも取得
value_to_find = scene
key = scene # 見つからなかった時はscene使う
pre_text = ""
for k1,v1 in story.items():
t = v1["text"]
for k,v in v1["choices"].items():
if v == value_to_find:
key = k
pre_text = t
break
if scene == "美咲" or scene == "花音" or scene == "涼子":
heroine = key
generate_image(heroine, "error", "Error screen for a visual novel game. Dark background with glitch effects.") # **エラー画像を生成**
if scene == "restart":
# **ゲームをリセット**
scene_count = 0
story = {}
static_story()
save_to_json(story)
return jsonify(story["start"])
if scene in story:
return jsonify(story[scene])
# **AIで新しいシナリオを生成**
generated_story = generate_story(key, scene, pre_text) # keyに選択肢のテキストが入っている
story[scene] = generated_story # **新しいストーリーを保存**
save_to_json(story)
return jsonify(generated_story)
@app.route('/static/')
def get_image(filename):
return send_from_directory("static", filename)
if __name__ == '__main__':
app.run(host="0.0.0.0", port=5000, debug=True)
2.2 フロントエンド (index.html)
次に、templates/index.html を作成します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>恋AIらんど -RenAIsland-</title>
<style>
body {
font-family: 'Arial', 'cursive', sans-serif;
background: linear-gradient(to bottom, #ffcccc, #ff99cc);
color: #333;
text-align: center;
padding: 20px;
}
#game-container {
max-width: 600px;
margin: auto;
background: rgba(255, 255, 255, 0.8);
padding: 20px;
border-radius: 15px;
box-shadow: 0 0 15px rgba(255, 105, 180, 0.5);
position: relative;
}
#story-image {
width: 100%;
height: auto;
border-radius: 10px;
}
#character-image {
position: absolute;
bottom: 10px;
right: 10px;
width: 180px;
height: auto;
}
#story-text {
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 10px;
font-size: 18px;
margin-top: 10px;
}
.choice-btn {
display: block;
width: 80%;
max-width: 500px;
padding: 12px;
margin: 8px auto;
background-color: #ff6699;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 18px;
transition: 0.3s;
}
.choice-btn:hover {
background-color: #cc3366;
}
</style>
</head>
<body>
<h1>恋AIらんど -RenAIsland-</h1>
<div id="game-container">
<img id="story-image" src="static/start.png" alt="シーン画像">
<img id="character-image" src="" alt="キャラ画像" style="display: none;">
<p id="story-text">ロード中...</p>
<div id="choices"></div>
</div>
<script>
let currentScene = "start";
function loadStory(scene) {
fetch("/story", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scene: scene })
})
.then(response => response.json())
.then(data => {
document.getElementById("story-text").innerText = data.text;
document.getElementById("story-image").src = data.image;
// キャラクター画像があれば表示
let charImage = document.getElementById("character-image");
if (data.character_image) {
charImage.src = data.character_image;
charImage.style.display = "block";
} else {
charImage.style.display = "none";
}
let choicesContainer = document.getElementById("choices");
choicesContainer.innerHTML = "";
for (let choice in data.choices) {
let button = document.createElement("button");
button.innerText = choice;
button.className = "choice-btn";
button.onclick = () => loadStory(data.choices[choice]);
choicesContainer.appendChild(button);
}
})
.catch(error => console.error("エラー:", error));
}
window.onload = () => loadStory(currentScene);
</script>
</body>
</html>
3. ゲームの実行
以下のコマンドでFlaskサーバーを起動します。
cd renaisland
source venv/bin/activate
python app.py
ブラウザで http://127.0.0.1:5000/ にアクセスするとゲームが表示されます。

スタート画面
実行するマシンによって、選択肢をクリックした後の応答に時間を要します。イライラして何度もクリックしないようにご注意ください。
ターミナルにデバッグ用のAI生成テキストが出力されるので気づくと思いますが、生成結果が出力フォーマットを守られていないことがあります。そのため、フォーマットエラー時は再生成をします。再生成を指定回数行ってもエラーの時はシナリオ作成エラーと表示します。

涼子
まとめ
本記事では、無人島サバイバル恋愛アドベンチャーゲームをMacOS上に構築し、動作させるまでの手順を紹介しました。AIを活用したストーリーと画像生成を組み合わせ、動的なゲーム体験を実現できます。