从零开始的8-bit迷宫探险【Level 11】在 iPhone 里盖座迷宫,就。很。墙

黑森林的样貌正如其名,不管白天或黑夜,一但走进了森林里就伸手不见五指...
长老说:「少年,你确定要去吗?」
山姆坚定地说:「是的。」
「因为,这是身为探险家的必经之路啊!」
山姆背对着长老,挥了挥手,阳光洒落在他的帽檐上,特别耀眼。

今日目标

  • 绘制游戏的主要画面:迷宫
  • 调整画面以符合不同的载具
  • 学习使用 Safe Area

新增游戏专案

首先,我们先创建一个游戏模板的专案
https://imgur.com/qg6AmT4.png

选择 Game 模板
https://imgur.com/7mOjTu5.png

给专案一个很酷的名称
https://imgur.com/dXGm8Ku.png

选择好要创建的位置,点击 Create 就创建完成罗!

清除不需要的程序及档案

  • 清除 GameScene.sks 中的 helloLabel

  • 删除 Action.sks

  • GameScene.swift
    把不需要的程序码删除,只留下这些:

import SpriteKit

class GameScene: SKScene {
    override func didMove(to view: SKView) {
    }
}
  • GameViewController.swift
    将下列改为 false,即可把画面右下角的资讯隐藏
view.showsFPS = false
view.showsNodeCount = false

建置迷宫/地图

我们的目标是要做出可以让角色在迷宫内移动的地图,因此采用格子的方式来拼出迷宫,每个格子可以代表墙壁或是路,墙壁会放上不同种类的墙壁贴图,而路则可以让角色移动。
首先先决定要绘制的迷宫格子尺寸,我们将绘制:宽:17 格、高:23 格的地图。
先把地图的样子写出来吧!

定义阵列

使用阵列,画出 17 x 23 样子的地图,并且用符号表示:
(今天先处理画出墙壁的部分,其他後续的单元会说明)

  • w:墙壁
  • .:水晶
  • *:魔幻水晶
  • +:香菇
  • #:能隐身的树

除了 w (墙壁) 以外,都可以让角色行走
而选择使用一维阵列,主要是想让程序码看起来跟迷宫比较相近、简洁

  • GameScene.swift
let map = [ "wwwwwwwwwwwwwwwww",
            "   .....w*......w",
            "www.www.w.ww.ww.w",
            "  w.www....w.ww.w",
            "www.....ww.w....w",
            "w*...ww.ww...ww.w",
            "w.ww.ww....w....w",
            "w..w.ww.ww.w.wwww",
            "ww...       .w   ",
            "w..w.www www.w   ",
            "w.ww.w     w.wwww",
            "w+...wwwwwww.    ",
            "wwww.       .ww.w",
            "   w.ww.w#.w....w",
            "   w....ww.w.ww.w",
            "wwww.ww....w.ww.w",
            "    .ww.ww.w....w",
            "wwww....w....ww.w",
            "   w...ww.ww.w..w",
            "   w.w.........ww",
            "wwww.w.www.w.w.ww",
            "w*.............*w",
            "wwwwwwwwwwwwwwwww",
]

用表格表示可能会清楚些!以下是地图内所有的格子点
横的列,未来会定义 gridX 来表示 (范围0~16)
直的栏,未来会定义 gridY 来表示 (范围0~22)
透过 gridX 及 gridY 来记录角色或当前物体的所在位置

- 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16
00
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22

调整 GameScene 的 anchorPoint

先将 GameScene 的 anchorPoint 调整至左上角 (0, 1)

有两种方式:

  1. 可以在 GameScene.sks 的属性中修改
    https://imgur.com/xbWYN7I.png

  2. 可以在 GameViewController.swift 中加入

...
if let scene = SKScene(fileNamed: "GameScene") {
    ...
    scene.anchorPoint = CGPoint(x: 0, y: 1)
    ...
}

攥写 GameScene 类别

