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

Last updated at Posted at 2025-09-26

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

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

前回までの内容↓

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


CoffeeShopScene.swift ファイル

コーヒーショップゲームの SpriteKit のゲーム世界を管理するメインクラス。
プレイヤーの移動・カメラの制御・NPCとのインタラクションを統合し管理する。

状態管理↓

class CoffeeShopScene: SKScene {
    var touchLocation: CGPoint?                 // プレイヤーのタッチ位置
    var showDialog: ((any Character) -> Void)?  // SwiftUIとの連携コールバック
    var player: PlayerSprite?                   // プレイヤースプライト参照
    // プレイヤーの連絡先からランダムな顧客を生成する
    let randomCustomerGenerator = RandomCustomerGenerator()
    var isGeneratingEncounter: Bool = false

内容としてはタッチ入力とプレイヤー移動の連携と SwiftUIとSpriteKit の橋渡し機能。そして AI 生成 NP Cシステムとの統合。

初期化↓

static public func finishSetup(
        _ scene: CoffeeShopScene,
        showDialog: @escaping (any Character) -> Void
    ) {
        scene.showDialog = showDialog
        for child in scene.children {
            if let sprite = child as? SKSpriteNode {
                sprite.texture?.filteringMode = .nearest // ピクセルアート設定
                
                if let player = child as? PlayerSprite {
                    scene.player = player // プレイヤー参照保存
                }
                
                if let character = child as? CharacterSprite {
                    character.showDialog = showDialog  // 会話システム連携
                    character.checkProximity = scene.checkProximity(_:)
                }
                
            } else if let cam = child as? SKCameraNode {
                scene.camera = cam // カメラ設定
            }
        }
        
        // プレイヤーの連絡先から顧客を生成する
        scene.generateEncounter()
    }

初期化の流れとしては、シーンファイルから子要素を取得 → 各スプライトにコールバック設定 → ピクセルアート最適化 → AI 生成の NPC の配置開始といった感じに、全てが揃った状態を確認した後に初期化し表示させています。

会話システム(UI画面)はゲームシーン(Scene画面)とは別の処理であり外側で管理されているため、Sceneの初期化の時点では、まだ外部の会話システム(UI)が準備できていないため整った状態を確認してから初期化を行う流れ。

CharacterSprite とは異なる AI 生成の NPC キャラクターの動的処理↓

static public func addDreamCustomer(
        scene: CoffeeShopScene,
        npc: NPC,
        triggerEncounter: @escaping (NPC) -> Void
    ) {
        let spawn = RandomDreamSprite(npc: npc)!
        spawn.checkProximity = scene.checkProximity(_:)
        spawn.triggerEncounter = triggerEncounter
        scene.addChild(spawn)
        spawn.animate()
    }

FoundationModels の NPC データを受け取りキャラクターを生成。RandomDreamSprite を使って"魂の画像"を用いいたキャラクターとして視覚化する。ランダムに移動させ(浮遊アニメーション)夢の世界の住人を演出するねらい。

ゲームループの更新↓

override func update(_ currentTime: TimeInterval) {
        if let touchLocation, let player {
            player.movePlayer(touchLocation)
            
            moveCamera(player.position) // カメラをアップデートする
        }
    }

毎フレームの処理として、プレイヤーの移動とカメラ追従をアップデート。

距離判定を設定↓

func checkProximity(_ targetLocation: CGPoint) -> Bool {
        if let player {
            let dist = hypot(
                abs(targetLocation.x - player.position.x),
                abs(targetLocation.y - player.position.y)
            )
            Logging.general.log("distance: \(dist)")
            return dist < 200.0
        }
        return false
    }

ユークリッド距離計算で 200 ピクセル以内で NPC との会話可能に。リアルな空間的相互作用を演出。RPGによくあるエンカウントシステム。これがないと全く違う位置にいるキャラクターとの会話がおこったりしてゲームの世界観が成立しないですよね。

プレイヤーを追従するカメラの処理↓

func moveCamera(_ playerLocation: CGPoint) {
        let stride = 0.25
        self.camera?.position.x.interpolate(towards: playerLocation.x, amount: stride)
        self.camera?.position.y.interpolate(towards: playerLocation.y, amount: stride)
    }

プレイヤーには移動時の制約をつけてワープのようにならないようにしてはあるが、視点も let stride = 0.25 で 25% の補間率で緩やかに追従するようにし、プレイヤーの急激な動きに対して視覚的にワープのようにならないようにしている(視点があまり早いと酔いそうになるものね)。

連絡先ベースの AI 生成のNPCキャラクター↓

func generateEncounter() {
        if isGeneratingEncounter {
            return
        }
        isGeneratingEncounter = true
        
        Task {
            let character = try await randomCustomerGenerator.generate()
            let sprite = GeneratedCustomerSprite(character: character)
            sprite.showDialog = self.showDialog
            sprite.checkProximity = checkProximity(_:)
            self.addChild(sprite)
            isGeneratingEncounter = false
        }
    }

ユーザーごとの NPC生成としてプレイヤーの連絡先データを活用。実在の人をベースにした NPC 作成し会話システムとの完全統合を行う。

Task {
            let character = try await randomCustomerGenerator.generate()
            let sprite = GeneratedCustomerSprite(character: character)
            sprite.showDialog = self.showDialog
            sprite.checkProximity = checkProximity(_:)
            self.addChild(sprite)
            isGeneratingEncounter = false
        }

↑ この箇所では、AI での NPC の生成を非同期処理で行う。生成を待たずにスプライトが表示されてしまうとクラッシュしてしまうため必ず await で処理を待ってから次の処理を行うようにしている。

プラットフォーム対応↓

#if os(iOS)
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        touchLocation = touches.first?.location(in: self)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        touchLocation = touches.first?.location(in: self)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        touchLocation = nil
    }
    
    #elseif os(iOS)
    override func mouseDown(with event: NSEvent) {
        touchLocation = event.location(in: self)
    }
    #endif

