[Swift] Spritekitでキャラクター表示を勉強してみた！

いろいろなアプリ制作ができるようになるため勉強していたところ、2Dゲーム制作などに使えるSpriteKitに興味が出てきたのでさっそく勉強してみました。

## SpriteKitとは？
SpriteKitは、Apple が提供する 図形、粒子、テキスト、画像、およびビデオを2次元で描画するための汎用フレームワークです。Metal を活用して高性能レンダリングを実現し、ゲームやその他のグラフィックを多用するアプリを簡単に作成できるシンプルなプログラミング インターフェイスを提供します。
iOS、macOS、tvOS、およびwatchOSでサポートされており、GameplayKitやSceneKitなどのフレームワークとうまく統合されています。

Swiftで開発をしている自分にとっては、学習コストが低くとりあえずシンプルなゲームを作るには十分な機能を備えているのでゲーム開発の初期学習には十分かと思います。

https://developer.apple.com/documentation/spritekit/

## ・参考
今回、勉強するにあたり下記の方の記事を参考にさせていただきました↓

https://qiita.com/am10/items/df42c1f0091e6c4705e2

## ・素材

![CatFront.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808574/1e9a1776-b09b-c7db-9605-49dda7c5a6ba.png) 
テクスチャ名"Cat"
![CatLeft.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808574/dc38dc77-c152-67c4-dbd2-3a3a75cd74fa.png)
テクスチャ名"CatL"
![CatRight.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808574/0aca0a42-ed8a-aed3-7b1b-ad99dafdd729.png)
テクスチャ名"CatR"

それぞれのテクスチャ名を上記のようにし、Assets.xcassets内に画像を準備しました。


今回は、「ぴぽや倉庫」さんの無料素材を使わせていただきます。

https://pipoya.net/sozai/

## ・実装
プロジェクトファイルを作成します。
Gameからプロジェクトを作成します。

![スクリーンショット 2024-06-12 15.01.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808574/e1a63857-5bf2-0822-0f05-3a46aa9f494a.png)

Gameからファイルを作成すると、デフォルトでScene.sksとActions.sksファイルは作成されており、ViewContoroller上にSceneを表示させるためのコードも記述されています。
Scene.sksファイル内の"Hello world"というNodeと、Scene.swiftにそれらを表示させるためのデフォルトで記述されているコードは削除しました。

今回はあくまでキャラクター表示の勉強のため、ViewContorollerにデフォルトで記述のあるコードに変更は加えません。

次にPlayerを表示するためのコードを記述します。

今回のキャラクターイメージは猫の画像のみを使用しますが、キャラクターを増やした時を想定してイメージ管理を列挙型にしていきます。
さらにドラッグしてキャラクターを左右に動かした際、左右それぞれのイメージを適用させるため、テクスチャー名を取得するメソッドも記述します。

```Swift

class Players {
    //    プレイヤーノードを格納する変数
    var player: SKSpriteNode!
    //    キャラクターの種類を表す列挙型
    enum Characters: String {
        case mammalian = "Cat"
        case reptiles = ""
        
    }
    //    キャラクターの移動に関する構造体
    struct MoveCharacters {
        //        右移動時のテクスチャ名の後置文字
        static let rightPostfix = "R"
        //        左移動時のテクスチャ名の後置文字
        static let leftPostfix = "L"
        //        画像名から対応するテクスチャ名を取得するメソッド
        static func textureName(for imageName: String) -> String {
            //            画像名に対応するキャラクターを取得
            guard let character = Characters(rawValue: imageName) else {
                //                サポートされていない画像名の場合、エラーメッセージを表示
                print("Error Unsupported imageName \(imageName)")
                return ""
            }
            //            左方向のテクスチャ名を返す
            return textureName(for: character, direction: .left)
        }
        //        キャラクターと方向から対応するテクスチャ名を取得するメソッド
        static func textureName(for character: Characters, direction: Direction) -> String {
            let baseName = character.rawValue
            //            三項演算子(directionが.left左であれば、postfixにleftPostfixを代入する)
            let postfix = (direction == .left) ? leftPostfix : rightPostfix
            return "\(baseName)\(postfix)"
        }
    }
    //    移動方向を表す列挙型
    enum Direction: String {
        case right = "R"
        case left = "L"
    }
    //    プレイヤーテクスチャーの設定し、SKSpriteNodeを作成するメソッド
    func setPlayer(playertexture: SKTexture) -> SKSpriteNode? {
        //        テクスチャーを使用してプレイヤーノードを作成
        self.player =  SKSpriteNode(texture: playertexture)
        //        プレイヤーノードが正しく作成されたか確認
        if let player = self.player {
            //            CGSize
            player.size = CGSize(width: 70, height: 70)
            //            衝突判定のための物理ボディーを設定
            player.physicsBody = SKPhysicsBody(circleOfRadius: max(player.size.width/2 - 50, player.size.height/2 - 50))
            //            重力の影響を受けるか？
            player.physicsBody?.affectedByGravity = false
            //            プレイヤーノードを返す
            return player
        }
        //        　プレイヤーノードが作成されなかった場合、nilを返す
        return nil
    }
}
```
Scene.swiftにキャラクターを表示させるためのメソッドを記述します。

