Phaser -- HTML 遊戲框架

第 8 章    記憶翻牌

(1) 遊戲規格

django讚

∗ 記憶翻牌遊戲

▸ 所有牌都蓋著

▸ 玩家一次可翻兩張牌,如果兩張牌花色相同就得分

(2) 建立專案

∗ 遊戲專案

▸ 建立專案目錄結構,遊戲名稱: concentration

▸ 下載資產 assets08.zip

▸ 建立 index.html 及初始 main.js

▸ 遊戲視窗 500x500

▸ 接下來修改 main.js 開始建構遊戲

(3) Preload

∗ 設定相關屬性

var main = {
  tileSize: 80,
  numRows: 4,
  numCols: 5,
  tileSpacing: 10,
  
  preload: function() {
  ... 
}

▸ 磚塊尺寸為 80,共有 4 列、五行,磚塊間隔 10px

∗ 在 preload 函式中載入磚塊影像資產

  preload: function() {
    game.load.spritesheet('tiles', 'assets/tiles.png', this.tileSize, this.tileSize);
  },

▸ 載入精靈表:共有 4 列 5 行 (20 個磚塊),兩兩一組,因此需 10 張不同花樣的影像,再加上一致的背面 (問號), 總共 11 張影像,每個磚塊的尺寸為 80x80,如下:

tiles

(4) Create

∗ 設定各個變數值

  create: function() {
    this.placeTiles();
  },

this.placeTiles():放置所有磚塊的函式

∗ placeTiles 函式

  create: function() {
    this.placeTiles();
  },
  
  placeTiles: function() {
    var leftSpace = (game.width - this.numCols*this.tileSize -
                     (this.numCols-1)*this.tileSpacing)/2;
    var topSpace = (game.height - this.numRows*this.tileSize -
                    (this.numRows-1)*this.tileSpacing)/2;
    for (var i=0; i<this.numRows; i++) {
      for (var j=0; j<this.numCols; j++) {
        var tile = game.add.button(leftSpace + j*(this.tileSize + this.tileSpacing),
                                   topSpace + i*(this.tileSize + this.tileSpacing),
                                   'tiles', this.showTile, this);
        tile.frame = 10;
      }
    }
  },

▸ 以巢狀迴圈放置磚塊影像:先計算邊界距離 (leftSpace, topSpace),然後在適當位置放置每個磚塊

concentration1

▸ 將磚塊影像改為按鈕,讓使用者可以點擊 (桌機:滑鼠點擊,行動裝置:觸控)

✶ 加入影像指令改為加入按鈕指令
var tile = game.add.button(leftSpace + j*(this.tileSize + this.tileSpacing),
                           topSpace + i*(this.tileSize + this.tileSpacing),
                           'tiles', this.showTile, this);
語法如下:
game.add.button(x, y, key, callback, callbackContext)
# (x, y):位置
# key:資產鍵
# callback:回呼函式,當按了按鈕,則執行此程式
# callbackContext:回呼函式執行的環境

* showTile 函式:目前僅印出 "Show me" 字串

  placeTiles: function() {
    ...
  },
  
  showTile: function() {
    console.log('Show me');
  },

▸ 測試:滑鼠移至的時候成為手形

∗ 給每個磚塊編號

▸ 為了要知道使用者點了那個磚塊,每個磚塊必須要有編號

▸ 兩兩一組,共 10 組,因此可編:0, 0, 1, 1, 2, 2, …, 8, 8, 9, 9,並儲存在陣列中

▸ 首先在 main 物件中宣告空陣列屬性

var main = {
  ...
  tileSpacing: 10,
  tilesArray: [],
  ...
}

▸ 在 placeTile 函式中,記錄磚塊編號

  placeTiles: function() {
    ...
    var topSpace = (game.height * this.numRows*this.tileSize -
                    (this.numRows-1)*this.tileSpacing)/2;
    for (var i=0; i<this.numRows*this.numCols; i++) {
        this.tilesArray.push(Math.floor(i/2));
    }
  
    var index = 0;
    for (var i=0; i<this.numRows; i++) {
      for (var j=0; j<this.numCols; j++) {
        ...
        tile.frame = 10;
        tile.value = this.tilesArray[index++];
      }
    }
  },
✶ 設定 this.tilesArray 陣列的內容為: [0, 0, 1, 1, 2, 2, …, 8, 8, 9, 9]
✶ 設定磚塊的編號:
tilesArray

∗ 測試:如下修改 showTile 並點擊磚塊

  showTile: function(target) {
    console.log('Tile value: ', target.value);
  },

∗ 翻開所點選的磚塊

▸ 當使用者點選某磚塊,應顯示該磚塊影像:如下修改 showTile

  showTile: function(target) {
    target.frame = target.value;
  },
* 改變 targetframe

▸ 測試:點選所有磚塊

showTile

∗ 一次只能點兩個磚塊,並且記錄所點選的磚塊編號

▸ 目前可以點全部磚塊,但依照遊戲規則,每一回只能點兩塊

▸ 同一磚塊不能多次點選

▸ 記錄點選磚塊:在 main 物件宣告 selectedArray 陣列屬性

var main = {
  ...
  tilesArray: [],
  selectedArray: [],

  preload: function() {
  
  ...

}

▸ 在 showTile 中處理只能點選 2 個磚塊的規則:如果 selectedArray 的長度小於 2,而且 target 沒被選過, 才顯示磚塊,並且將磚塊置入陣列

  showTile: function(target) {
    if (this.selectedArray.length<2 && this.selectedArray.indexOf(target)==-1) {
      target.frame = target.value;
      this.selectedArray.push(target);
    }
  },
.length:陣列長度
.indexOf(...):在陣列中尋找物件,-1 表示找不到

∗ 檢查配對是否成功

▸ 當使用者點兩次後,selectedArray 裡就有兩個物件,如果相同,就全部刪除,如果不同,將磚塊翻回

▸ 修改 showTile

  showTile: function(target) {
    if (this.selectedArray.length<2 && this.selectedArray.indexOf(target)==-1) {
      target.frame = target.value;
      this.selectedArray.push(target);
    }
    if (this.selectedArray.length==2) {
      if (this.selectedArray[0].value==this.selectedArray[1].value) {
        this.selectedArray[0].destroy();
        this.selectedArray[1].destroy();
      }
      else {
        this.selectedArray[0].frame = 10;
        this.selectedArray[1].frame = 10;
      }
      this.selectedArray.length = 0;
    }
  },
✶ 如果 selectedArray 長度等於 2,偵測配對結果,否則不做任何事
✶ 如果配對成功 (value 相等)
# destroy():將兩個磚塊徹底破壞,如果含有輸入、事件、動畫等,都一併刪除
✶ 否則
# .frame = 10:將磚塊設定為第 10 號影像 (問號)
# .length = 0:利用設定陣列長度為 0 的方式將其元素刪除, 也可以利用設定為空陣列的方式 this.selectedArray = []; 清除元素

▸ 測試:配對成功的兩個磚塊會刪除,不成功的磚塊會回復問號

∗ 讓第二個磚塊也顯示

▸ 目前配對失敗就直接翻牌,看不到第二張牌

▸ 透過計時讓使用者一窺第二張牌:在 showTile 中,將後半部程式碼移到 checkTiles

  showTile: function(target) {
    if (this.selectedArray.length<2 && this.selectedArray.indexOf(target)==-1) {
      target.frame = target.value;
      this.selectedArray.push(target);
      if (this.selectedArray.length==2) {
        game.time.events.add(Phaser.Timer.SECOND, this.checkTiles, this);
      }
    }
  },

  checkTiles: function() {
    if (this.selectedArray[0].value==this.selectedArray[1].value) {
      this.selectedArray[0].destroy();
      this.selectedArray[1].destroy();
    }
    else {
      this.selectedArray[0].frame = 10;
      this.selectedArray[1].frame = 10;
    }
    this.selectedArray.length = 0;
  },
✶ 當使用者點了第二張牌
# game.time.events.add():加入計時事件
# Phaser.Timer.SECOND:等待一秒鐘
# this.checkTiles:定義一秒鐘後要執行的程式
checkTiles:執行原先破壞或回復磚塊的功能

▸ 測試:可以看到兩張牌 (一秒鐘的時間)

∗ 洗牌

▸ 目前磚塊按照規律排列,應該隨機設定其值

▸ 隨機互換 tilesArray 的元素

  placeTiles: function() {
    ...
    for (var i=0; i<this.numRows*this.numCols; i++) {
        this.tilesArray.push(Math.floor(i/2));
    }
    for (i=0; i<this.numRows*this.numCols; i++) {
      var from = game.rnd.between(0, this.tilesArray.length-1);
      var to = game.rnd.between(0, this.tilesArray.length-1);
      // Swapping two tiles
      var temp = this.tilesArray[from];
      this.tilesArray[from] = this.tilesArray[to];
      this.tilesArray[to] = temp;
    }
    var index = 0;
    ...
  },
game.rnd.between(min, max): 在 [min, max] 範圍內隨機挑一個數字
from, to:來自與前往,兩者調換

(5) 增加遊戲狀態及音效

∗ 增加遊戲狀態

▸ 目前遊戲只有一個狀態,增加另一個狀態 titleScreen:遊戲一開始的標題螢幕

▸ 先加入狀態,並從 titleScreen 狀態開始

...
var game = new Phaser.Game(500, 500, Phaser.AUTO, 'gameDiv');
game.state.add('titleScreen', titleScreen);
game.state.add('main', main);
game.state.start('titleScreen');

∗ titleScreen:遊戲開始畫面

  var titleScreen = {
  
    preload: function() {
      game.load.spritesheet('soundicons', 'assets/soundicons.png', 80, 80)
    },
  
    create: function() {
      var style = {
        font: "48px Monospace",
        fill: "#00ff00",
        align: "center"
      };
      var text = game.add.text(game.width/2, game.height/2-100, '破解外星密碼', style);
      text.anchor.set(0.5);
      var soundButton = game.add.button(game.width/2-100 , game.height/2+100,
                                                 "soundicons", this.startGame, this);
      soundButton.anchor.set(0.5);
      soundButton = game.add.button(game.width/2+100 , game.height/2+100,
                                            "soundicons", this.startGame, this);
      soundButton.frame = 1;
      soundButton.anchor.set(0.5);
    },
  
    startGame: function(target) {
      if (target.frame==0) {
        this.playSound = true;
      }
      else {
        this.playSound = false;
      }
      game.state.start('main');
    },
  
  };

preload:載入 soundicons 精靈表,讓使用者選擇是否要播放聲音

soundicons

create

var style ...:設定文字樣式,字體、顏色、對齊方式
game.add.text():加入文字,設定位置、文字內容為「破解外星密碼」、並引用樣式
text.anchor.set(0.5):文字錨點置中
✶ 加入 2 個聲音按鈕
# game.add.button():加入按鈕,2 個按鈕放在不同位置,均設定資產鍵及點擊時執行 starGame 函式,執行環境在 titleScreen 物件中
# soundButton.anchor.set(0.5):聲音按鈕錨點置中
# soundButton.frame = 1:第二個聲音按鈕使用第 1 個畫面 (第一個聲音按鈕預設使用第 0 個畫面, 因此不需寫設定指令)

startGame

✶ 輸入參數 target:被點擊的物件
✶ 如果 target 的畫面屬性是 0:設定聲音變數 this.playSound 屬性為 true (播音),否則為 false (靜音)
✶ 最後,進入 main 狀態

∗ main

var main = {

  ...
  selectedArray: [],

  preload: function() {
    game.load.spritesheet('tiles', 'assets/tiles2.png',
                                this.tileSize, this.tileSize);
    game.load.audio('select', ['assets/select.mp3', 'assets/select.ogg']);
    game.load.audio('right', ['assets/right.mp3', 'assets/right.ogg']);
    game.load.audio('wrong', ['assets/wrong.mp3', 'assets/wrong.ogg']);
  },

  create: function() {
    this.placeTiles();
    if (titleScreen.playSound) {
      this.selectSound = game.add.audio('select');
      this.rightSound = game.add.audio('right');
      this.wrongSound = game.add.audio('wrong');
    }
  },

  placeTiles: function() {
    ...
  },

  showTile: function(target) {
    if (this.selectedArray.length<2 && this.selectedArray.indexOf(target)==-1) {
      if (titleScreen.playSound) {
        this.selectSound.play();
      }
      target.frame = target.value;
      ...
    }
  },

  checkTiles: function() {
    if (this.selectedArray[0].value==this.selectedArray[1].value) {
      if (titleScreen.playSound){
        this.rightSound.play();
      }
      this.selectedArray[0].destroy();
     ...
    }
    else {
      if (titleScreen.playSound){
        this.wrongSound.play();
      }
      this.selectedArray[0].frame = 10;
      ...
    }
    this.selectedArray.length = 0;
  }

};

preload

✶ 為使遊戲更像外星密碼,載入另一個精靈表 tiles2.png
✶ 載入 3 種聲音:點選、配對正確、配對錯誤

create:如果設定播音,將聲音資產加入遊戲並指派給聲音屬性

showTile:如果設定播音,播放點選 聲音

checkTile:如果設定播音,配對成功播放正確聲音,否則播放錯誤聲音

(6) 顯示分數

∗ 在 main 中增加分數及分數文字兩個屬性

var main = {
  ...
  selectedArray: [],
  score: 0,
  scoreText: null,
  ...
};

∗ create

 create: function() {
    this.score = 0;
    this.placeTiles();
    if (titleScreen.playSound) {
      ...
    }
    var style = {
      font: '32px Monospace',
      fill: '#00ff00',
      align: 'center'
    }
    this.scoreText = game.add.text(5, 5, '分數:' + this.score, style);
  },

▸ 設定分數文字樣式:字體、顏色、對齊

▸ 將分數文字加入遊戲,並設定位置、文字內容、目前分數

∗ checkTiles

  checkTiles: function() {
    if (this.selectedArray[0].value==this.selectedArray[1].value) {
      if (titleScreen.playSound){
        this.rightSound.play();
      }
      this.score++;
      this.scoreText.text = '分數:' + this.score;
      this.selectedArray[0].destroy();
      ...
    ...
  }

▸ 如果配對正確,將分數加 1,並修正分數文字的內容

(7) 增加難度

∗ 增加難度:每回遊戲僅有一分鐘時間

main:加上時間 (60 秒) 與時間文字

var main = {
  ...
  scoreText: null,
  timeLeft: 0,
  timeText: null,
  ...
}

titleScreen.create

var titleScreen = {
  ...
  create: function() {
    game.stage.disableVisibilityChange = true;
    var style = {
      ...
    }
};
✶ 遊戲預設模式為當瀏覽器分頁失焦遊戲就停止,若設定 disableVisibilityChange = true,遊戲會繼續

main.create:設定分數文字

  create: function() {
    this.score = 0;
    this.timeLeft = 60;
    ...
    this.scoreText = game.add.text(5, 5, '分數:' + this.score, style);
    this.timeText = game.add.text(5, game.height-5,
                                  '剩餘時間:' + this.timeLeft, style);
    this.timeText.anchor.set(0, 1);
    game.time.events.loop(Phaser.Timer.SECOND, this.decreaseTime, this);
  },
game.add.text():加入剩餘時間文字,同樣使用顯示分數的文字樣式
.anchor.set(...):設定剩餘時間文字之位置
game.time.events.loop():以迴圈方式不斷計數,每次計時一秒鐘,每次呼叫 this.decreaseTime 方法,執行環境設在 main 物件中

decreaseTime

  decreaseTime: function() {
    this.timeLeft--;
    this.timeText.text = '剩餘時間:' + this.timeLeft;
  },
✶ 將 this.timeLeft 扣減 1,並更新計時文字

∗ 時間截止顯示「遊戲結束」

▸ 加上一個「遊戲結束」狀態:

var game = new Phaser.Game(500, 500, Phaser.AUTO, 'gameDiv');
game.state.add('titleScreen', titleScreen);
game.state.add('main', main);
game.state.add('gameOver', gameOver);
game.state.start('titleScreen');

decreaseTime

  decreaseTime: function() {
    this.timeLeft--;
    this.timeText.text = '剩餘時間:' + this.timeLeft;
    if (this.timeLeft==0) {
      game.state.start('gameOver');
    }
  },
✶ 如果時間截止 (this.timeLeft==0),就進入 gameOver 狀態

gameOver

var gameOver = {
  create: function() {
    var style = {
      font: '32px Monospace',
      fill: '#00ff00',
      align: 'center'
    }
    var text = game.add.text(game.width/2, game.height/2,
     '遊戲結束\n\n你的分數:' + main.score + '\n\n點擊重新開始', style);
    text.anchor.set(0.5);
    game.input.onDown.add(this.restartGame, this);
  },

  restartGame: function() {
    main.tilesArray.length = 0;
    main.selectedArray.length = 0;
    game.state.start('titleScreen');
  },
};
create:
# 設定文字樣式:字體、顏色、對齊
# 加入結束文字:設定位置、文字內容、使用樣式
# 設定結束文字錨點
# 偵測使用者是否點擊,如果是,呼叫 restsartGame 方法, 執行環境在 gameOver 物件
restartGame:
# 清除tilesArrayselectedArray 陣列
# 開始titleScreen 狀態 (遊戲重新開始)

(8) 再加一些功能

∗ 當使用者成功配對所有磚塊,但時間尚未截止

▸ 再重新布局磚塊,讓使用者能繼續玩,得到更多分數

▸ 此外,每配對成功就贈送 2 秒時間

main:加上 tilesLeft 屬性

var main = {
  ...
  timeText: null,
  tilesLeft: 0,

  ...
};

placeTiles:設定 tilesLeft 初始值

  placeTiles: function() {
    this.tilesLeft = this.numRows*this.numCols;
    var leftSpace = (game.width * this.numCols*this.tileSize -
                     (this.numCols-1)*this.tileSpacing)/2;
    ...
  },

checkTiles:正確配對贈送秒數,完成遊戲則重新佈磚

  checkTiles: function() {
    if (this.selectedArray[0].value==this.selectedArray[1].value) {
      ...
      this.score++;
      this.timeLeft += 2;
      this.timeText.text = '剩餘時間:' + this.timeLeft + '(贈送2秒)';
      this.scoreText.text = '分數:' + this.score;
      this.selectedArray[0].destroy();
      this.selectedArray[1].destroy();
      this.tilesLeft -= 2;
      if (this.tilesLeft==0) {
        this.tilesArray.length = 0;
        this.selectedArray.length = 0;
        this.placeTiles();
        this.timeText.text = '剩餘時間:' + this.timeLeft + '(再送1局)';
      }
    }
    else {
      ...
    }
    this.selectedArray.length = 0;
  },
✶ 正確配對:
* 加 2 秒,並修正時間文字內容 (贈送 2 秒)
* 如果配對完畢,將所有陣列內的元素刪除,重新佈磚

(9) 適應各種裝置

∗ 讓遊戲在各種裝置看來都很美觀

titleScreen.create:將視域放大到最大

var titleScreen = {
  ...
  create: function() {
    game.scale.pageAlignHorizontally = true;
    game.scale.pageAlignVertically = true;
    game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;
    game.stage.disableVisibilityChange = true;
    ...
};
✶ 水平及垂直對齊
...SHOW_ALL:需看到整個畫面

index.html:在 <head> 標籤裡加上 meta 資料並設定 body 樣式

→ 裝置製造商的建議
<head>
<meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
<meta name=apple-mobile-web-app-capable content=yes>
<meta name=apple-mobile-web-app-status-bar-style content=black>
<meta name=HandheldFriendly content=true>
<meta name=mobile-web-app-capable content=yes>
<style>
body{
  padding: 0;
  margin: 0;
  background: #000;
}
</style>
</head>

上一章       下一章