クロスプラットフォームの対応として、iOS はタッチ操作、macOS はマウスクリックに設定。

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

import SwiftUI
import FoundationModels
import SpriteKit
import os

class CoffeeShopScene: SKScene {
    var touchLocation: CGPoint?                 // プレイヤーのタッチ位置
    var showDialog: ((any Character) -> Void)?  // SwiftUIとの連携コールバック
    var player: PlayerSprite?                   // プレイヤースプライト参照
    // プレイヤーの連絡先からランダムな顧客を生成する
    let randomCustomerGenerator = RandomCustomerGenerator()
    var isGeneratingEncounter: Bool = false
    
    // 重要: 初期化後すぐに呼び出す必要がある
    static public func finishSetup(
        _ scene: CoffeeShopScene,
        showDialog: @escaping (any Character) -> Void
    ) {
        scene.showDialog = showDialog
        for child in scene.children {
            if let sprite = child as? SKSpriteNode {
                sprite.texture?.filteringMode = .nearest // ピクセルアート設定
                
                if let player = child as? PlayerSprite {
                    scene.player = player // プレイヤー参照保存
                }
                
                if let character = child as? CharacterSprite {
                    character.showDialog = showDialog  // 会話システム連携
                    character.checkProximity = scene.checkProximity(_:)
                }
                
            } else if let cam = child as? SKCameraNode {
                scene.camera = cam // カメラ設定
            }
        }
        
        // プレイヤーの連絡先から顧客を生成する
        scene.generateEncounter()
    }
    
    static public func addDreamCustomer(
        scene: CoffeeShopScene,
        npc: NPC,
        triggerEncounter: @escaping (NPC) -> Void
    ) {
        let spawn = RandomDreamSprite(npc: npc)!
        spawn.checkProximity = scene.checkProximity(_:)
        spawn.triggerEncounter = triggerEncounter
        scene.addChild(spawn)
        spawn.animate()
    }
    
    override func update(_ currentTime: TimeInterval) {
        if let touchLocation, let player {
            player.movePlayer(touchLocation)
            
            moveCamera(player.position) // カメラをアップデートする
        }
    }
    
    func generateEncounter() {
        if isGeneratingEncounter {
            return
        }
        isGeneratingEncounter = true
        
        Task {
            let character = try await randomCustomerGenerator.generate()
            let sprite = GeneratedCustomerSprite(character: character)
            sprite.showDialog = self.showDialog
            sprite.checkProximity = checkProximity(_:)
            self.addChild(sprite)
            isGeneratingEncounter = false
        }
    }
    
    // プレイヤーがターゲットに十分近いかどうかを確認します
    func checkProximity(_ targetLocation: CGPoint) -> Bool {
        if let player {
            let dist = hypot(
                abs(targetLocation.x - player.position.x),
                abs(targetLocation.y - player.position.y)
            )
            Logging.general.log("distance: \(dist)")
            return dist < 200.0
        }
        return false
    }
    
    
    // ゲームカメラをプレイヤーに追従させる
    func moveCamera(_ playerLocation: CGPoint) {
        let stride = 0.25
        self.camera?.position.x.interpolate(towards: playerLocation.x, amount: stride)
        self.camera?.position.y.interpolate(towards: playerLocation.y, amount: stride)
        /*
         スムーズカメラ:
         - 25%の補間率で緩やかな追従
         - プレイヤーの急激な動きにも滑らかに対応
         */
    }
    
    /*
     プレイヤーがシーン内を移動できるようにします。iOS の場合は、プレイヤーが画面をタッチした場所を登録します。
     macOS の場合は、プレイヤーが画面をクリックした場所を登録します。
     */
    #if os(iOS)
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        touchLocation = touches.first?.location(in: self)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        touchLocation = touches.first?.location(in: self)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        touchLocation = nil
    }
    
    #elseif os(iOS)
    override func mouseDown(with event: NSEvent) {
        touchLocation = event.location(in: self)
    }
    #endif
}

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

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