iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0
Mobile Development

從零開始的8-bit迷宮探險!Swift SpriteKit 遊戲開發實戰系列 第 14

從零開始的8-bit迷宮探險【Level 14】讓主角奔跑吧!Running Sam

適應了黑森林的孤寂,山姆開始這趟旅程的目的:找尋水晶。
森林雖然漆黑,但是路還算好走,山姆的腳步也開始輕盈了起來。
「這個路口往左,直直走之後往右...。」
「疑?這裡是一小時前就經過的地方,地上還有我做的記號。」山姆面色開始凝重,瀰漫一股不祥的預感。
「難道說,這座森林有結界?」

今日目標

  • 新增方向按鈕,能控制主角往上下左右移動
  • 判斷可行走與不可行走 (牆壁) 的路
  • 播放各方向的主角序列動畫

PS. 這裡是開發 iOS 手機遊戲的系列文,如果還沒看過之前 劇情 文章的朋友,歡迎先點這邊回顧唷!


新增方向的列舉

新增 Direction 列舉,把方向名稱定義出來,NONE 為無方向,其餘為四種方向,包含

  • Direction.LEFT:左
  • Direction.RIGHT:右
  • Direction.UP:上
  • Direction.DOWN:下
  • GameCharacter.swift
enum Direction: String {
    case NONE = "none"
    case LEFT = "left"
    case RIGHT = "right"
    case UP = "up"
    case DOWN = "down"
}

新增移動資訊

在 GameCharacter 類別加上移動資訊,包含:

  • direction:紀錄當前移動方向,初始先給 .NONE
  • isMoving:紀錄是否正在移動,初始先給 false
  • moveX:紀錄在目前座標系中,各移動方向的 x 軸需加減的數值
  • moveY:紀錄在目前座標系中,各移動方向的 y 軸需加減的數值
  • moveInterval:紀錄目前的移動間隔秒數,代表移動一格所需的時間,預設給 0.2 (秒)

在建構子 (init) 中,將 moveX、moveY 定義好各方向移動一格 (寬度為 gridWH) 需加減的數值

  • 往左:負
  • 往右:正
  • 往上:負
  • 往下:正
  • GameCharacter.swift
class GameCharacter {
    var direction: Direction = .NONE
    var isMoving: Bool = false
    var moveX: [Direction: CGFloat]
    var moveY: [Direction: CGFloat]
    var moveInterval = 0.2
    
    init(gridWH: Int, startGridX: Int, startGridY: Int, imageName: String, zPosition: CGFloat, role: Role) {
        self.moveX = [
            Direction.LEFT: -CGFloat(gridWH),
            Direction.RIGHT: CGFloat(gridWH),
            Direction.UP: 0,
            Direction.DOWN: 0,
            Direction.NONE: 0,
        ]
        self.moveY = [
            Direction.LEFT: 0,
            Direction.RIGHT: 0,
            Direction.UP: CGFloat(gridWH),
            Direction.DOWN: -CGFloat(gridWH),
            Direction.NONE: 0,
        ]
    }
}

判斷是否可以行走

新增判斷可行方向的方法

  • getValidDirection:回傳可以走的方向陣列
  • 透過 filter 搭配 isWall 方法,找出不是牆的方向

新增判斷是否為牆壁的方法

  • isWall:回傳 truefalse
  • 使用先前建立好的 map 陣列,搭配目前的 gridXgridY 格子點,找出各方向對應位置的值,判斷是否為 "w"
  • 穿梭:因地圖的左右各有捷徑通道,從地圖左邊可以穿梭到右邊,右邊也可以穿梭到左邊,因此對於這幾個特殊的位置搭配對應的方向,會回傳不是牆壁
  • GameCharacter.swift