```Swift

    func showPlayer(imageName: String) {
//        指定された画像名に対応するキャラクタータイプを取得
        guard let characterType = Players.Characters(rawValue: imageName) else {
            //            サポートしていないキャラクターの場合のエラーハンドリング
            print("Unsupported character type: \(imageName)")
            return
        }
        print("Transition NodeName: \(imageName)")
        //                左移動時のテクスチャ名を取得し、変数に代入
        lMoveTextureName = Players.MoveCharacters.textureName(for: characterType, direction: .left)
        print("LeftMoveTexture: \(String(describing: lMoveTextureName))")
//        右移動時のテクスチャ名を取得し、変数名に代入
        rMoveTextureName = Players.MoveCharacters.textureName(for: characterType, direction: .right)
        print("RightMoveTexture: \(String(describing: rMoveTextureName))")
        //        初期状態として左向きのテクスチャーを設定
        guard let imageName = lMoveTextureName else {
            print("Error: left move texture name is nil")
            return
        }
//        指定されたテクスチャ名からSKTextureオブジェクトを作成
        let texture = SKTexture(imageNamed: imageName)
        //        プレイヤーノードを作成
        self.playerNode = Players().setPlayer(playertexture: texture)
//        プレイヤーノードが正しく作成されたか確認
        if let player = self.playerNode {
            //        プレイヤーノードを画面に表示
            //           出現位置を設定(画面中央に設定)
            player.position = CGPoint(x: frame.midX, y: frame.midY)
//            シーンにプレイヤーノードを追加
            addChild(player)
        } else {
//            プレイヤーノードの作成の失敗した場合のエラーハンドリング
            print(" Error: Failed to create player.")
            // 必要に応じて、エラー通知や他の処理を追加する
            
        }
    }
```
Scene.swiftに画面のドラッグ時の処理を記述し、didMove内にキャラクターを表示させるための処理を記述します。

左右の向きを検知し描画させるイラストを変えるため、列挙型を用いて左右の検知を行うためのコードも追加していきます。

