【入門】iOS アプリ開発　#7【敵キャラクタの移動】

# はじめに

今回はパックマンに登場する敵キャラクタを作成する。以下が完成動画。
作成した GameGhost.swift ファイルは、コードが 1000行程度になり全ては説明できないため、[GitHub](https://github.com/Kikutada/0005_EnemyTest) に公開しているものを参照してほしい。

※YouTube動画
[![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/wqbIVGE-dBI/0.jpg)](http://www.youtube.com/watch?v=wqbIVGE-dBI)

テスト動作として、下記の仕様書にある「ノーイートタイム」を過ぎると、ゴースト（モンスター）が巣から出てきて攻撃中となるようにしている。そうでない時は休息中となる。またバッテン表示はデバッグ用としてゴーストの目標位置を示している。

# 仕様書

仕様書にはゴーストの出現タイミングや、それぞれ異なる４匹の性格仕様が記述されている。

![Image2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678332/28c4bb5a-6161-3d42-c5da-ad4d7d32c0f5.png)

![Image31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678332/883c7332-3a5b-1487-055d-1314ff5d1289.png)


整理していくと、以下のように全てのゴーストには共通の動作や状態があることがわかる。

* プレイフィールド中（巣から出た状態）のゴーストの移動は、攻撃中と休息中の２つの状態がある
* パックマンがパワーエサを食べた時、ゴーストはイジケ顔になってランダムに移動する
* パックマンがゴーストに噛みついた時、ゴーストは目玉だけとなって巣に逃げる
* ゴーストは特定のタイミングや条件によって巣から出てくる

これらは共通のクラスで実装し、攻撃中と休息中におけるゴーストの特徴の違いを派生クラスで実装するのが簡単そうだ。

# キャラクタ・クラスの構成

下図のようなクラス継承構造で設計する。

![ImageClass.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678332/d8a19243-959b-4185-b972-4a022ca8deb2.png)

各クラスの役割を補足すると、以下の通り。

* CbObject : イベントをハンドリングする
* CbContainer : CbObject（と派生クラス）を複数保持できるコンテナ
* CgActor : キャラクタ共通の位置や方向、スプライト処理を扱う
* CgGhost : ゴースト共通の処理を扱う 
* CgGhostBlinky : アカ・ゴースト
* CgGhostPinky : ピンク・ゴースト
* CgGhostInky : シアン・ゴースト
* CgGhostClyde : オレンジ・ゴースト
* CgPlayer : パックマン

# CgGhost クラスの設計

ゴースト共通の動作や状態をステート・チャートにまとめてみた。

![Image03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678332/63d318b1-44f6-c077-ca85-ddd6f978d120.png)


* Standby は巣にいる状態
* GoOut は巣からプレイフィールドに出る状態
* Chase は攻撃中状態
* Scatter は休息中状態
* Frightened はイジケ状態
* Escape は目玉になって巣に戻る状態
* EscapeInNest は目玉の状態で巣に戻って、スタート位置まで戻る状態

Frightened は Standby, GoOut, Chase, Scatter をサブ状態とするスーパー状態となっている。

ソースコードは、フレーム毎に呼ばれる update メソッドをオーバーライドして、ステートマシンを実装していく。状態に入る時の entryアクション、状態に入っている時のアクティビティを一つ一つ作り込んで、ゴーストの動きを作成する。

```swift
class CgGhost : CgActor {

    enum EnGhostAction {
        case None, Walking, Spurting, Frightened, Warping, Standby, GoingOut, Escaping
    }

    enum EnMovementRestrictions {
        case None, OnlyVertical
    }

    var target: CgPosition = CgPosition()
    var state: CgGhostState!

    private var movementRestriction: EnMovementRestrictions = .None

    override init(binding object: CgSceneFrame, deligateActor: ActorDeligate) {
        super.init(binding: object, deligateActor: deligateActor)
        state = CgGhostState(binding: self)
        enabled = false
    }

    // ============================================================
    //   Core operation methods for actor
    //  - Sequence: reset()->start()->update() called->stop()
    // ============================================================
    /// Reset ghost states and draw at default position
    override func reset() {
        super.reset()
        direction.reset()
        state.reset()
        movementRestriction = .None
    }

    /// Start
    override func start() {
        super.start()
        draw()
    }
    
    /// Stop
    override func stop() {
        super.stop()
        draw()
    }
    
    /// Update handler
    /// - Parameter interval: Interval time(ms) to update
    override func update(interval: Int) {

        //
        // Entry Action to state
        //
        if state.isChanging() {
            switch state.getNext() {
                case .Init : break
                case .Standby: entryActionToStandby()
                case .GoOut: entryActionToGoOut()
                case .Scatter: entryActionToScatter()
                case .Chase: entryActionToChase()
                case .Escape: entryActionToEscape()
                case .EscapeInNest: entryActionToEscapeInNest()
            }
            state.update()
        }

        //
        // Do Action in state
        //
        switch state.get() {
            case .Init : break
            case .Standby: doActionInStandby()
            case .GoOut:  doActionInGoOut()
            case .Scatter:
                if state.isFrightened() {
                    doActionInFrightened()
                } else {
                    doActionInScatter()
                }
            case .Chase:
                if state.isFrightened() {
                    doActionInFrightened()
                } else {
                    doActionInChase()
                }
            case .Escape:  doActionInEscape()
            case .EscapeInNest:  doActionInEscapeInNest()
        }

        //  Update direction and sprite animation for changes.
        if state.isChanging() || direction.isChanging() || state.isDrawingUpdated() {
            if direction.isChanging() {
                position.roundDown()
                direction.update()
            }
            draw()
            state.clearDrawingUpdate()
        }

        // Update position.
        sprite.setPosition(sprite_number, x: position.x, y: position.y)
    }
 
    // 以下、省略   
}
```

Frightenedのスーパー状態は、別の CgGhostState クラスを作成して実現している。
state.isFrightened()　が true ならイジケ状態で、scatter と chase状態において、doActionInFrightened() メソッド内でゴーストのランダム移動処理を行う。

# CgGhostXxxx 敵キャラクタ・クラスの設計

CgGhost クラスを継承して、４匹のゴーストのスタート位置や攻撃動作を実装していく。

```swift
/// Ghost Blinky class
class CgGhostBlinky : CgGhost {

    override init(binding object: CgSceneFrame, deligateActor: ActorDeligate) {
        super.init(binding: object, deligateActor: deligateActor)
        actor = .Blinky
        sprite_number = actor.getSpriteNumber()
    }
    
    // ============================================================
    //   Core operation methods for actor
    //  - Sequence: reset()->start()->update() called->stop()
    // ============================================================
    /// Reset ghost state and draw at default position
    override func reset() {
        super.reset()
        position.set(column: 13, row: 21, dx: 4)
        updateDirection(to: .Left)
        state.set(to: .Scatter)
        draw()
    }

    // ============================================================
    //  General methods in this class
    // ============================================================
    /// Chase player to enter chase state.
    /// Always chase the Pacman during the chase mode.
    /// - Parameter playerPosition: Player's position
    func chase(playerPosition: CgPosition) {
        super.setStateToChase(targetPosition: playerPosition)
    }
    
    /// Set the target position in scatter state.
    /// Blinky moves around the upper right in the play field.
    override func entryActionToScatter() {
        target.set(column: 25, row: 35)
        super.entryActionToScatter()
    }

    /// Set return destination in nest from escape state.
    override func entryActionToEscapeInNest() {
        target.set(column: 13, row: 18, dx: 4, dy: -4)
    }

}

/// Ghost Pinky class
class CgGhostPinky : CgGhost {
    
    override init(binding object: CgSceneFrame, deligateActor: ActorDeligate) {
        super.init(binding: object, deligateActor: deligateActor)
        actor = .Pinky
        sprite_number = actor.getSpriteNumber()
    }

    // ============================================================
    //   Core operation methods for actor
    //  - Sequence: reset()->start()->update() called->stop()
    // ============================================================
    /// Reset ghost state and draw at default position
    override func reset() {
        super.reset()
        position.set(column: 13, row: 18, dx: 4)
        updateDirection(to: .Down)
        state.set(to: .Standby)
        draw()
    }

    // ============================================================
    //  General methods in this class
    // ============================================================
    /// Chase player to enter chase state.
    /// Aiming for Pacman's third destination
    /// - Parameters:
    ///   - playerPosition: Player's position
    ///   - playerDirection: Player's direction
    func chase(playerPosition: CgPosition, playerDirection: EnDirection) {
        let dx = playerDirection.getHorizaontalDelta()*3
        let dy = playerDirection.getVerticalDelta()*3
        let newTargetPosition = CgPosition(column: playerPosition.column+dx, row: playerPosition.row+dy)
        super.setStateToChase(targetPosition: newTargetPosition)
    }
    
    /// Set the direction in standby state.
    override func entryActionToStandby() {
        updateDirection(to: .Down)
    }

    /// Set the target position in scatter state.
    /// Pinky moves around the upper left on the play field.
    override func entryActionToScatter() {
        target.set(column: 2, row: 35)
        super.entryActionToScatter()
    }
    
    /// Set return destination in nest from escape state.
    override func entryActionToEscapeInNest() {
        target.set(column: 13, row: 18, dx: 4, dy: -4)
    }

}

//  以下、省略

```

resetメソッドは、初期スタート位置を設定し、Init状態からの初期の状態遷移を行う。
- Blinky（アカ）の場合は、巣の上からスタートし Scatter状態へ遷移する。
- Pinky（ピンク）の場合は、巣の中からスタートし、Standby状態へ遷移する。

chaseメソッドは、目標位置に向けて移動する攻撃中の状態へと遷移する。
- Blinky（アカ）の場合は、パックマンの位置を目標位置とする。
- Pinky（ピンク）の場合は、パックマンの口先の３つ先のマスを目標位置とする。

entryActionToScatterメソッドは、休息中の状態へ遷移する。
- Blinky（アカ）の場合は、プレイフィールド上の右上を目標位置とする。
- Pinky（ピンク）の場合は、プレイフィールド上の左上を目標位置とする。

entryActionToEscapeInNestメソッドは、目玉の状態で巣に戻った時のスタート位置を目標位置として設定する。

といった具合に、CgGhostクラスの基底メソッドをオーバーライドして実装していく。

# ゴーストのシーケンス起動

作成したゴーストのクラスをシーケンスに実装して動かしていく。

プレイヤーの時と同様に、CgSceneMaze迷路シーンクラスにゴーストのクラス CgGhostXxxx を関連付け(binding)して生成し、イベントが通知できるようにする。

シーケンス実装の handleSequenceメソッド内でゴーストを ghost.start()　
（enabled=true）にすると、CgGhost内の updateメソッドがフレーム毎に呼ばれてステートマシンが実行される仕組みとなる。

```swift
class CgSceneMaze: CgSceneFrame, ActorDeligate {

    var player : CgPlayer!
    var blinky : CgGhostBlinky!
    var pinky  : CgGhostPinky!
    var inky   : CgGhostInky!
    var clyde  : CgGhostClyde!
    var allGhosts : [CgGhost] = []

    convenience init(object: CgSceneFrame) {
        self.init(binding: object, context: object.context, sprite: object.sprite, background: object.background, sound: object.sound)
        player = CgPlayer(binding: self, deligateActor: self)
        blinky = CgGhostBlinky(binding: self, deligateActor: self)
        pinky  = CgGhostPinky(binding: self, deligateActor: self)
        inky   = CgGhostInky(binding: self, deligateActor: self)
        clyde  = CgGhostClyde(binding: self, deligateActor: self)
        allGhosts.append(blinky)
        allGhosts.append(pinky)
        allGhosts.append(inky)
        allGhosts.append(clyde)
    }

    /// Handle sequence
    /// To override in a derived class.
    /// - Parameter sequence: Sequence number
    /// - Returns: If true, continue the sequence, if not, end the sequence.
    override func handleSequence(sequence: Int) -> Bool {
        switch sequence {
            case  0:
                drawBackground()
                let _ = setAndDraw()
                printPlayers()

                player.reset()
                for ghost in allGhosts { ghost.reset() }
                goToNextSequence()
            
            case 1:
                printBlinking1Up()
                drawPowerFeed(state: .Blinking)

                player.start()
                for ghost in allGhosts { ghost.start() }
                goToNextSequence()
            
            case  2:
                // ===========
                // Foever loop
                // ===========
                
                // Player checks to collide ghost.
                for ghost in allGhosts {
                    if player.detectCollision(ghostPosition: ghost.position) {
                        if ghost.state.isFrightened()  {
                            ghost.setStateToEscape()
                        }
                    }
                }

                // Set the chase state after after the time that the player doesn't eat feed.
                if player.timer_playerNotToEat.isEventFired()  {
                    blinky.chase(playerPosition: player.position)
                    pinky.chase(playerPosition: player.position, playerDirection: player.direction.get())
                    inky.chase(playerPosition: player.position, blinkyPosition: blinky.position)
                    clyde.chase(playerPosition: player.position)

                    for ghost in allGhosts { ghost.setStateToGoOut() }

                } else {
                    for ghost in allGhosts { ghost.setStateToScatter() }
                }
                
                // For debug
                for ghost in allGhosts { ghost.drawTargetPosition(show: true) }
                break

            default:
                // Stop and exit running sequence.
                return false
        }
        
        // Play BGM
        if sequence >= 2 {
            if player.timer_playerWithPower.isCounting() {
                sound.playBGM(.BgmPower)
            } else {
                sound.playBGM(.BgmNormal)
            }
        }
        
        // Continue running sequence.
        return true
    }
```

無限ループの case2 にテスト動作を実装している。

「ノーイートタイム（= player.timer_playerNotToEat.isEventFired()）」を過ぎると、ゴースト（モンスター）が巣から出てきて攻撃中（ =  blinky.chase）となるようにしている。またゴーストが巣にいる時は外に出す（ = ghost.setStateToGoOut()）。
ノーイートタイムにならない間は、休息中（ = ghost.setStateToScatter()）としている。


# まとめ

今回、パックマンの敵キャラクタの移動を作成した。ソースコードは全体で 4000行程度になっている。

細かい仕様が多く定義されていて、ゲームを面白くするための企画者のこだわりが感じられる。反面プログラマーは良く考えて設計していかないと、コードは大変な状況になりそうだ。Swiftでゲームアプリを開発する入門としては、意外と良い題材なのかもしれない。

次回は、スペシャルターゲットやパックマンがゴーストを噛み付いた時の得点表示、パックマンがゴーストに捕まった処理などを作成していく。

# 次の記事
[【入門】iOS アプリ開発　#8【スペシャルターゲット・得点表示】]
(https://qiita.com/KIKU_CHU/items/c32c2fe49915055041dd)