func getValidDirection()-> [Direction] {
    let direction = [
        Direction.LEFT,
        Direction.RIGHT,
        Direction.UP,
        Direction.DOWN
    ]
    let validDirection = direction.filter({
        self.isWall(dir: $0) == false
    })
    return validDirection
}
func isWall(dir: Direction) -> Bool {
    switch dir {
    case .LEFT:
        let mapRowArr = Array(map[gridY]);
        // 穿梭
        if ((gridX == gridMapping.leftPass1.x && gridY == gridMapping.leftPass1.y) || (gridX == gridMapping.leftPass2.x && gridY == gridMapping.leftPass2.y)) {
            return false
        }
        if (gridX - 1 < 0) {
            return true
        }
        return mapRowArr[gridX - 1] == "w"
    case .RIGHT:
        let mapRowArr = Array(map[gridY]);
        // 穿梭
        if (gridX == gridMapping.rightPass1.x && gridY == gridMapping.rightPass1.y) {
            return false
        }
        if (gridX + 1 >= mapRowArr.count) {
            return true
        }
        return mapRowArr[gridX + 1] == "w"
    case .UP:
        if (gridY - 1 < 0) {
            return true
        }
        let mapRowArr = Array(map[gridY - 1]);
        if (gridX < 0 || gridX >= mapRowArr.count) {
            return true
        }
        return mapRowArr[gridX] == "w"
    case .DOWN:
        if (gridY + 1 >= map.count) {
            return true
        }
        let mapRowArr = Array(map[gridY + 1]);
        if (gridX < 0 || gridX >= mapRowArr.count) {
            return true
        }
        return mapRowArr[gridX] == "w"
    default:
        return true
    }
}

宣告左右可穿梭的位置點

  • GameScene.swift
struct gridMapping {
    struct leftPass1 {
        static let x = 0
        static let y = 1
    }
    struct leftPass2 {
        static let x = 0
        static let y = 16
    }
    struct rightPass1 {
        static let x = 16
        static let y = 11
    }
}

新增移動方法的 protocol

有了判斷是否可以行走的方法後,就可以開始來寫移動動畫了
由於主角跟怪物都會移動,但是移動的方式不同:

  • 主角:根據玩家按下的方向按鈕移動,會持續此方向移動直到撞到牆壁才停止,玩家可隨時按下不同的方向按鈕,但是撞到牆壁的方向不會有反應
  • 怪物:自動偵測方向移動

因此我們可以新增同樣名稱,但是不同實作的移動方法,請新增 protocol Move

  • startMove:給角色一個方向,讓他開始移動 (播放移動動畫)
  • endMove:移動動畫播放完成
  • GameCharacter.swift
protocol Move {
    func startMove(direction: Direction)
    func endMove()
}

實作方法

請在 Sam 類別中,遵循 Move protocol,並且實作方法

  • startMove:
    • 先判斷收到的方向 direction 是否為可行的方向
    • 如果是可行的方向
      • 儲存 direction,並且將 isMoving 設定為 true
      • 先處理地圖左右通道可以穿梭移動的部分,當移動到邊界點時,將角色瞬移到地圖的另一邊,並且往地圖外多移動一格,讓他有從外面進入的感覺。左邊有兩個可以進入的通道,用 randomElement 隨機選取一個。最後改變 node 的 position
      • 使用 SKAction.moveBy 播放移動動畫,xy 需帶入移動的向量,duration 帶入移動間隔秒數。並透過 .run 執行動畫,當動畫完成時,執行 endMove 方法
      • 設定該方向的格子 x、y
    • 如果是不可行的方向,則將 direction 設定為 Direction.NONE,並且將 isMoving 設定為 false,不再播放移動動畫,停止移動
  • endMove:呼叫 startMove 方法,持續播放下一格的移動動畫
  • sam.swift
