公式ドキュメントからダウンロードできる CoffeeShop ゲームのサンプルコードを読み解いて、自分でも実際に作ってどの程度正確に動作するかも確認してみたいと思います。最終的にはそれを使って類似するゲームをリリースするところまで実践していければと思います。
独学・初心者のため、コードの見解など間違っている箇所があれば勉強になりますのでコメントをいただけると幸いです。
前回までの内容↓
その都度ファイルごとに勉強し、まとめていきます。
GenerableImage.swift ファイル
このファイルは AI 生成の NPC キャラクターのプロフィール表示時用に、画像生成システムを使って NPC キャラクター固有のプロフィール画像を生成する。
まずはクラス定義と制約 ↓
@MainActor
@Observable
final class GenerableImage: Generable, Equatable {
// nonisolatedプロパティ(どこからでもアクセス可能)
nonisolated static let fallbackDescription: String = "かわいいセーターを着た愛らしい猫。"
// nonisolatedメソッド(どこからでも実行可能)
nonisolated static func == (lhs: GenerableImage, rhs: GenerableImage) -> Bool {
lhs === rhs // 参照等価性でのみ比較(同じ説明文でも、生成される画像は毎回異なる可能性があるため)
}
@MainActor を使ってUI更新の安全性確保。
これは、生成後にUIへの変更が走るので安全のためこの時点てはメインスレッドでの処理を行うようにしておく。他のファイル全体を見るに呼び出し時には Task でバックグランド処理にさせてユーザー体験を確保していてるので、万が一の場合にアプリのクラッシュが起こらないようにするための保険的な意味合いもあるのかな?
さらに、final で継承を禁止して最適化しつつ、 Equatable を使って変数の一つが更新する度にすべてのUIに更新が走るような冗長な更新を防ぐようにしている(大量にメモリを確保させない対策)。
nonisolated static let fallbackDescription: String = "かわいいセーターを着た愛らしい猫。"
nonisolated static func == (lhs: GenerableImage, rhs: GenerableImage) -> Bool {
lhs === rhs // 参照等価性でのみ比較(同じ説明文でも、生成される画像は毎回異なる可能性があるため)
}
↑ この変数については nonisolated で他のファイルとの共有リソース化と非同期処理からの切り離し、そして呼び出し時に await の省略を可能しつつ高速化を考えた設計にしている。
// nonisolatedメソッド(どこからでも実行可能)
nonisolated static func == (lhs: GenerableImage, rhs: GenerableImage) -> Bool {
lhs === rhs
↑ 参照等価性での比較については、AI生成の画像については同じ説明文でも生成される画像は毎回異なる可能性があるということを前提に設計。
例えば (A: "こんにちは") と (B: "こんにちは") のように String 型の文字であれば値の比較で同一であることは簡単に比較できるし文字であれば変更の必要もないが、 (A: 青い目の猫の画像) と (B: 赤い目の猫の画像) のような Image の場合、微妙に違う画像なのにインスタンスが同じ説明文("猫の画像")となりこの場合には誤った判断で ture になる。それではデフォルトである "かわいいセーターを着た愛らしい猫。" の場合に同じ猫の画像であれば全て ture になってしまいすべての NPC が同じ画像になってしまう可能性とあわせ予期せぬ Error が発生する危険があるため、「説明文同じ」ではなく「物理的に同じ物」かどうかで判定させています。
同じ画像の使い回しではユーザーからしたらつまらないゲームになってしまうので、デフォルトの プロンプト でも NPC ごとに違う画像を表示できるようにしているといったところかな。
AI生成の際の制約とスタイルについて↓
@Guide(description: "人間のような描写は避け、動物、植物、または物体に限定してください。")
let imageDescription: String
let imageStyle: ImagePlaygroundStyle = .sketch
今回は、人間に似た外見を禁止、動物 / 植物 /物体 にスタイルを限定。
表現はスケッチスタイルで統一させています。
次に生成状態を管理するための変数を用意↓
var attemptCount: Int = 0
var isResponding: Bool { task != nil } // 生成中かどうか
private(set) var image: CGImage? // 生成された画像
private var task: Task<Void, Error>? // 非同期生成タスク
↑ これには nonisolated はつけておらず、メインスレッドでの処理を前提としている行うようにして他のファイルからの呼び出しをしないようにしている。誤ってメインスレッドで二重の処理が走らないようにするための対策かな。
Generable プロトコル実装 ↓
nonisolated static var generationSchema: GenerationSchema {
GenerationSchema(
type: GenerableImage.self,
description: """
画像生成モデルに渡す画像の説明。\
説明は短く、人間らしくないものにしてください。
""",
properties: [
GenerationSchema.Property(
name: "imageDescription",
type: String.self
)
]
)
}
nonisolated var generatedContent: GeneratedContent {
GeneratedContent(properties: [
"imageDescription": imageDescription
])
}
↑ これらの関数はどちらも nonisolated をつけて別ファイルから必要なタイミングで呼び出せるようにしている。
初期化と画像生成の開始 ↓
nonisolated var generatedContent: GeneratedContent {
GeneratedContent(properties: [
"imageDescription": imageDescription
])
}
AI生成された description を取得し非同期で画像生成を開始する。作成中もUIがフリーズしてユーザー体験に影響が出ないようにする処理。
ImagePlayground との連携( Apple Intelligence の画像生成機能) ↓
private func generableImage(UserDefault: Bool = false) throws {
task?.cancel() // 既存タスクをキャンセル
task = Task {
do {
// 開始前にキャンセルされたタスクを確認する。
if Task.isCancelled {
return
}
let generator = try await ImageCreator()
let prompt = UserDefault ? GenerableImage.fallbackDescription: imageDescription
let generations = generator.images(
for: [.text(prompt)],
style: imageStyle,
limit: 1
)
// 生成前にキャンセルされたタスクを確認する。
if Task.isCancelled {
return
}
for try await generation in generations {
self.image = generation.cgImage
self.task = nil
return
}
// エラーハンドリング
} catch let error {
self.task = nil
Logging.general.log("mage generation failed for prompt: \(self.imageDescription). Error: \(error)")
// フォールバック画像生成
attemptCount += 1
if attemptCount < 2 {
Logging.general.log("デフォルトのフォールバック: 猫画像を生成しています。")
try generableImage(UserDefault: true) // 猫の画像で再試行
}
throw error
}
}
}
画像生成プロセスとしては、ImageCreator ( ImagePlayground の生成エンジン ) を使って、テキストプロンプトを画像に変換。スケッチスタイルで生成1枚のみ画像を生成させる。この画像は NPC キャラクター のプロフィール画像表示時に使用する。
詳しくはこちら↓
エラーハンドリングとフォールバック ↓
} catch let error {
self.task = nil
Logging.general.log("mage generation failed for prompt: \(self.imageDescription). Error: \(error)")
// フォールバック画像生成
attemptCount += 1
if attemptCount < 2 {
Logging.general.log("デフォルトのフォールバック: 猫画像を生成しています。")
try generableImage(UserDefault: true) // 猫の画像で再試行
}
throw error
}
生成失敗時のフォールバックとしては猫の画像で代替を準備しつつ、最大2回までは試行するようにさせている。回数を設けることでユーザー体験に支障が出ない範囲で生成を試みる仕組みといったとこでしょうか?以下にも Apple らしい注意点といった感じ。
コード全体はこちら ↓
import FoundationModels
import ImagePlayground
import SwiftUI
import os
// Foundation Models と Image Playground を連携したAI画像生成システム
@MainActor
@Observable
final class GenerableImage: Generable, Equatable {
// nonisolatedプロパティ(どこからでもアクセス可能)
nonisolated static let fallbackDescription: String = "かわいいセーターを着た愛らしい猫。"
// nonisolatedメソッド(どこからでも実行可能)
nonisolated static func == (lhs: GenerableImage, rhs: GenerableImage) -> Bool {
lhs === rhs // 参照等価性でのみ比較(同じ説明文でも、生成される画像は毎回異なる可能性があるため)
}
@Guide(description: "人間のような描写は避け、動物、植物、または物体に限定してください。")
let imageDescription: String
let imageStyle: ImagePlaygroundStyle = .sketch
var attemptCount: Int = 0
var isResponding: Bool { task != nil } // 生成中かどうか
private(set) var image: CGImage? // 生成された画像
private var task: Task<Void, Error>? // 非同期生成タスク
nonisolated static var generationSchema: GenerationSchema {
GenerationSchema(
type: GenerableImage.self,
description: """
画像生成モデルに渡す画像の説明。\
説明は短く、人間らしくないものにしてください。
""",
properties: [
GenerationSchema.Property(
name: "imageDescription",
type: String.self
)
]
)
}
nonisolated var generatedContent: GeneratedContent {
GeneratedContent(properties: [
"imageDescription": imageDescription
])
}
// 初期化と画像生成開始
nonisolated init(_ content: GeneratedContent) throws {
self.imageDescription = try content.value(forProperty: "imageDescription")
Logging.general.log("Generating image for description: \(self.imageDescription)")
Task { try await self.generableImage() }
}
// Image Playground連携
private func generableImage(UserDefault: Bool = false) throws {
task?.cancel() // 既存タスクをキャンセル
task = Task {
do {
// 開始前にキャンセルされたタスクを確認する。
if Task.isCancelled {
return
}
let generator = try await ImageCreator()
let prompt = UserDefault ? GenerableImage.fallbackDescription: imageDescription
let generations = generator.images(
for: [.text(prompt)],
style: imageStyle,
limit: 1
)
// 生成前にキャンセルされたタスクを確認する。
if Task.isCancelled {
return
}
for try await generation in generations {
self.image = generation.cgImage
self.task = nil
return
}
// エラーハンドリング
} catch let error {
self.task = nil
Logging.general.log("mage generation failed for prompt: \(self.imageDescription). Error: \(error)")
// フォールバック画像生成
attemptCount += 1
if attemptCount < 2 {
Logging.general.log("デフォルトのフォールバック: 猫画像を生成しています。")
try generableImage(UserDefault: true) // 猫の画像で再試行
}
throw error
}
}
}
}
↑ ファイル全体は @MainActor でUIの変更に影響するためメインスレッドでの処理としているが、生成時などにUIが固まってフリーズしないように Task で非同期処理で ImageCreator() を使って画像生成を行っている。
EncounterEngine.swift ファイル
次は、ゲームの AI 生成 NPC の管理システムについて。
Foundation Models を使ってキャラクターを作成させつつ、コーヒー評価システムも用意している。
AI 生成の NPC 定義 ↓
@Generable
struct NPC: Equatable {
let name: String // AI生成される名前
let coffeeOrder: String // AI生成されるコーヒー注文
let picture: GenerableImage // AI生成される画像
}
@Generable で構造体全体を AI 生成可能にしながらGenerableImage で AI 画像生成との統合をしつつ、やはりここも Equatable を使って一つの変数の変更ですべてのUIの更新が走らないようにし冗長な更新を防ぐようにしている(大量にメモリを確保させない対策)。
AI 生成の NPC システム ↓
@MainActor
@Observable class EncounterEngine {
var customer: NPC?
func generateNPC() async throws -> NPC {
do {
let session = LanguageModelSession {
"""
ユーザーと親切なアシスタントとの会話。これは、夢の世界で愛されているコーヒーショップ「ドリームコーヒー」を舞台にしたファンタジーRPGゲームです。
あなたの役割は想像力を駆使して、楽しいゲームキャラクターを生み出すことです。
"""
}
let prompt = """
夢の世界にふさわしい、楽しい性格のNPC顧客を作成します。顧客にコーヒーを注文してもらいます。
インスピレーションを得るための例をいくつか挙げます:{name: "Thimblefoot", imageDescription: "虹色のたてがみを持つ馬",
coffeeOrder: "夏の草原の草のように爽やかで甘いコーヒーが欲しい。"}{name: "Spiderkid",imageDescription:"Aクールな野球帽をかぶった毛むくじゃらのクモ",coffeeOrder:"アイスコーヒーをください。私と同じくらい怖いんです!"}
{name: "Wise Fairy", imageDescription: "知恵と輝きを放つ青い妖精",
coffeeOrder: "シンプルで植物由来のもの、それが私の賢明なエネルギーを回復させてくれるものをお願いします。"}
"""
let npc = try await session.respond(
to: prompt,
generating: NPC.self
).content
Logging.general.log("Generated NPC: \(String(describing: npc))")
return npc
}
}
プロンプト設計の特徴として、世界観を設定 ( "dream realm" というファンタジー世界 )。
具体例を提示し3つのキャラクターの例えで創造性を誘導。
コーヒー評価のための関数 ↓
func judgeDrink(drink: CoffeeDrink) async -> String {
do {
if let customer {
let session = LanguageModelSession {
"""
ユーザーと親切なアシスタントとの会話。これはドリームコーヒーを舞台にしたファンタジーRPGゲームです。,
夢の世界で愛されているコーヒーショップ。あなたの役割は、以下のお客様のふりをすることです。:\(customer.name): \(customer.picture.imageDescription)
"""
}
let prompt = """
次のドリンクを注文しました:\(customer.coffeeOrder) バリスタがこのドリンクを作りました:\(drink) このドリンクはご期待に沿うものでしたか? 気に入っていただけましたか?バリスタへのフィードバックを必ずご記入ください。気に入った飲み物であれば、褒めてあげましょう!気に入らなかった場合は、その理由を丁寧にバリスタに伝えましょう。
"""
return try await session.respond(to: prompt).content
}
} catch let error {
Logging.general.log("Generation error: \(error)")
}
return "ふーん…大丈夫だよ!"
}
NPC の人格を維持したセッション作成し、注文内容と実際の飲み物を比較。建設的フィードバックの生成を行う。
return try await session.respond(to: prompt).content
↑ここで、await を使ってメインスレッドでの処理が終わるのを待つようにしている。
コード全体はこんな感じ↓
import FoundationModels
import SwiftUI
import os
@Generable
struct NPC: Equatable {
let name: String // AI生成される名前
let coffeeOrder: String // AI生成されるコーヒー注文
let picture: GenerableImage // AI生成される画像
}
// @Observable: SwiftUIとの自動バインディング
@MainActor
@Observable class EncounterEngine {
var customer: NPC?
func generateNPC() async throws -> NPC {
do {
let session = LanguageModelSession {
"""
ユーザーと親切なアシスタントとの会話。これは、夢の世界で愛されているコーヒーショップ「ドリームコーヒー」を舞台にしたファンタジーRPGゲームです。
あなたの役割は想像力を駆使して、楽しいゲームキャラクターを生み出すことです。
"""
}
let prompt = """
夢の世界にふさわしい、楽しい性格のNPC顧客を作成します。顧客にコーヒーを注文してもらいます。
インスピレーションを得るための例をいくつか挙げます:{name: "Thimblefoot", imageDescription: "虹色のたてがみを持つ馬",
coffeeOrder: "夏の草原の草のように爽やかで甘いコーヒーが欲しい。"}{name: "Spiderkid",imageDescription:"Aクールな野球帽をかぶった毛むくじゃらのクモ",coffeeOrder:"アイスコーヒーをください。私と同じくらい怖いんです!"}
{name: "Wise Fairy", imageDescription: "知恵と輝きを放つ青い妖精",
coffeeOrder: "シンプルで植物由来のもの、それが私の賢明なエネルギーを回復させてくれるものをお願いします。"}
"""
let npc = try await session.respond(
to: prompt,
generating: NPC.self
).content
Logging.general.log("Generated NPC: \(String(describing: npc))")
return npc
}
}
func judgeDrink(drink: CoffeeDrink) async -> String {
do {
if let customer {
let session = LanguageModelSession {
"""
ユーザーと親切なアシスタントとの会話。これはドリームコーヒーを舞台にしたファンタジーRPGゲームです。,
夢の世界で愛されているコーヒーショップ。あなたの役割は、以下のお客様のふりをすることです。:\(customer.name): \(customer.picture.imageDescription)
"""
}
let prompt = """
次のドリンクを注文しました:\(customer.coffeeOrder) バリスタがこのドリンクを作りました:\(drink) このドリンクはご期待に沿うものでしたか? 気に入っていただけましたか?バリスタへのフィードバックを必ずご記入ください。気に入った飲み物であれば、褒めてあげましょう!気に入らなかった場合は、その理由を丁寧にバリスタに伝えましょう。
"""
return try await session.respond(to: prompt).content
}
} catch let error {
Logging.general.log("Generation error: \(error)")
}
return "ふーん…大丈夫だよ!"
}
}
やはりここもUIの変更がおこるため基本は @MainActor でメインスレッドでの処理を前提としつつ、プロンプトを投げてAI生成を行う際は非同期処理にしている。
RandomDreamSprite.swift ファイル
これは CharacterSprite とは異なる夢の世界独自の住人を AI で生成する際のファイル。またこのキャラクターの Game 画面上での移動やイメージなどについて管理。
Tool などで連絡先情報やカレンダー情報を使う仕組みは同じだがこちらの方がより夢の世界の住人感を出した NPC をイメージしている感じ。
さらに、別の Image を使った AI 生成の NPC はScene 上で移動しないが、この NPC はゲーム上で動いている存在で、画像についてはあらかじめ用意したこのNPC 用の Image を使っているのが特徴。
class RandomDreamSprite: SKSpriteNode {
var dreamCustomer: NPC? // AI生成されたNPCデータ
var triggerEncounter: ((NPC) -> Void)? // エンカウンター開始コールバック
var checkProximity: ((CGPoint) -> Bool)? // 距離判定コールバック
dreamCustomer では EncounterEngine ファイルで生成されたAI NPCを保持。CharacterSprite とは異なるエンカウンターシステムを設計しつつ、AI が生成した NPC それぞれの性格などの設定を保持させる。
初期化と配置 ↓
init?(npc: NPC) {
super.init(
texture: SKTexture(imageNamed: "glowSprite"), // 光る球体テクスチャ
color: .clear,
size: CGSize(width: 60, height: 42)
)
dreamCustomer = npc
isUserInteractionEnabled = true
// 鮮明なピクセルアートを実現するためにアンチエイリアシングを行わない
texture?.filteringMode = .nearest
// シーン内のコーヒーバーの下にランダムに配置する
position.x = CGFloat.random(in: -320..<320)
position.y = CGFloat.random(in: -480..<160)
self.zPosition = position.y * -1
}
ここで生成するキャラクターには 光る球体をイメージしたイラストを使い、ランダム位置配置で予測不可能性をもたせる。それを Game シーン内のコーヒーカウンターより下の領域に配置させる。
ランダム性のある AI キャラクター移動用のアニメーション ↓
func animate() {
let dist = Double.random(in: 10...30) // ランダム移動距離
let dur = Double.random(in: 1...2) // ランダム移動時間
run(
.sequence([
.wait(forDuration: Double.random(in: 0...1)), // 開始遅延
.repeatForever(
.sequence([
.moveBy(x: dist, y: 0.0, duration: dur),
.moveBy(x: 0.0, y: dist, duration: dur),
.moveBy(x: dist * -1.0, y: 0.0, duration: dur),
.moveBy(x: 0.0, y: dist * -1.0, duration: dur)
])
)
])
)
}
ある程度のパターンを用意し、四角形の軌道を無限リピートさせる(流石に制約のない状態ではディバイスの画面から出てしまう可能性もあるので)。各 NPC ごとに異なる速度・距離でランダム性をもたせそれぞれ独立した飛び込み的な夢の世界の住人感を演出させる狙いかな。
プラットフォーム対応操作 ↓
#if os(iOS)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let triggerEncounter, let checkProximity, let dreamCustomer {
if checkProximity(position) { // iOS: 距離チェック必要
triggerEncounter(dreamCustomer)
}
}
}
#elseif os(iOS)
override func mouseDown(with event: NSEvent) {
if let triggerEncounter, let dreamCustomer {
triggerEncounter(dreamCustomer) // macOS: 直接クリックで即座にエンカウンター
}
}
#endif
iOS ではプレイヤーとの距離判定を必要とし、macOS では直接クリックで即座にエンカウンターさせる。(ここはこのゲームないで統一された感じかな)
コード全体はこちら ↓
import Foundation
import SpriteKit
class RandomDreamSprite: SKSpriteNode {
var dreamCustomer: NPC? // AI生成されたNPCデータ
var triggerEncounter: ((NPC) -> Void)? // エンカウンター開始コールバック
var checkProximity: ((CGPoint) -> Bool)? // 距離判定コールバック
init?(npc: NPC) {
super.init(
texture: SKTexture(imageNamed: "glowSprite"), // 光る球体テクスチャ
color: .clear,
size: CGSize(width: 60, height: 42)
)
dreamCustomer = npc
isUserInteractionEnabled = true
// 鮮明なピクセルアートを実現するためにアンチエイリアシングを行わない
texture?.filteringMode = .nearest
// シーン内のコーヒーバーの下にランダムに配置する
position.x = CGFloat.random(in: -320..<320)
position.y = CGFloat.random(in: -480..<160)
self.zPosition = position.y * -1
}
required init?(coder aDecoder: NSCoder) {
fatalError("nit(coder:) has not been implemented")
}
func animate() {
let dist = Double.random(in: 10...30) // ランダム移動距離
let dur = Double.random(in: 1...2) // ランダム移動時間
run(
.sequence([
.wait(forDuration: Double.random(in: 0...1)), // 開始遅延
.repeatForever(
.sequence([
.moveBy(x: dist, y: 0.0, duration: dur),
.moveBy(x: 0.0, y: dist, duration: dur),
.moveBy(x: dist * -1.0, y: 0.0, duration: dur),
.moveBy(x: 0.0, y: dist * -1.0, duration: dur)
])
)
])
)
}
#if os(iOS)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let triggerEncounter, let checkProximity, let dreamCustomer {
if checkProximity(position) { // iOS: 距離チェック必要
triggerEncounter(dreamCustomer)
}
}
}
#elseif os(iOS)
override func mouseDown(with event: NSEvent) {
if let triggerEncounter, let dreamCustomer {
triggerEncounter(dreamCustomer) // macOS: 直接クリックで即座にエンカウンター
}
}
#endif
RandomCustomerGenerator.swift ファイル
プレイヤーの連絡先データを活用して、 "glowSprite" のImage を使った夢の世界の住人 NPC を連絡先を元に AI で生成するためのファイル。
ツール統合クラス設計 ↓
@MainActor
class RandomCustomerGenerator {
private static let contactsTool = ContactsTool()
let session = LanguageModelSession(
tools: [contactsTool],
instructions: """
顧客の名前を取得するには、\(contactsTool.name) ツールを使用してください。
"""
)
これで、AI が自動的に ContactsTool を使用し月ベースの連絡先検索を実行。
実在の人の名前を取得して NPC キャラクターを作成する。
NPC 生成のプロセス ↓
func generate() async throws -> GenerableCustomer {
let prompt = """
コーヒーを楽しんでいるフレンドリーな顧客を生成してください。\
この顧客の名前は、あなたの連絡先に登録されている必要があります。
"""
let response = try await session.respond(
to: prompt,
generating: GenerableCustomer.self
)
return response.content
}
プロンプトでキャラクターの特徴"フレンドリーな客"を生成するように指示。
AI が ContactsTool を自動実行し連絡先から名前を取得し GeneratedCustomer 構造体を完全生成する。
ContactsTool 内にあらかじめ書いてあるエラーハンドリグを使って、連絡先の名前が見つからない場合にはデフォルトに設定されている名前を使用し生成する。これによってユーザー体験への影響を考えた設計になっている。
ContactsTool.Swift ↓
guard let pickedContact = contacts.shuffled().first else {
Logging.general.log("Contact Tool: No contact found")
return defaultName
}
見つからなければデフォルト値を返すようにしている。
コード全体はこんな感じ ↓
import FoundationModels
@MainActor
class RandomCustomerGenerator {
private static let contactsTool = ContactsTool()
let session = LanguageModelSession(
tools: [contactsTool],
instructions: """
顧客の名前を取得するには、\(contactsTool.name) ツールを使用してください。
"""
)
func generate() async throws -> GenerableCustomer {
let prompt = """
コーヒーを楽しんでいるフレンドリーな顧客を生成してください。\
この顧客の名前は、あなたの連絡先に登録されている必要があります。
"""
let response = try await session.respond(
to: prompt,
generating: GenerableCustomer.self
)
return response.content
}
}
ここでも、このファイルの実行は UI への変更が起こるため @MainActor でメインスレッドでの実行を保証し安全を確保している。
とりあえず今回はここまで。
書き方・解釈に間違いがあれば勉強になりますのでぜひコメントお願いします!
引き続きAIApp開発に向けて勉強した内容をまとめつつ記録していきたいと思います。