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 ゲームを再現してみる - その7

Posted at

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

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

前回までの内容↓

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


GameView.Swift

この View はこのゲーム全体の統合ハブとして機能し、SpriteKitとSwiftUIの2つを橋渡ししています。

システム統合の状態管理 ↓

struct GameView: View {
    @State var skScene: CoffeeShopScene?            // SpriteKitゲーム世界
    @State var showModel: Bool = false              // エンカウンターモーダル制御
    @State var dialogEngine = DialogEngine()        // 会話システム
    @State var encounterEngine = EncounterEngine()  // AI生成NPC管理

4つの独立したシステムを単一画面で統合し各エンジンが相互作用しながら独立性を保持するようにしている。

UIの構造 ↓

 var body: some View {
        ZStack {
            // 背景層:SpriteKitゲーム世界
            if let skScene {
                SpriteView(scene: skScene)
                    .ignoresSafeArea()
            }
            // 前景層:SwiftUI会話インターフェース
            VStack {
                Spacer()
                DialogBoxView(dialogEngine: dialogEngine)
            }
            // モーダル層:NPCエンカウンター
            .sheet(isPresented: $showModel) {
                ZStack {
                    Color(.backgroundBrown)
                    EncounterView(encounterEngine: encounterEngine)
                }
            }

View の表示内容としては、SpriteKitで 2D ゲームの世界を会計的に表示し SwiftUI はテキスト表示などのUI要素を担当。モーダル(シート)を使ってNPCとのエンカウント時のtextViewを表示する。

SpritKit(NPC)を Tap した時の処理 ↓

func showDialog(_ character: any Character) {
    dialogEngine.talkTo(character)
}

SpriteKitで NPC をタップした際に dialogEngine を呼び出す関数。

@Stateは SwiftUI 専用のプロパティラッパーのためキャラクター管理の CharacterSprite では dialogEngine は持たせらません。また仮に @State なしで DialogEngine を持たせても @State 何では監視ができないため UI 更新が自動で起きない。さらに NPC が10体いたら、DialogEngine が10個作られるため会話には一つしか必要ないばかりかも無駄な9個でメモリを圧迫する。
このことからGamveView上で橋渡しを行うことで処理させる。

流れとしては、

[プレイヤーがNPCをタップ]

[SpriteKitがタップを検知]

[showDialog関数を呼び出す] ← ここが橋渡し!

[dialogEngine.talkTo()を実行]

[DialogBoxViewに会話が表示される]

これで、タップされた際は画面下の DialogBoxView に会話が表示される仕組み。

非同期でゲームSceneの初期化 ↓

.task {
    if let scene = CoffeeShopScene(fileNamed: "CoffeeShopScene") {
        CoffeeShopScene.finishSetup(
            scene,
            showDialog: showDialog(_:),  // 会話開始のコールバック
        )
        scene.scaleMode = .aspectFill
        skScene = scene

        // 初期AI生成NPCを4体配置
        for _ in 0..<4 {
            generateDreamCustomer()
        }
    }
}

この初期化で、SpriteKit シーンファイル読み込み・ SwiftUI 連携のセットアップ・ AI 生成の NPC の非同期での配置を行う。

非同期で行うのは、画面生成時ではまだ NPC が生成されておらず同期処理にすると NPC が生成完了するまで何も表示されずユーザー体験に影響が出る。
AI生成などの時間がかかる処理をバックグラウンドで実行することで、処理中でもユーザーは他の操作ができることでユーザーの操作性も意識した作り。

そして生成時には

for _ in 0..<4  {
generateDreamCustomer()
}

DreamCustomerで4体の NPC を生成するようにしている。

4体の DreamCustomer とのエンカウンターシステム ↓

func triggerEncounter(_ npc: NPC) {
        Logging.general.log("trigger encounter! \(npc.name)")
        encounterEngine.customer = npc
        showModel = true
    }

この関数では、DreamCustomer を使って生成した4体の NPC とエンカウントした際の処理を書いている。
このNPC をタップした際には、このNPCはコーヒーを注文する仕組みになっていてオーダー表のような形でViewがモーダル(シート)で呼ばれる仕組み。
その際にどの NPC をタップしたかを保持し generateDreamCustomer() の渡すようにしている。

コーヒー注文をする NPC の動的配置 ↓

  func generateDreamCustomer() {
        Task {
            if let skScene {
                do {
                    let npc = try await encounterEngine.generateNPC()
                    CoffeeShopScene.addDreamCustomer(
                        scene: skScene,
                        npc: npc,
                        triggerEncounter: triggerEncounter(_:)
                    )
                } catch let error {
                    Logging.general.log("Generation error for dream customer: \(error)")
                }
            }
        }
    }

encounterEngine.generateNPC() で NPC の生成、CoffeeShopScene.addDreamCustomer を使ってシーンに視覚的配置。triggerEncounter(_:) でエンカウント可能な状態で追加。

これで、GameScene上には、会話用の showDialog(:) 関数を持つ NPC と注文用triggerEncounter(:) 関数をもつ NPC がそれぞれ租納している感じ。

GameBoxStyle - 統一デザインのためのViewModifier ↓

// ゲーム全体で使用されるレトロなボックスビュースタイル
struct GameBoxStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .fontDesign(.monospaced)       // レトロゲーム風フォント
            .background(.brown.secondary)  // 茶色テーマ
            .border(Color.brown, width: 6) // ピクセルアート風ボーダー
    }
}

コーヒーショップのテーマカラーとしてブラウンを設定しピクセルアート風ボーダーでレトロゲーム風をイメージしている。これらを DialogBoxView や EncounterView で使用。これで何度も同じコードを書く手間を省きデザイン変更のミスを防ぐことができる。

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

import SpriteKit
import SwiftUI
import os

struct GameView: View {
    @State var skScene: CoffeeShopScene?            // SpriteKitゲーム世界
    @State var showModel: Bool = false              // エンカウンターモーダル制御
    @State var dialogEngine = DialogEngine()        // 会話システム
    @State var encounterEngine = EncounterEngine()  // AI生成NPC管理
    /*
     - 4つの独立したシステムを単一画面で統合
     - 各エンジンが相互作用しながら独立性を保持
     */
    var body: some View {
        ZStack {
            // 背景層:SpriteKitゲーム世界
            if let skScene {
                SpriteView(scene: skScene)
                    .ignoresSafeArea()
            }
            // 前景層:SwiftUI会話インターフェース
            VStack {
                Spacer()
                DialogBoxView(dialogEngine: dialogEngine)
            }
            // モーダル層:NPCエンカウンター
            .sheet(isPresented: $showModel) {
                ZStack {
                    Color(.backgroundBrown)
                    EncounterView(encounterEngine: encounterEngine)
                }
            }
            /*
             3層構造の利点:
             - SpriteKit → 2Dゲーム表現
             - SwiftUI   → モダンなUI要素
             - モーダル    → 集中的なインタラクション
             */
        }
        .frame(maxWidth: .infinity)
        // 非同期ゲーム初期化
        .task {
            // コーヒーショップのSpriteKitシーンをロードする
            if let scene = CoffeeShopScene(fileNamed: "CoffeeShopScene") {
                CoffeeShopScene.finishSetup(
                    scene,
                    showDialog: showDialog(_:),  // 会話開始のコールバック
                )
                scene.scaleMode = .aspectFill
                skScene = scene
                // 初期AI生成NPCを4体配置
                for _ in 0..<4 {
                    generateDreamCustomer()
                }
            }
        }
    }
    /*
     初期化の流れ:
     - SpriteKitシーンファイル読み込み
     - SwiftUI連携のセットアップ
     - AI生成NPCの非同期配置
     */
    
    func showDialog(_ character: any Character) {
        dialogEngine.talkTo(character)
    }
    
    func generateDreamCustomer() {
        Task {
            if let skScene {
                do {
                    let npc = try await encounterEngine.generateNPC()
                    CoffeeShopScene.addDreamCustomer(
                        scene: skScene,
                        npc: npc,
                        triggerEncounter: triggerEncounter(_:)
                    )
                } catch let error {
                    Logging.general.log("Generation error for dream customer: \(error)")
                }
            }
        }
    }
    
    func triggerEncounter(_ npc: NPC) {
        Logging.general.log("trigger encounter! \(npc.name)")
        encounterEngine.customer = npc
        showModel = true
    }
}

// ゲーム全体で使用されるレトロなボックスビュースタイル
struct GameBoxStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .fontDesign(.monospaced)       // レトロゲーム風フォント
            .background(.brown.secondary)  // 茶色テーマ
            .border(Color.brown, width: 6) // ピクセルアート風ボーダー
    }
}

#Preview {
    GameView()
}

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

引き続き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?