class Sam: GameCharacter, Move {
    ...
    func startMove(direction: Direction) {
        let validDirection = self.getValidDirection()
        if (validDirection.contains(direction)) {
            self.direction = direction
            self.isMoving = true
            
            // 左右穿梭
            if ((gridX == gridMapping.leftPass1.x && gridY == gridMapping.leftPass1.y && direction == .LEFT) || (gridX == gridMapping.leftPass2.x && gridY == gridMapping.leftPass2.y && direction == .LEFT) || (gridX == gridMapping.rightPass1.x && gridY == gridMapping.rightPass1.y && direction == .RIGHT)) {
                self.gridX = direction == .LEFT ? gridMapping.rightPass1.x + 1 : gridMapping.leftPass1.x - 1
                self.gridY = direction == .LEFT ? gridMapping.rightPass1.y : [gridMapping.leftPass1.y, gridMapping.leftPass2.y].randomElement()!
                self.node.position = CGPoint(x: (gridX * gridWH) + (gridWH/2), y: -gridY * gridWH - (gridWH/2))
            }
            
            // 播放移動動畫
            let animation = SKAction.moveBy(x: self.moveX[direction]!, y: self.moveY[direction]!, duration: self.moveInterval)
            self.node.run(animation, completion: endMove)
            
            // 設定格子
            self.setGridXY(direction: self.direction)
        } else {
            self.direction = Direction.NONE
            self.isMoving = false
        }
    }
    func endMove() {
        self.startMove(direction: self.direction)
    }
}

設定格子點的 x、y,可以對照地圖陣列 map,方便我們紀錄角色移動後的位置

  • GameCharacter.swift
func setGridXY(direction: Direction) {
    switch self.direction {
    case .LEFT:
        self.gridX -= 1;
    case .RIGHT:
        self.gridX += 1;
    case .UP:
        self.gridY -= 1;
    case .DOWN:
        self.gridY += 1;
    case .NONE:
        break
    }
}

給個方向移動吧

我們在主角的類別中,先寫上點擊方向按鈕後要觸發的方法 setDirection
由於方向按鈕是隨時都可以點擊的,所以先判斷點擊的方向是否為可行的路,如果不可行的話就忽略它,在這邊我們印出 "此路不通!!"。如果是可行的,就將方向存到 self.direction
在遊戲開始時,角色是靜止的,直到玩家按下方向按鈕才會開始朝著某方向持續前進,因此若判斷 isMovingfalse 時,我們就呼叫開始移動的方法 startMove,如果已經正在移動了,就只需要設定新的方向值,移動動畫會自動接續朝著新的方向移動。

  • Sam.swift
func setDirection(direction: Direction) {
    let validDirection = self.getValidDirection()
    if (validDirection.contains(direction)) {
        self.direction = direction             
        if (!self.isMoving) {
            self.startMove(direction: direction)
        }
    } else {
        print("此路不通!!")
    }
}

在畫面上加入按鈕

這邊來介紹直接在 GameScene.sks 上加 node,由程式碼取得 node 資訊的方式

  • 請點擊右上角的 + ,拖拉 Color Sprite 到 Scene 中
    https://imgur.com/0b1GnLy.png

  • 新增一個 btns node,裡頭新增四個按鈕 node,可以依照下圖設定屬性

    • Texture 可以選擇顯示的圖片
    • Rotation 可以旋轉圖片角度
      https://imgur.com/RlduDf0.png
      https://imgur.com/9fU80s2.png
      https://imgur.com/98gOiAv.png
      https://imgur.com/M5dITcZ.png
      https://imgur.com/vz30b2O.png

調整按鈕位置

回到程式碼的地方,將按鈕位置置底置中

  • 在之前新增的 applySafeArea 方法中調整位置
  • 使用 childNode 找到場景中的按鈕外層 node:btns
  • bottomSafeArea 校正回來
  • 由於 btnNode 的 anchorPoint 是 (0.5, 0.5),因此還需要加上自身高度的一半 (150/2)
  • GameScene.swift
class GameScene: SKScene {
    ...
    func applySafeArea() {
        ...
        if let btnNode = self.childNode(withName: "//btns") as? SKSpriteNode {
            btnNode.position.x = self.size.width / 2
            btnNode.position.y = -self.size.height + bottomSafeArea + 75
        }
    }
}

點擊按鈕

接著寫上偵測點擊按鈕的方法

  • 覆寫 touchesBegan
  • 使用 childNode 找到場景中的四個方向按鈕
  • 在點擊到四個按鈕的時候,分別對主角設定對應的方向 setDirection
  • GameScene.swift
