0
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?

[SwiftUI] 公式ドキュメントの FoundationModel を使った CoffeeShop ゲームを再現してみる - その8

Posted at

公式ドキュメントからダウンロードできる CoffeeShop ゲームのサンプルコードを読み解いて、自分でも実際に作ってどの程度正確に動作するかも確認してみたいと思います。最終的にはそれを使って類似するゲームをリリースするところまで実践していければと思います。

独学・初心者のため、コードの見解など間違っている箇所があれば勉強になりますのでコメントをいただけると幸いです。

前回までの内容↓

その都度ファイルごとに勉強し、まとめていきます。

前回の GameView.swift ファイル内で書いた GameBoxStyle の ViewModifier を使用する
Viewを今回は読んでいきます。


EncounterView ファイル

AI 生成の NPC とのメインインタラクション画面を管理するモーダルビューです。
プロフィール表示からコーヒー作成・評価まで一連の流れを統合しています。

状態管理プロパティ ↓

struct EncounterView: View {
    @Environment(\.dismiss) var dismiss             // モーダル終了用
    @State var encounterEngine = EncounterEngine()  // NPC管理エンジン
    @State var feedbackDialog: String?              // AI評価結果
    @State var processing: Bool = false             // 処理中フラグ

状態の役割:
dismiss → SwiftUI標準のモーダルを終了し閉じるための機能
feedbackDialog → NPC からのコーヒー評価テキスト
processing → AI評価生成中のローディング制御

画面レイアウト構成 ↓

VStack(spacing: 0) {
   ScrollView(.vertical) {
       HStack {
           Spacer()
           exitButton  // 右上の×ボタン
       }
       .padding()
       
       if let character = encounterEngine.customer {
           // NPCが設定されている場合の表示
           
       } else {
           ProgressView()  // NPC読み込み中
       }
   }
}

画面構成としては ScrollView(.vertical) で縦スクロール対応にし、Viewの右上に×ボタンで終了ボタンを配置。読み込み中であれば ProgressView() でローディング中を視覚的に表記する。

NPCデータの有無で表示切り替え

NPCプロフィール表示 ↓

if let character = encounterEngine.customer {
    CustomerProfileView(customer: character)
    
    Spacer()
    
    // 装飾的なアイコン
    HStack {
        Image(systemName: "cloud.fill").foregroundStyle(.white)
        Image(systemName: "cup.and.saucer.fill").foregroundStyle(.brown)
        Image(systemName: "cloud.fill").foregroundStyle(.white)
    }
    
    Spacer()
}

CustomerProfileView(customer: character) でAI 生成の NPC のプロフィール画像を表示しつつ、視覚的要素としてコーヒーカップアイコンや雲アイコンなどで夢の世界観表現している感じかな。

状態別コンテンツ表示 ↓

if let feedbackDialog {
        // AIの評価が完了済み
        Text(AttributedString(feedbackDialog))
             .modifier(GameBoxStyle())
        } else if processing {
           // AI評価生成中
           ProgressView()
        } else {
           CoffeeOrderView { drink in
                   // コーヒー作成画面
                   processing = true
        Task {
            feedbackDialog = await encounterEngine.judgeDrink(drink: drink)
            processing = false
            }
        }
     }

if let の複数分岐で3段階の状態管理を行う。AIの評価が完了済みならばコーヒー評価をUI表示、処理中であれば ProgressView でローディング中であること視覚的に表示。

コーヒー評価の非同期処理 ↓

CoffeeOrderView { drink in
    processing = true
    Task {
        feedbackDialog = await encounterEngine.judgeDrink(drink: drink)
        processing = false
    }
}

まだ何も行われていなければprocessing = false で未完了であることを保持しつつ 非同期でコーヒーの評価を作成する。

@ViewBuilder を使って終了ボタンを作成 ↓

 @ViewBuilder
   var  exitButton: some View {
       Button {
           // モーダルを閉じる
           dismiss()
       } label: {
           Image(systemName: "xmark")
               .fontWeight(.bold)
               .foregroundStyle(.darkBrown)
               .font(.title2)
       }
       .buttonStyle(.plain)
   }
}