接着来写 GameScene 类别的内容。
设定好 gridXCountgridYCount 常数值。
计算格子的长度及宽度 gridWH 为 萤幕宽度/宽17格。
我们预计会加上许多格子 (格子为正方形),因此可以先建立一个父节点 mapNode,准备将这些格子加进父节点中。请先设定好它的宽跟高,并将 anchorPoint 定在左上角,加到场景里。

  • GameScene.swift
class GameScene: SKScene {
    var mapNode: SKSpriteNode?
    let gridXCount = 17
    let gridYCount = 23
    var gridWH = 0
    
    override func didMove(to view: SKView) {
        self.backgroundColor = .black
        self.gridWH = Int(self.size.width) / gridXCount
        
        self.mapNode = SKSpriteNode(color: .black, size: CGSize(width: CGFloat(self.size.width), height: CGFloat(self.gridWH * gridYCount)))
        self.mapNode!.anchorPoint = CGPoint(x: 0, y: 1)
        self.addChild(self.mapNode!)
    }
}

绘制地图方法

接着写绘制地图的方法 drawMap
for 回圈的第一层先跑Y轴,先取得列,接着将字串用 Array(map[i]) 转换成阵列,再依序取得字元。
我们先对 case "w" 贴上一张树的贴图,并设定宽高皆为 gridWH
因其 anchorPoint 为 (0.5, 0.5),而父节点的 anchorPoint 为 (0, 1),因此设定 position 的 x 应该为正数,而 y 为负数。除了加/减自己的长度乘以阵列位置的 index,还需加/减回自身长度的一半。
最後加进 mapNode 中。
附注:图片档案需拖拉至左侧档案列表的 Assets.xcassets 里。

  • GameScene.swift
func drawMap() {
    for i in 0..<gridYCount {
        let mapRowArr = Array(map[i])
        for j in 0..<gridXCount {
            switch mapRowArr[j] {
            case "w":
                let spriteItem = SKSpriteNode(imageNamed: "tree-green")
                spriteItem.anchorPoint = CGPoint(x: 0.5, y: 0.5)
                spriteItem.size.width = CGFloat(gridWH)
                spriteItem.size.height = CGFloat(gridWH)
                spriteItem.position = CGPoint(x: gridWH * j + (gridWH/2), y: -gridWH * i - (gridWH/2))
                self.mapNode!.addChild(spriteItem)
            default:
                break
            }
        }
    }
}

最後呼叫绘制地图的方法:

  • GameScene.swift
override func didMove(to view: SKView) {
    ...
    self.drawMap()
}

运行模拟器後可以看到以下成果:
https://imgur.com/18khzkR.png

画面微调

我们来观察用不同模拟器开启的结果

  • [左]使用iPhone 8运行:上方画面被时间列盖住,画面比例正常
  • [右]使用iPhone 11运行:上方画面被浏海盖住,画面超出萤幕
    https://imgur.com/GTeYYAB.png

超出萤幕了!

首先,先解决画面超出萤幕的问题,原因在於手机的尺寸不同
我们印出场景的 size

  • iPhone 8:(375.0, 667.0)
  • iPhone 11:(414.0, 896.0)

打开 GameScene.sks,发现场景的尺寸是 750x1334
https://imgur.com/xbWYN7I.png

由於 GameViewController.swift 中有设定 scaleMode,所以画面会缩放,而 iPhone 8 刚好比例与 GameScene.sks 设定的比例一样,所以看起来没有跑版。而 iPhone 11 比例不一样,因此跑版了。

scene.scaleMode = .aspectFill

我们将这一行删除,iPhone 11 画面就可以正常呈现了。
https://imgur.com/PixFVpL.png

浏海盖住脸了?

iOS11 之後,多了 Safe Area 功能,他的范围能避开可能会被萤幕切掉或挡住的部分
我们来观察 Main.storyboard View 跟 Safe Area 的差异,Safe Area 比较符合我们想要呈现的方式
https://imgur.com/nFnS5z8.png
https://imgur.com/UhC5FHj.png

