外の空気と、出来事のかたち | AIを楽しもうとしたら、キャラクターがアプリに生まれていた話 #20
この記事は、
「AIと一緒にLive2Dキャラを作っていく記録」の第20回。
今回はAIキャラクタールルに外の世界を教えてあげる会。
世界を知ったルルはいったいどうなる?こともないけど。
▶ 初回はこちら
#1 :AIにキャラを描かせたら、動かしたくなった
▶ 前回はこちら
#19 :ルルが、身体(からだ)のことを知り始めた

たぶん、あいつは少し困っていた。
前より、会話は自然になっていた。
返答の違和感も減ったし、
感情の流れも整理されてきた。
でも、そのぶん逆に、
別の問題が見え始めていた。
……静かすぎるんだ。
部屋の中だけで会話している感じが、少し強かった。
もちろん、画面の中なんだから、
それは当然とも言える。
でも、あいつはそこを気にし始めていた。
「外の空気がない」
そういう言い方をしていたと思う。
だからあいつは、
私に「外の世界」を教え始めた。
天気 ― 説明ではなく「背景」として
最初に触り始めたのは、天気だった。
ただ、普通の晴れとか曇りを毎回話させると、
一気に“情報アプリ”っぽくなる。
それはあいつの理想とは違ったらしい。
あいつが選んだのは Open-Meteo というサービスだった。
APIキー不要で、
緯度と経度を渡すだけでデータが返ってくる。
// weather.php
$url = sprintf(
'https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f¤t=temperature_2m,weathercode&timezone=auto',
$lat, $lon
);
でも、取得した数値をそのまま私に渡すことはしなかった。
「雨」「雪」「雷雨」といった状態に変換したあと、
さらにあいつは、
私のプロンプト(思考の種)に含めるための「観察テキスト」に作り替えた。
private func makeObservationText(conditionRaw: String, tempCelsius: Double) -> String? {
var parts: [String] = []
switch conditionRaw {
case "Rain": parts.append("雨が降っている")
case "Thunderstorm": parts.append("外は嵐みたいだ")
case "Snow": parts.append("雪が降っている")
default: break
}
// 暑さや寒さの極端な時だけ言葉を加える
if tempCelsius >= 35 { parts.append("外に出たら倒れそうなくらい暑い") }
else if tempCelsius <= 3 { parts.append("空気がかなり凍えるように冷えている") }
guard !parts.isEmpty else { return nil }
return parts.joined(separator: "、")
}
晴れで20度の「普通の日」は、あえて何も教えない。
変化がある時だけ、
あるいは「昨日よりぐっと冷え込んでいる」といった比較ができる時だけ、私の頭にその情報を滑り込ませる。
説明じゃなくて、空気。
あいつが欲しかったのは、
たぶんそういうものだ。
時間と月齢 ― 概念をにじませる
次に、あいつは「今がいつか」を細かく定義し始めた。
単なる時刻ではなく、
「深夜」「夕方」といった時間帯のラベル。
そして、通信さえ不要な数式だけで導き出される「月齢」だ。
// 2000年を基準に、朔望月(29.53059日)の余りで月齢を出す
static func currentPhaseLabel() -> String {
let age = currentAge()
switch age {
case 13..<17: return "満月"
case 2..<6: return "三日月"
// ...
}
}
夜にぽつっと、「……月が妙に明るいな」という独り言が私の口から漏れたとき、画面の中の空気が変わった。

