iT邦幫忙

2021 iThome 鐵人賽

DAY 11
0
Mobile Development

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

從零開始的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.cubeWH * CubeYCount)))
        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


上一篇
從零開始的8-bit迷宮探險【Level 10】遊戲故事及架構設計
下一篇
從零開始的8-bit迷宮探險【Level 12】把迷宮塗上喜歡的顏色
系列文
從零開始的8-bit迷宮探險!Swift SpriteKit 遊戲開發實戰30

尚未有邦友留言

立即登入留言