@ViewBuilder (通常、子ビューを生成するクロージャー パラメーターのパラメーター属性として使用し、それらのクロージャーが複数の子ビューを提供できるようにする)

body 内にコードを書きすぎて読みづらくなったりせず、Bottonの再利用の目的やコード修正をしやすいように Button を作成。
変数名を exitButton とすることで目的もわかりやすくなる。

コード全体はこんな感じ ↓

import SwiftUI

struct EncounterView: View {
    @Environment(\.dismiss) var dismiss             // モーダル終了用
    @State var encounterEngine = EncounterEngine()  // NPC管理エンジン
    @State var feedbackDialog: String?              // AI評価結果
    @State var processing: Bool = false             // 処理中フラグ
    
    /*
     dismiss: SwiftUI標準のモーダル終了機能
     feedbackDialog: NPCからのコーヒー評価テキスト
     processing: AI評価生成中のローディング制御
     */
    
    var body: some View {
        VStack(spacing: 0) {
            ScrollView(.vertical) {
                HStack {
                    Spacer()
                    exitButton
                }
                .padding()
                
                if let character = encounterEngine.customer {
                    CustomerProfileView(customer: character)
                    
                    Spacer()
                    
                    HStack {
                        Image(systemName: "cloud.fill")
                            .foregroundStyle(.white)
                        Image(systemName: "cup.and.saucer.fill")
                            .foregroundStyle(.brown)
                        Image(systemName: "cloud.fill")
                            .foregroundStyle(.white)
                    }
                    
                    Spacer()
                    
                    if let feedbackDialog {
                        // AIの評価が完了済み
                        Text(AttributedString(feedbackDialog))
                            .modifier(GameBoxStyle())
                    } else if processing {
                        // AI評価生成中
                        ProgressView()
                    } else {
                        CoffeeOrderView { drink in
                            // コーヒー作成画面
                            processing = true
                            Task {
                                feedbackDialog = await encounterEngine.judgeDrink(drink: drink)
                                processing = false
                            }
                        }
                    }
                    // NPCが設定されている場合の表示
                } else {
                    ProgressView()
                }
            }
        }
    }
    /*
     処理フロー:
     - プレイヤーがコーヒーを完成
     - processing = true でローディング開始
     - judgeDrink() でAI評価生成
     - 結果をfeedbackDialogに格納
     - processing = false でUI更新
     */
    
    @ViewBuilder
    var  exitButton: some View {
        Button {
            // モーダルを閉じる
            dismiss()
        } label: {
            Image(systemName: "xmark")
                .fontWeight(.bold)
                .foregroundStyle(.darkBrown)
                .font(.title2)
        }
        .buttonStyle(.plain)
    }
}

#Preview {
    EncounterView()
}

CoffeeOrderView ファイル

コールバック設計 ↓

struct CoffeeOrderView: View {
    let orderReady: (CoffeeDrink) -> Void

orderReady という変数に CoffeeDrink 型のデータを受け取る。-> Void でこれ自体は何も返さない関数型の変数。「関数そのもの」を保存する変数といった感じ。親 View に引数として渡す。

状態管理 ↓

@State var drinkType: CoffeeDrink.DrinkType = .dripCoffee
    @State var temp: CoffeeDrink.Temp = .hot
    @State var milk: CoffeeDrink.MilkType = .none
    @State var flavorSet: Set<CoffeeDrink.Flavor> = []

初期値設定としてドリップコーヒー、ホット、ミルクなし。フレーバーは空のセット(複数選択対応)にしている。

基本オプション選択の UI ↓