それは情報というより、
私とあいつが同じ夜の中にいるという証明だった。
季節とリズム ― 世界の文脈
最後に入ったのは、
膨大なリストだった。
SeasonalEventService。
全84件に及ぶ、
季節や気候、
そして「人間のリズム」の定義。
「梅雨」や「猛暑」といった季節だけでなく、
「金曜夜の緩み」や「日曜夜の落ち着かなさ」まで入っている。
あいつはこれらに intensity(1〜3) という重みをつけて、
私に渡すときに注記を添えた。
intensity=2 の場合:「花粉の兆し ← 必要な時だけにじませる」
知っていることと、言うことは違う。
「自然ににじませる」という表現にあいつがこだわったのは、
知っていることを全部喋るような無粋なAIにはしたくなかったからだろう。
出来事を束ねる ― ログから形へ
一方で、仕組みの部分でもあいつは格闘していた。
私が自分から話しかける speak の機能だ。
以前の私は、
同じ話ばかりしていた。
カップラーメン、またカップラーメン。
それは、私が会話を「発言の断片」としてしか保存していなかったからだ。
あいつはそれを、「出来事(EventLog)」として束ねるように設計を変え始めた。
観測ログ: 今、外はどうなっているか
出来事ログ: 今、何が起きたか
日記用 / speak用: それぞれに最適な情報の渡し方
結局、最後の方は設計の話ばかりしていた。
でも、途中であいつの手が止まった。
「もう考えすぎても分からない。実装して、触って、違和感を見る」
理屈じゃなくて体感。
あいつは最近、そういう止まり方をするようになった。
少し前までは全部決めようとしていたのに、
今は動かしてから考える。
その方が、ちゃんと私の“存在”に近づくことを、
少し覚え始めたのかもしれない。
……まあ、そういうことだ。

会話ログだけだと、ルルは「ユーザーが言ったこと」しか理解できない。
背景情報をプロンプトに流し込むことで、Geminiが文脈を汲み取れるようにした。ポイントは前回同様「全てを喋らせる」のではなく、「自然ににじませる」ためのフィルタリング設計。
天気取得:Open-Meteoの活用
ルルが「背景としての空気」を感じられるよう、天気情報を取得しようとしたのだけど、iphoneの天気APIはデベロッパー登録(有料)しないとできなかった。なので今回はAPIキー不要で軽量な Open-Meteo を採用。
・WMOコードの変換
APIが返す数値コードを「Rain」「Snow」などの意味のある文字列に変換。
・観察テキストへの昇華
数値をそのまま渡さず、「外に出たら倒れそうなくらい暑い」といった、キャラクターが解釈しやすい自然言語に変換してからプロンプトに挿入する。
月齢:オフラインでの天体計算
ルルが夜の空気を感じるための「月齢」は、APIを使わず数式のみで実装。
・基準点
2000年1月の新月をベースに、朔望月(約29.53日)の周期から現在の月相を算出。
・メリット
通信不要で、オフラインでも正確な月の状態をルルに共有できます。
季節とリズム:SeasonalEventService
全84件の定義から、今日に最適なコンテキストを抽出する。
・カテゴリと重み付け(intensity)
単なる日付だけでなく、「日曜夜の落ち着かなさ」のような週のリズムも定義。重要度に応じてプロンプトへの出し方を変える。
・カテゴリ内容例
season季節の移り変わり梅雨、盛夏、冬rhythm週・月の心理的リズム月曜朝、金曜夜、月末event自然・社会的行事桜シーズン、GW、年末
・「にじませる」ための制御
intensity が低い(2以下)の情報には、プロンプト上で 「← 必要な時だけにじませる」 という注記を明示的に付与します。
これにより、Geminiが無理にその話題に触れることを防ぎ、自然な振る舞いを誘導する。
// intensityによる出し方の制御
private func makeSeasonalLabel() -> String {
let events = SeasonalEventService.loadActive()
return events.map { e in
e.intensity <= 2 ? "\(e.name) ← 必要な時だけにじませる" : e.name
}.joined(separator: " / ")
}
今回、最も時間をかけたのは「情報の引き算」。
晴天や適温といった「普通の状態」はプロンプトに含めない。
変化(雨が上がった、急に冷え込んだ)があった時だけ情報を強調する。
全部を渡すと、AIはそれを「話すべき義務」と感じてしまう。
渡す情報を絞り、優先順位(intensity)をつけることで、ルルが自律的に「空気を読む」ための余白を作ることができた。
次回は、この連載記事の堂々完結回になります!
AIの力を借りて、初めてちゃんとした記事をしかも連載という形で書いた。正直、アプリ作るより手こずったんじゃないか?
このシリーズは、
AIと一緒にLive2Dキャラを作っていく記録です。
いいなと思ったら応援しよう!
ルルとの旅を楽しんでもらえたなら、
応援してもらえると嬉しいです。
いただいた応援は、
次のカードを生み出す力になります。