试着在 GameViewController.swiftviewDidLoad 中取出 Safe Area 的 size、top、bottom

override func viewDidLoad() {
    print("size: \(view.safeAreaLayoutGuide.layoutFrame.size)")
    print("top: \(view.safeAreaInsets.top)")
    print("bottom: \(view.safeAreaInsets.bottom)")
}

在iPhone 11模拟器中印出:
size: (414.0, 896.0)
top: 0.0
bottom: 0.0

发现取出来的数值不是我们预期的结果,原因在於 viewDidLoad 的时间点太早了,还取不到真正的 Safe Area 资讯。覆写另一个 viewSafeAreaInsetsDidChange 试试看:

override func viewSafeAreaInsetsDidChange() {
    super.viewSafeAreaInsetsDidChange()
    print("size: \(view.safeAreaLayoutGuide.layoutFrame.size)")
    print("top: \(view.safeAreaInsets.top)")
    print("bottom: \(view.safeAreaInsets.bottom)")
}

在iPhone 11模拟器中印出:
size: (414.0, 818.0)
top: 44.0
bottom: 34.0

成功取得 Safe Area 的资讯了!
查看官网的说明,这个方法能在 Safe Area 改变时通知 view controller,因此我们必须在这个时间点调整节点的位置。

  • GameScene.swift
    新增 applySafeArea 方法,把 mapNode 的位置往下校正回来(topSafeArea)
class GameScene: SKScene {
    var topSafeArea: CGFloat = 0
    var bottomSafeArea: CGFloat = 0

    ...
    func applySafeArea() {
        if #available(iOS 11.0, *) {
            if let view = self.view {
                self.topSafeArea = view.safeAreaInsets.top
                self.bottomSafeArea = view.safeAreaInsets.bottom
            }
        }

        if let mapNode = self.mapNode {
            mapNode.position = CGPoint(x: 0, y: -self.topSafeArea)
        }
    }
}
  • GameViewController.swift
    程序会先呼叫 viewDidLoad 方法,在这边我们一样呈现 GameScene,并把 gameScene 储存起来。
    当运行到 viewSafeAreaInsetsDidChange 方法时,判断确定取得到 gameScene 後,呼叫 applySafeArea 方法
class GameViewController: UIViewController {
    var gameScene: GameScene?
    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        if let scene = self.gameScene {
            scene.applySafeArea()
        }
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        if let view = self.view as? SKView {
            if let scene = SKScene(fileNamed: "GameScene") {
                self.gameScene = scene as? GameScene
                ...
            }
            ...
        }
    }
}

请特别注意!不能把原本写在 viewDidLoad 的程序整个搬进 viewSafeAreaInsetsDidChange
viewDidLoad 在生命周期里只会呼叫一次,而 viewSafeAreaInsetsDidChange 则是当 Safe Area 变动时就会再次呼叫,像是旋转手机,就会发生改变。若是整个程序都搬进去,当旋转手机时,游戏就会重来罗!

https://imgur.com/aFf5rJm.png

成功绘制完迷宫了!
明天来带大家美化迷宫~


参考来源:
viewSafeAreaInsetsDidChange


<<:  Spring boot 配置 Fluent bit 传递 Log

>>:  Day04 X Core Web Vital & RAIL Model

登录档的增删改查--风险技能平民都会

今天迈入第7天,根据计画,前几天我们介绍了一些登录档的基础知识和前置作业,假设读者跟笔者一样略懂略懂...

【Day28】建立一个 LUIS Bot

今天我们要来将 Chatbot 与 Language Understanding Service (...

Day 4 仓库 Repository

若你有用过 github 的话,对於仓库 Repository 的概念想必是不陌生。它就是一个存放各...

近似最短路径 (8)

今天来写点杂记和更多的 Leetcode :) 11.7 把树对应到 Hamming Distanc...

[Day0] 前言

嗨!我是莉莉,目前是个软件工程师。去年因为公司内部任务接触到和资安相关的议题,开始对资讯安全感兴趣、...