    VStack {
            HStack(spacing: 8) {
                Group {
                    VStack {
                        Text("Drink")
                            .fontWeight(.bold)
                            .foregroundStyle(.darkBrown)
                        
                        Picker("", selection: $drinkType) {
                            ForEach(CoffeeDrink.DrinkType.allCases) { drink in
                                Text(drink.rawValue.capitalized)
                            }
                        }
                        .tint(.black)
                    }
                }
                
                Group {
                    VStack {
                        Text("Temperature")
                            .fontWeight(.bold)
                            .foregroundStyle(.darkBrown)
                        
                        Picker("", selection: $temp) {
                            ForEach(CoffeeDrink.Temp.allCases) { item in
                                Text(item.rawValue.capitalized)
                            }
                        }
                        .tint(.black)
                    }
                }
                
                Group {
                    VStack {
                        Text("Milk")
                            .fontWeight(.bold)
                            .foregroundStyle(.darkBrown)
                        
                        Picker("", selection: $milk) {
                            ForEach(CoffeeDrink.MilkType.allCases) { item in
                                Text(item.rawValue.capitalized)
                            }
                        }
                        .tint(.black)
                    }
                }

UI 設計として3つの基本オプションを横並び配置し、Group でレイアウト構造を整理。 .capitalized プロパティ を使って頭文字だけを大文字にした文字列を使って表示・整形している。

フレーバーの選択システム ↓

private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]



LazyVGrid(columns: columns) {
    ForEach(CoffeeDrink.Flavor.allCases) { item in
        Button {
            toggleFlavor(item)
        } label: {
            Image(systemName: flavorIsSelected(item) ? "square.fill" : "square")
                .foregroundStyle(.darkBrown)
            Text(item.rawValue)
            Spacer()
        }
        .buttonStyle(.plain)
    }
}

GridItem(.flexible()) を使って幅が自動調整される1つの列を用意。
LazyVGrid で用意した列を縦に配列・表示する。(例:フレーバーが9つであれば 3 × 3 で表示)

ForEach を使って CoffeeDrink.Flavor.allCases から要素を一つずつ取り出しそれぞれのボタンをつくる。ボタンの見た目としてチェックボックスにしてある感じ。

フレーバーの管理ロジック ↓

func toggleFlavor(_ flavor: CoffeeDrink.Flavor) {
        if flavorSet.contains(flavor) {
            flavorSet.remove(flavor)
        } else {
            flavorSet.insert(flavor)
        }
    }

func flavorlsSelected(_ flavor: CoffeeDrink.Flavor) -> Bool {
     return flavorSet.contains(flavor)
}

toggleFlavor(item) で選択したフレーバーを配列で保存。flavorlsSelected でそのフレーバーが選択済みか未選択かを trure or false で返し、追加または削除を行う。

ドリンク作成と完了処理 ↓

Button{
    orderReady(brewDrink())
 } label: {
    Spacer()
                
    Text("Brew drink!")
         .fontWeight(.bold)
        .foregroundStyle(.darkBrown)
                
    Spacer()
}


func brewDrink() -> CoffeeDrink {
        return CoffeeDrink(
            drinkType: drinkType,
            temperature: temp,
            milk: milk,
            flavors: Array(flavorSet)
        )
    }

brewDrink() で全選択肢を 型安全性の保証(コンパイル時エラー検出)のため CoffeeDrink 構造体にまとめる。 "Brew drink!" ボタンで orderReady コールバックを使って値を渡して親Viewで実行。

また、

@State var flavorSet: Set<CoffeeDrink.Flavor> = []

↑ UI 側では Set 使い重複を防ぎ追加/削除が高速する設計をしているが、

flavors: Array(flavorSet)

↑ Set → Array に変換することで順序を保持、JSON変換しやすいようにしている。

コード全体はこんな感じ ↓

import SwiftUI

struct CoffeeOrderView: View {
    let orderReady: (CoffeeDrink) -> Void
    
    @State var drinkType: CoffeeDrink.DrinkType = .dripCoffee
    @State var temp: CoffeeDrink.Temp = .hot
    @State var milk: CoffeeDrink.MilkType = .none
    @State var flavorSet: Set<CoffeeDrink.Flavor> = []
    
    private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
    
    var body: some View {
        VStack {
            HStack(spacing: 8) {
                Group {
                    VStack {
                        Text("Drink")
                            .fontWeight(.bold)
                            .foregroundStyle(.darkBrown)
                        
                        Picker("", selection: $drinkType) {
                            ForEach(CoffeeDrink.DrinkType.allCases) { drink in
                                Text(drink.rawValue.capitalized)
                            }
                        }
                        .tint(.black)
                    }
                }
                
                Group {
                    VStack {
                        Text("Temperature")
                            .fontWeight(.bold)
                            .foregroundStyle(.darkBrown)
                        
                        Picker("", selection: $temp) {
                            ForEach(CoffeeDrink.Temp.allCases) { item in
                                Text(item.rawValue.capitalized)
                            }
                        }
                        .tint(.black)
                    }
                }
                
                Group {
                    VStack {
                        Text("Milk")
                            .fontWeight(.bold)
                            .foregroundStyle(.darkBrown)
                        
                        Picker("", selection: $milk) {
                            ForEach(CoffeeDrink.MilkType.allCases) { item in
                                Text(item.rawValue.capitalized)
                            }
                        }
                        .tint(.black)
                    }
                }
            }
            
            LazyVGrid(columns: columns) {
                ForEach(CoffeeDrink.Flavor.allCases) { item in
                    Button {
                        toggleFlavor(item)
                    } label: {
                        Image(systemName: flavorlsSelected(item) ? "square.fill" : "square")
                            .foregroundStyle(.darkBrown)
                        Text(item.rawValue)
                        Spacer()
                    }
                    .buttonStyle(.plain)
                }
            }
            .modifier(GameBoxStyle())
            
            Button{
                orderReady(brewDrink())
            } label: {
                Spacer()
                Text("Brew drink!")
                    .fontWeight(.bold)
                    .foregroundStyle(.darkBrown)
                Spacer()
            }
            .buttonStyle(.plain)
            .modifier(GameBoxStyle())
        }
        .modifier(GameBoxStyle())
    }
    
    func toggleFlavor(_ flavor: CoffeeDrink.Flavor) {
        if flavorSet.contains(flavor) {
            flavorSet.remove(flavor)
        } else {
            flavorSet.insert(flavor)
        }
    }
    
    func flavorlsSelected(_ flavor: CoffeeDrink.Flavor) -> Bool {
        return flavorSet.contains(flavor)
    }
    
    func brewDrink() -> CoffeeDrink {
        return CoffeeDrink(
            drinkType: drinkType,
            temperature: temp,
            milk: milk,
            flavors: Array(flavorSet)
        )
    }
}

CustomerProfileView ファイル

AI生成NPCの詳細プロフィール表示を担当するSwiftUIコンポーネントです。エンカウンター時にNPCの情報を視覚的に表示します。

データ受け取り ↓

struct CustomerProfileView: View {
    var customer: NPC  // AI生成されたNPCデータ

外部からNPCインスタンスを受け取る。var(変数)と書いてありますが、実際にはこの画面内でcustomerを変更することはない。

「もう少し具体的にいうと親から渡される値が変わる可能性がある」だからvar。View内で変更はしないも親から渡される値が変わると、自動的に新しい値を受け取る。「受け取り直す」ため。だからVar。

プロフィール画像表示 ↓

// AIが作成したプロフィール画像
ZStack {
    if let image = customer.picture.image {
    #if canImport(UIKit) // UIKit(iOS): UIImageを使用
    Image(uiImage: UIImage(cgImage: image))
        .resizable()
        .accessibilityLabel(customer.picture.imageDescription)
    #elseif canImport(AppKit) // AppKit(macOS): NSImageを使用
    Image(
        nsImage: NSImage(
        cgImage: image,
        size: NSSize(width: image.width, height: image.height)
        )
    )
    .resizable()
    .accessibilityLabel(customer.picture.imageDescription)
    #endif
    }
    if customer.picture.isResponding {
        ProgressView()  // 生成中のローディング表示
     }
}

if let を使って複数分岐で AI 生成のプロフィール画像が生成済みであれば Image を使って画像を表示。生成中であればProgressView() でローディング中であることを視覚的に表示する。

iPhone とMac では、画像を扱う方法が違うためプラットフォーム対応(iOS/macOS両対応)を書いています。
iOS(iPhone/iPad) では UIImage を使い macOS(Mac) では NSImage を使うようにしている。

また

.accessibilityLabel(customer.picture.imageDescription)

これはアクセシビリティラベル対応で、iPhoneやMacには「VoiceOver」という機能があり、目が見えない・見えにくい人のための画面の内容を音声で読み上げる機能を使ってAIが生成した画像を文書で読み上げてイメージを使えるようにしている。

Text や Button や Image などで絵文字を使ったり画像を使っている際に画像名では意味が伝わらない際にString型で意味を持たせる必要がある時に .accessibilityLabel を使う。
今回の場合は customer.picture.imageDescription でAIが生成した画像の説明を使える仕組みにしている。

短い文章にするなど細かなルールがあるが、これは WWDC でもよく出てくる説明で今後リリースする際に必須とされるかもしれないので別の機会にまた深掘りしたいと思います。

公式ドキュメント↓

テキスト情報表示 ↓

VStack(alignment: .leading) {
    // AI生成された名前
    LabeledContent("Name:", value: customer.name)
        .font(.headline)
        .foregroundStyle(.darkBrown)

    // AI生成されたコーヒー注文
    Text(AttributedString(customer.coffeeOrder))
        .padding(.top)
        .frame(height: 100)

}.padding()

NPC の名前と コーヒーの注文内容を縦に並べて表示し .leading で左揃えにし、名前についてはラベルと値を表示するのに便利な LabeledContent を使い表示しています。

公式ドキュメント ↓

LabeledContent とはラベルを値を表示するビューに付けるためのコンテナで、使う場所(FormやListなど)に応じて自動的に見た目が調整される。

より深い機能についてはまた別の機会に深掘りしていこうと思います。

 Text(AttributedString(customer.coffeeOrder))
        .padding(.top)
        .frame(height: 100)

↑ AttributedString とは簡単にいうと「文字の一部だけ色や太さを変えるのと同じことを、アプリの中で簡単にできるようにするもの」

公式ドキュメント ↓

例えば・・・

func makeStyledText() -> AttributedString {
        var text = AttributedString("重要な お知らせ")
        
        // "重要な"を赤色にする
        if let range = text.range(of: "重要な") {
            text[range].foregroundColor = .red
        }
        
        // "お知らせ"を太字にする
        if let range = text.range(of: "お知らせ") {
            text[range].font = .boldSystemFont(ofSize: 18)
        }
        
        return text
    }

このように書くことで文章の箇所ごとに色を変えて表示させられる。

それ以外にも、安全なコピー操作や参照の共有による予期しない変更がないや Swift のCopy-on-Writec最適化の恩恵などのさまざまの機能があるみたいですが、ここもなかなか深い箇所なので別の機会に深掘りしていきます。

コード全体はこんな感じ ↓

import SwiftUI

struct CustomerProfileView: View {
    var customer: NPC
    
    var body: some View {
        HStack(alignment: .top) {
            // AIが作成したプロフィール画像
            ZStack {
                if let image = customer.picture.image {
                    #if canImport(UIKit) // UIKit(iOS): UIImageを使用
                    Image(uiImage: UIImage(cgImage: image))
                        .resizable()
                        .accessibilityLabel(customer.picture.imageDescription)
                    #elseif canImport(AppKit) // AppKit(macOS): NSImageを使用
                    Image(
                        nsImage: NSImage(
                        cgImage: image,
                        size: NSSize(width: image.width, height: image.height)
                        )
                    )
                    .resizable()
                    .accessibilityLabel(customer.picture.imageDescription)
                    #endif
                }
                if customer.picture.isResponding {
                    ProgressView()  // 生成中のローディング表示
                }
            }
            .aspectRatio(contentMode: .fit)
            .frame(width: 200, height: 200)
            
            VStack(alignment: .leading) {
                // 顧客の生成された名前
                LabeledContent("Name", value: customer.name)
                    .font(.headline)
                    .foregroundStyle(.darkBrown)
                // 顧客が生成したダイアログ
                Text(AttributedString(customer.coffeeOrder))
                    .padding(.top)
                    .frame(height: 100)
            }.padding()
        }
        .modifier(GameBoxStyle())
    }
}

DialogBoxView ファイル

DialogBoxViewは、コーヒーショップゲームの会話インターフェースを提供するSwiftUIコンポーネント。

状態管理 ↓

struct DialogBoxView: View {
    @State var dialogEngine = DialogEngine()  // 会話ロジック管理
    @State var isTalking: Bool = false        // NPCタイピング中フラグ
    @State var userText: String = ""          // プレイヤー入力
    @State var text: String = ""              // NPCの完全なセリフ
    @State var renderedText: String = ""      // 現在表示中のテキスト
    @FocusState private var isFocused: Bool   // キーボード表示制御

text で AI 生成された完全な応答を変数として保持、renderedText では段階的に表示される文字列を保持し isTalking でBool 値を使いタイピング中は入力を無効化するための値を保持するようにしている。

メインレイアウト ↓

var body: some View {
    VStack(spacing: 0) {
        HStack(alignment: .top, spacing: 0) {
            if !renderedText.isEmpty {
                dialogView           // NPCのセリフ表示
                exitButton           // 会話終了ボタン
                    .opacity(isTalking ? 0 : 1)  // タイピング中は非表示
            }
        }
        
        responseField  // プレイヤーの返答フィールド
            .opacity(dialogEngine.talkingTo != nil && !isTalking && !dialogEngine.isGenerating ? 1 : 0)
    }
    .onChange(of: dialogEngine.nextUtterance) {
        text = dialogEngine.nextUtterance ?? ""
        renderedText = ""
        userText = ""
        typingAnimation()  // 新しいセリフごとにアニメーション開始
    }
}

if !renderedText.isEmpty で表示するテキストがある時には、dialogView と exitButton を表示する。
プレイヤー入力フィールドは三項演算子を使って、

.opacity(dialogEngine.talkingTo != nil && !isTalking && !dialogEngine.isGenerating ? 1 : 0)

// 条件1: dialogEngine.talkingTo != nil  会話相手がいる
// 条件2: !isTalking                     AIの返答中(タイピング中)ではない
// 条件3: !dialogEngine.isGenerating     AI生成中ではない

これらが「すべて真」なら1(表示)、それ以外は0(非表示)で responseField を表示・非表示を制御している。

また

.onChange(of: dialogEngine.nextUtterance) {
    text = dialogEngine.nextUtterance ?? ""  // 新しいセリフを取得
    renderedText = ""                        // 表示中のテキストをリセット
    userText = ""                            // ユーザー入力をクリア
    typingAnimation()                        // アニメーション開始
}

この箇所は .onChange を使って dialogEngine.nextUtterance の値が変わるたびに実行される。

NPCセリフ表示 ↓

@ViewBuilder
var dialogView: some View {
   VStack(alignment: .leading, spacing: 0) {
       Text(dialogEngine.talkingTo?.displayName ?? "")
           .fontWeight(.bold)
       HStack {
           Text(LocalizedStringResource(stringLiteral: renderedText))
               .onChange(of: renderedText, typingAnimation)
           Spacer()
       }
   }
   .frame(maxWidth: 350)
   .modifier(GameBoxStyle())
   .padding()
   .onAppear(perform: typingAnimation)
}

ここでも後の追加や修正のしやすさ、コードの見やすさを考慮して @ViewBuilder を使用。

Text(dialogEngine.talkingTo?.displayName ?? "")
            .fontWeight(.bold)

条件分岐でNPCの名前が取得できていればその名前を表示しなければ空を返すようにし "??"(オプショナルチュニング)で見つからない際にアプリがクラッシュしないように設計している。

HStack {
            Text(LocalizedStringResource(stringLiteral: renderedText))
                .onChange(of: renderedText, typingAnimation)
            Spacer()
        }

↑ この箇所の LocalizedStringResource はiOS16以降に推奨された書き方で NSLocalizedString と同じ Localizable.strings を参照させる多言語対応の型です。

サンプルでは特に多言語対応についてコードの用意はされていませんが、対応を想定した書き方になっています。

公式ドキュメント ↓

会話終了ボタン ↓

@ViewBuilder
var exitButton: some View {
    Button {
        dialogEngine.endConversation()  // 会話状態をクリア
    } label: {
        Image(systemName: "xmark")
            .fontWeight(.bold)
            .foregroundStyle(.darkBrown)
            .font(.title2)
    }
    .buttonStyle(.plain)
    .modifier(GameBoxStyle())
    .padding([.top, .bottom, .trailing])
}

EncounterView ファイル内でも exitButton を @ViewBuilder (通常、子ビューを生成するクロージャー パラメーターのパラメーター属性として使用し、それらのクロージャーが複数の子ビューを提供できるようにする) を使って書いたが、Tap 時の処理が違うのでここでも書いて用意。

プレイヤー入力フィールド ↓

@ViewBuilder
var responseField: some View {
    HStack {
        TextField("Reply", text: $userText)
            .textFieldStyle(.plain)
            .focused($isFocused)
            .disabled(isTalking)  // タイピング中は無効
            .onSubmit {
                userResponds()
            }
        Button {
            userResponds()
        } label: {
            Image(systemName: "paperplane.fill")
                .foregroundStyle(.darkBrown)
                .font(.title2)
        }
        .buttonStyle(.plain)
        .disabled(isTalking)
    }
    .frame(height: 50)
    .modifier(GameBoxStyle())
    .padding(.horizontal)
    .padding(.bottom)
}

@ViewBuilder (通常、子ビューを生成するクロージャー パラメーターのパラメーター属性として使用し、それらのクロージャーが複数の子ビューを提供できるようにする) を使って、responseField を再利用やコード修正を目的とした書き方で作成。

.disabled(isTalking) で NPC がタイピング中(返答中) かを判断しタイピング中にはグレーアウトし入力不可。タイピングが終わった際に表示され入力可としている。

.modifier(GameBoxStyle()) で GameBoxStyle() で定義した統一のスタイルを使用。

プレイヤー応答処理 ↓

func userResponds() {
    isFocused = false               // キーボードを隠す
    dialogEngine.respond(userText)  // DialogEngineに応答送信
}

.onSubmit でユーザーがEnter キーを押した時(macOS)または Return キーを押した時(iOS)や キーボードの完了ボタンをタップした時(iOS) を押したタイミングの処理を書き、userResponds() 内で isFocused に false を渡し送信と合わせてキーボードを閉じる。

タイピングアニメーション ↓

func typingAnimation() {
    if renderedText.count < text.count {
        isTalking = true  // 話し中状態
        Task {
            try? await Task.sleep(for: .seconds(0.025))  // 25ms待機
            
            if renderedText.count < text.count {
                let next = text[renderedText.endIndex]  // 次の文字取得
                renderedText.append(next)                // 表示文字列に追加
            } else {
                isTalking = false  // 表示完了
            }
        }
    } else {
        isTalking = false
    }
}

typingAnimation() はタイピングエフェクトについて設定しています。

全体の動作イメージとしては...

-完全なテキスト: "Hello "-

ステップ1: "" (空)
ステップ2: "H" (1文字追加)
ステップ3: "He" (2文字追加)
ステップ4: "Hel" (3文字追加)
ステップ5: "Hell" (4文字追加)
ステップ6: "Hello" (完了!)

と、try? await Task.sleep(for: .seconds(0.025)) ここで各ステップ間に**25ms(0.025秒)**の待機時間を設定。またUIをフリーズさせずに時間を置いて処理させるために Task で非同期処理にしている。

最初の条件分岐としては、Text でAI の生成した文章を受け取りそれと renderedText を比較しまだ表示されていない文字があれば続け、表示済みであれば終了する。また文字追加の前にも再度おなじ条件分岐をし、万が一非同期中に Text 変更が起こった際にクラッシュしないよう対応している。

.count と .endIndex 次に追加する位置を取得し文字を renderedText 追加。

タイピングアニメーションというだけあって、ループで書くと UI 更新が見えないので再帰的に書くことで UI が更新されゲームのタイピング表示を表現している。

コード全体はこんな感じ ↓

import SwiftUI

struct DialogBoxView: View {
    /// 会話ロジックを管理
    @State var dialogEngine = DialogEngine()
    // NPCが話している最中かどうか
    @State var isTalking: Bool = false
    //プレイヤーが入力したテキスト
    @State var userText: String = ""
    /*
     キャラクターダイアログのレンダリング:テキストは次のダイアログ全体です。
     renderedTextに文字が追加されるたびに、入力アニメーションが表示されます。
     */
    // NPCの完全なセリフ
    @State var text: String = ""
    // タイピング表示中のテキスト
    @State var renderedText: String = ""
    // テキストフィールドのフォーカス状態
    @FocusState private var isFocused: Bool
    
    var body: some View {
        VStack(spacing: 0) {
            HStack(alignment: .top, spacing: 0) {
                if !renderedText.isEmpty {
                    // NPCのセリフ表示
                    dialogView
                    // 会話を終了する
                    exitButton
                        .opacity(isTalking ? 0 : 1)  // 話し中は非表示
                        .transition(.opacity)
                        .animation(.linear(duration: 0.2), value: isTalking)
                }
            }
            // プレイヤーの返答
            responseField
                .opacity(dialogEngine.talkingTo != nil && !isTalking && !dialogEngine.isGenerating ? 1 : 0)
                .transition(.opacity)
                .animation(.linear(duration: 0.2), value: isTalking)
        }
        .onChange(of: dialogEngine.nextUtterance) {
            // 新しいダイアログごとに、入力アニメーションをリセットします。
            text = dialogEngine.nextUtterance ?? ""
            renderedText = ""
            userText = ""
            typingAnimation()
        }
    }
    
    @ViewBuilder
    var dialogView: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(dialogEngine.talkingTo?.displayName ?? "")
                .fontWeight(.bold)
            HStack {
                Text(LocalizedStringResource(stringLiteral: renderedText))
                    .onChange(of: renderedText, typingAnimation)
                Spacer()
            }
        }
        .frame(maxWidth: 350)
        .modifier(GameBoxStyle())
        .padding()
        .onAppear(perform: typingAnimation)
    }
    
    @ViewBuilder
    var exitButton: some View {
        Button {
            dialogEngine.endConversation()
        } label: {
            Image(systemName: "xmark")
                .fontWeight(.bold)
                .foregroundStyle(.darkBrown)
                .font(.title2)
        }
        .buttonStyle(.plain)
        .modifier(GameBoxStyle())
        .padding([.top, .bottom, .trailing])
    }
    
    @ViewBuilder
    var responseField: some View {
        HStack {
            TextField(
                "Reply",
                text: $userText
            )
            .textFieldStyle(.plain)
            .disabled(isTalking)
            .onSubmit {
                 userResponds()
            }
            Button {
                userResponds()
            } label: {
                Image(systemName: "paperplane.fill")
                    .foregroundStyle(.darkBrown)
                    .font(.title2)
            }
            .buttonStyle(.plain)
            .disabled(isTalking)
        }
        .frame(height: 50)
        .modifier(GameBoxStyle())
        .padding(.horizontal)
        .padding(.bottom)
    }
    
    func typingAnimation() {
        // まだ表示しきれていない文字があるかどうか
        if renderedText.count < text.count {
            isTalking = true  // 話し中状態にする
            Task {
                try? await Task.sleep(for: .seconds(0.025)) // 25ms待機
                // 待機中にテキストが更新された場合は再度確認してください
                if renderedText.count < text.count {
                    // 次の文字を取得
                    let next = text[renderedText.endIndex]
                    // 表示文字列に追加
                    renderedText.append(next)
                } else {
                    isTalking = false // 表示完了
                }
            }
        } else {
            isTalking = false
        }
    }
    
    func userResponds() {
        isFocused = false               // キーボードを隠す
        dialogEngine.respond(userText)  // DialogEngineに応答を送信
    }
}

// プレビューでの使用例
#Preview {
    let dialogEngine = DialogEngine()
    DialogBoxView(dialogEngine: dialogEngine)
        .task {
            dialogEngine.talkingTo = Barista()                    // NPCを設定
            dialogEngine.nextUtterance = "Hello welcome to Dream Coffee"  // 初期セリフ
        }
}

とりあえず今回はここまで。
書き方・解釈に間違いがあれば勉強になりますのでぜひコメントお願いします!

引き続きAIApp開発に向けて勉強した内容をまとめつつ記録していきたいと思います。

0
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
0
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?