class GameScene: SKScene {
    ...
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {        
        guard let sam = self.sam else {
            return
        }
        let btnLeft = self.childNode(withName: "//left") as? SKSpriteNode
        let btnRight = self.childNode(withName: "//right") as? SKSpriteNode
        let btnUp = self.childNode(withName: "//up") as? SKSpriteNode
        let btnDown = self.childNode(withName: "//down") as? SKSpriteNode
        for touch in (touches) {
            let location = touch.location(in: self)
            if self.atPoint(location) == btnLeft {
                sam.setDirection(direction: Direction.LEFT)
            }
            if self.atPoint(location) == btnRight {
                sam.setDirection(direction: Direction.RIGHT)
            }
            if self.atPoint(location) == btnUp {
                sam.setDirection(direction: Direction.UP)
            }
            if self.atPoint(location) == btnDown {
                sam.setDirection(direction: Direction.DOWN)
            }
        }
    }
}

我們來看一下目前的成果吧!
https://imgur.com/JK5kfBa.gif

主角可以按照我們點的按鈕移動囉,而且遇到牆壁也能成功停下來,也能穿梭到地圖的另一邊!
但是看起來還少了點什麼,角色是用平移的方式走路,我們來幫他換個方向吧


播放角色對應方向的動畫

目前我們已經有正面的序列圖,我們繼續把其他方向的圖補完
https://imgur.com/RGCHZxT.png
https://imgur.com/cQxGRMy.png

將圖片命名好:

  • 左:sam_left_1、sam_left_2
  • 右:sam_right_1、sam_right_2
  • 上:sam_up_1、sam_up_2
  • 下:sam_down_1、sam_down_2

播放圖片序列動畫方法

在角色類別中寫個共用的播放圖片序列動畫方法:

  • imageName:圖片名稱
  • num:圖片數量
  • repeatAni:是否重複播放,預設為 true
  • 新增一個 sequences,存入 SKTexture 類別的圖片序列
  • 如果需要重複播放,則使用 repeatForever
  • GameCharacter.swift
func playAnimation(imageName: String, num: Int, repeatAni: Bool = true) {
    var sequences: [SKTexture] = []
    for index in 1...num {
        let sequence = SKTexture(imageNamed: imageName + "_" + String(index))
        sequences.append(sequence)
    }
    let ani = SKAction.animate(with: sequences, timePerFrame: 0.4)
    if repeatAni {
        let aniRepeat = SKAction.repeatForever(ani)
        self.node.run(aniRepeat, withKey: "sequence")
        return
    }
    self.node.run(ani, withKey: "sequence")
}

呼叫播放動畫

在剛剛的按鈕點擊方法中,呼叫 playAnimation,並帶入對應方向的圖片名稱

  • GameScene.swift
for touch in (touches) {
    let location = touch.location(in: self)
    if self.atPoint(location) == btnLeft {
        sam.setDirection(direction: Direction.LEFT)
        sam.playAnimation(imageName: "sam_left", num: 2)
    }
    if self.atPoint(location) == btnRight {
        sam.setDirection(direction: Direction.RIGHT)
        sam.playAnimation(imageName: "sam_right", num: 2)
    }
    if self.atPoint(location) == btnUp {
        sam.setDirection(direction: Direction.UP)
        sam.playAnimation(imageName: "sam_up", num: 2)
    }
    if self.atPoint(location) == btnDown {
        sam.setDirection(direction: Direction.DOWN)
        sam.playAnimation(imageName: "sam_down", num: 2)
    }
}

來看看成果吧!
https://imgur.com/buPSWd7.gif


參考來源:
SpriteKit SKAction moveBy
SpriteKit childNode


上一篇
從零開始的8-bit迷宮探險【Level 13】主角總是孤獨的
下一篇
從零開始的8-bit迷宮探險【Level 15】迷人的反派角色-製作怪物
系列文
從零開始的8-bit迷宮探險!Swift SpriteKit 遊戲開發實戰30

尚未有邦友留言

立即登入留言