从零开始的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


<<:  Day-10 近水楼台先得月

>>:  [Day 21] 针对API的单元测试(一)

CLOUDWAYS虚拟主机限时首二月7折优惠码,只到2021/9/5

优惠码SUMMER30 优惠时间:只到2021/9/5 折扣内容:首2个月7折(适用於所有方案) ☞...

问不用下载环境档便可执行的语言

在下还只是一位新手请大家鞭小力点 如果要执行用Visual Studio的C#或用python写出的...

Fit Leather Jackets

We are making your quest for VIP coats simpler by ...

Innodb资料页结构-Part1(使用者纪录、空闲空间、页面中最小与最大的纪录)

前文提到页是Innodb的基本存取单位,一般为16kb,Innodb为了实现功能其实设计了许多不同类...

[iT铁人赛Day27]练习题(6)

第二十七天来讲到第六题练习题 这题题目有点冗长,害得我当时都有点懒得看了 题目大意是:要写一个程序自...