```Swift

import GameplayKit
import SpriteKit
//スワイプ方向を表す列挙型
enum SwipeDirection {
    case left
    case right
    case up
    case down
}

class GameScene: SKScene {
    //    TextureNameを格納する変数
    var textureName: String?
    //    PlayerNodeを格納する変数
    var playerNode: SKSpriteNode!
    //    ドラッグ開始位置を格納する変数
    var beforeDragPosition: CGPoint?
    //    正面のTextureNameを格納する変数
    var frontTextureName: String?
    //    左移動時のTextureNameを格納する変数
    var lMoveTextureName: String?
    //    右移動時のTextureNameを格納するの変数
    var rMoveTextureName: String?
    //    インパルスペクトを格納する変数
    var impulse: CGVector?
    //    スワイプ方向を検知
    private func detectSwipeDirection(from start: CGPoint, to end: CGPoint) -> SwipeDirection? {
        let deltaX = end.x - start.x
        let deltaY = end.y - start.y
        //        スワイプ方向を計算
        let angle = atan2(deltaY, deltaX)
        let absoluteAngle = abs(angle)
        //        スワイプ方向を決定
        if absoluteAngle < .pi / 4 {
            return .right
        } else if absoluteAngle > .pi / 4 && absoluteAngle < .pi * 3 / 4 {
            return deltaY > 0 ? .up : .down
        } else {
            return deltaX > 0 ? .right : .left
        }
        
    }
    //    Sceneが初めてメモリーに読み込まれた時の処理
    override func sceneDidLoad() {
        super.sceneDidLoad()
        //        初期化処理などをここに記述
        
        
    }
    //    SceneがViewに追加された時に呼びだされた時の処理(Sceneの初期化や表示オブジェクト)
    override func didMove(to view: SKView) {
        
        textureName = "Cat"
        //        セットされたTextureがあるか確認し、あれば適用する
        if let textureName = textureName {
            print("Transition TextureName:\(textureName)")
            
            showPlayer(imageName: textureName)
        }
    }
    //    タッチが始まった時に呼ばれるメソッﾄﾞ
    func touchDown(atPoint pos : CGPoint) {
        //        タッチ開始時の処理をここに記述
        
    }
    //    ドラッグ中のに呼ばれるメソッド
    func touchMoved(toPoint pos : CGPoint) {
        
        if let p1 = self.beforeDragPosition {
            let deltaX = pos.x - p1.x
            let deltaY = pos.y - p1.y
            //        playerの位置をpos.x・pos.yで移動
            self.playerNode.position.x += deltaX
            self.playerNode.position.y += deltaY
            //            左右の移動範囲の最大値を決める
            if !(self.playerNode.position.x < self.size.width / 2 && self.playerNode.position.x > -self.size.width / 2) {
                self.playerNode.position.x -= deltaX
            }
            //            上下の移動範囲の最大値を決める
            if !(self.playerNode.position.y < self.size.height / 2 && self.playerNode.position.y > -self.size.height / 2) {
                self.playerNode.position.y -= deltaY
            }
            //            スワイプの方向を検知
            let swipeDirection = detectSwipeDirection(from: p1, to: pos)
            //            スワイプ方向に応じた処理
            switch swipeDirection {
            case .left:
                //                左への動きを検知した時の処理
                print("Swipe left!")
                guard let leftImageName = lMoveTextureName else { return }
                playerNode.texture = SKTexture(imageNamed: leftImageName)
                
                let playerDirection = atan2(deltaY, deltaX)
                
                let directionX = cos(playerDirection)
                let directionY = sin(playerDirection)
                
                print("Player Direction : \(directionX),\(directionY)")
                impulse = CGVector(dx: directionX, dy: directionY)
                
            case .right:
                //                右への動きを検知した時の処理
                print("Swipe right!")
                guard let rightImageName = rMoveTextureName else { return }
                playerNode.texture = SKTexture(imageNamed: rightImageName)
                
                let playerDirection = atan2(deltaY, deltaX)
                
                let directionX = cos(playerDirection)
                let directionY = sin(playerDirection)
                
                print("Player Direction : \(directionX),\(directionY)")
                impulse = CGVector(dx: directionX, dy: directionY)
                
            case .up:
                print("Swipe up!")
                
                let playerDirection = atan2(deltaY, deltaX)
                
                let directionX = cos(playerDirection)
                let directionY = sin(playerDirection)
                
                print("Player Direction : \(directionX),\(directionY)")
                impulse = CGVector(dx: directionX, dy: directionY)
                
            case .down:
                print("Swipe down!")
                let playerDirection = atan2(deltaY, deltaX)
                
                let directionX = cos(playerDirection)
                let directionY = sin(playerDirection)
                
                print("Player Direction : \(directionX),\(directionY)")
                impulse = CGVector(dx: directionX, dy: directionY)
                
            case .none:
                print("Swipe Data No!")
            }
        }
        //        現在のドラッグ位置を保存
        self.beforeDragPosition = pos
    }
    //    タッチが完了した時に呼ばれるメソッド
    func touchUp(atPoint pos : CGPoint) {
        //    ドラッグ位置をリセット
        self.beforeDragPosition = nil
    }
    //    タッチが開始された時に呼ばれるメソッド
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        //        タッチ開始時の処理をここに記述
        
    }
    //    ドラッグした時に呼ばれる
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        
        guard let touch = touches.first else { return }
        
        self.touchMoved(toPoint: touch.location(in: self))
    }
    //    ドラッグを離した時に呼ばれる
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        
        if let touch = touches.first as UITouch? {
            let location = touch.location(in: self)
            self.touchUp(atPoint: location)
            
            print("Drag touhesEnded")
            
            //            ドラッグを話した時、正面のテクスチャをセット
            guard let imageName = frontTextureName else { return }
            playerNode.texture = SKTexture(imageNamed: imageName)
        }
    }
    //    タッチがキャンセルされた時に呼ばれるメソッド
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        //        タッチキャンセル時の処理をここに記述
        
        
    }
    
}
```
これで準備ができました。さっそくテストしていこうと思います。

## ・サンプル動画

![IMG_3614.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808574/62b37f1e-9a30-9e80-2472-1d2002809dfe.gif)

ドラッグに反応して左右のイメージを表示し、ドラッグを離すと表面を向いたイメージが表示されます。無事イメージ通りに表示されたようです。

## おわりに
比較的簡単にキャラクター描画やドラッグ検知の処理が書けました。SpriteKitを使って引き続き2Dゲーム制作の勉強を続けていきたいと思います。