0%

S2-4-卡牌配對遊戲

此篇記錄卡牌配對遊戲製作過程,用來提醒自己的技術筆記
實作練習作品

第一張牌

使用flex佈局來完成第一張牌,一共四排一排13張牌,透過flex來指定每張牌佔位比例,

1
2
3
4
5
6
7
8
#cards {
display: flex;
}
.card {
flex: 7% 0 1; /* 每個 item 分配容器寬度 */
height: 8vw; /* 依視窗的寬度等比例變化 */
border: 2px solid #e0e0e0;
}

flex 縮寫的表定順序是 flex-grow → flex-shrink → flex-basis,不過因為 flex 是高容錯的設計,而這裡指定 7% 和 flex-shink & flex-grow 預期吃到的參數格式不一樣,因此自動當成了 flex-basis 的值。

flex-grow:分配剩餘空間

設定 0 的意思是不要分配剩餘空間。

如果是 1 或 1 以上的數字,這個元素會佔用剩餘的空間,並且根據設定的數字大小來彈性調整分配方式。
flex-grow: 1 代表平分,若是 flex-grow: 2 代表中間佔用的空間會是右邊的兩倍,還有更多可以查官方文件。

flex-shrink:空間不足如何收縮

如果視窗變小,flex item 就需要收縮,減少自己所佔的空間,而 flex-shrink。這裡我們設定 1,我們想讓元素等比例收縮;如果設定比 1 更高的數字,代表我們要深入控制每個元素的收縮比例。

flex-basis:分配寬度

flex-basis 可以控制 item 在容器裡佔的寬度,在這裡我們使用百分比,就是以容器的寬度為 100%。
由於我們希望 52 張牌能以 4*13 排列,每一橫列要有 13 張卡片,100 / 13 = 7.7,這裡暫時抓 7% 的比例,剩餘一點空隙就當成 margin。

卡片高度:與寬度等比例縮放

由於卡片的寬度根據容器寬度變化,卡片高度也需要等比例變化,容器寬度的預設值為視窗大小,所以這裡我們把卡片高度設定為 8vw,代表視窗大小的 8%。這樣就寬高就能等比例的變化。

使用:last-child 及 transform來倒轉數字

1
2
3
.card p:last-child {
transform: rotate(180deg);
}

當物件的屬性與函式/變數名稱相同時,可以省略不寫:

1
2
3
4
5
6
7
8
// 原本的寫法
const view = {
displayCards: function displayCards() { ... }
}
// 省略後的寫法
const view = {
displayCards() { ... }
}

由於我們會把 view 以及之後的 model 和 controller 當成模組來使用,目的是創造出一個命名空間 (namespace),把相關的變數和函式都分類進去,因此屬性名稱和函式名稱基本上都會是同名的,不會特地取新的名字,所以之後我們都會省略屬性名稱不寫,看起來會比較簡潔。

透過switch來轉換數字成為J, Q, K

由於狀況有 4 種,在這裡用 switch 會比 if/else 清楚,若是 1、11、12、13 的狀況,則分別回傳 A、J、Q、K,如果數字是 2-10,則把數字原封不動地回傳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
transformNumber (number) {
switch (number) {
case 1:
return 'A'
case 11:
return 'J'
case 12:
return 'Q'
case 13:
return 'K'
default:
return number
}
},

使用Array.from(Array(52).key()),來生成連續數字陣列

Array.from 可以從「類似陣列」的物件來建立陣列
Array(52),它會產生一個有 52 個元素的空陣列:
Array(52).keys()會變成一個迭代器,需要使用類似for迴圈之類的迭代,才有辦法生成。
ES6 推出了 Array.from() 之後,就可以搭配迭代器來生成陣列:

產生 52 個 DOM 元素並拼裝 template

有了連續數字陣列以後

1
2
3
4
displayCards() {
const rootElement = document.querySelector("#cards");
rootElement.innerHTML = Array.from(Array(52).keys()).map(index => this.getCardElement(index)).join("");
},
  • 用 map 迭代陣列,並依序將數字丟進 view.getCardElement(),會變成有 52 張卡片的陣列;
  • 接著要用 join(“”) 把陣列合併成一個大字串,才能當成 HTML template 來使用;
  • 把組合好的 template 用 innerHTML 放進 #cards 元素裡。

洗牌演算法:Fisher-Yates Shuffle

先看一下 JavaScript 的實作版本 getRandomNumberArray,參數 count 表示陣列的長度,count 會傳入 52。

1
2
3
4
5
6
7
8
getRandomNumberArray(count) {
const number = Array.from(Array(count).keys())
for (let index = number.length - 1; index > 0; index--) {
let randomIndex = Math.floor(Math.random() * (index + 1))
;[number[index], number[randomIndex]] = [number[randomIndex], number[index]]
}
return number
}
  1. 生成連續數字陣列
    const number = Array.from(Array(count).keys()) - 生成一個長度為 count 的連續數字陣列。這個之前介紹過了。

我們先假設 count 是 7,那麼 number 就是 [0, 1, 2, 3, 4, 5, 6]。

選定想交換的位置
let index = number.length - 1 取出最後一項。

let randomIndex = Math.floor(Math.random() * (index + 1)),找到一個隨機項目。

  1. 交換陣列元素
    [number[index], number[randomIndex]] = [number[randomIndex], number[index]]
    這段是運用了 ES6 的解構賦值語法,意思是讓 number[randomIndex] 和 number[index] 交換,等同於:

temp = number[index]
number[index] = number[randomIndex]
number[randomIndex] = temp
此時我們的 index 是 6,如果假設 randomIndex 是 2,那麼 number 就會變成:[0, 1, 6, 3, 4, 5, 2]

  1. 解構賦值
    以前要為變數賦值,只能一個個慢慢寫:
    1
    2
    3
    let a = 1
    let b = 2
    let c = 3

有了 ES6 比較厲害的解構賦值 (destructuring assignment) 之後,

1
let [a, b, c] = [1, 2, 3]

只要等號兩邊的模式相同 (例如都是 [] 或都是 {}),左邊的變數就會被賦予右邊的對應值。

不可省略的分號
注意在程式碼裡有一個分號 ;,他不是寫錯的。

在 JavaScript 的分號代表「這個執行語句結束了」,大部分的分號都是可以省略的,這裡剛好是不可以省略的。
因為前面呼叫了 Math.floor() 這個函式庫,如果沒有加上分號,會和後面的 [] 連起來,被解讀成 Math.floor()[],雖然沒有實際的意義,但因為瀏覽器對 JavaScript 的語法解析很寬鬆,這裡會發生錯誤,所以需要加上分號變成 Math.floor();[] 來把執行語句隔開。

設定遊戲狀態

1
2
3
4
5
6
7
const GAME_STATE = {
FirstCardAwaits: "FirstCardAwaits",
SecondCardAwaits: "SecondCardAwaits",
CardsMatchFailed: "CardsMatchFailed",
CardsMatched: "CardsMatched",
GameFinished: "GameFinished",
}

使用switch來控制遊戲狀態

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dispatchCardAction (card) {
if (!card.classList.contains('back')) {
return
}
switch (this.currentState) {
case GAME_STATE.FirstCardAwaits:
view.flipCard(card)
model.revealedCards.push(card)
this.currentState = GAME_STATE.SecondCardAwaits
break
case GAME_STATE.SecondCardAwaits:
view.flipCard(card)
model.revealedCards.push(card)
// 判斷配對是否成功
break
}
console.log('this.currentState', this.currentState)
console.log('revealedCards', model.revealedCards.map(card => card.dataset.index))
}

展開運算子及其餘參數

展開運算子

1
2
3
const numbers = [5, 6, 8, 9, 11, 999];
Math.max(...numbers)
// instead of Math.max(5, 6, 8, 9, 11, 999);

其餘參數
「把值蒐集起來變成陣列」的情況,在下例中,不確定會傳給sum 函式幾個參數,但總之我們想要在 sum 函式裡使用陣列處理方法,這時候也可以用 … 把參數轉變成一個陣列來迭代:

1
2
3
4
5
6
7
8
9
function sum(...numbers) {
let result = 0
numbers.forEach(function (number) {
result += number
})
return result
}
sum(1) // 1
sum(1, 2, 3, 4, 5) // 15

改寫flipCard,function帶入其餘參數

flipCard改寫為可以傳入1個或是2個參數

1
2
3
4
5
6
7
8
9
10
11
flipCards (...cards) {
cards.map(card => {
if (card.classList.contains('back')) {
card.classList.remove('back')
card.innerHTML = this.getCardContent(Number(card.dataset.index))
return
}
card.classList.add('back')
card.innerHTML = null
})
},

改寫pairCard,function帶入其餘參數

1
2
3
4
5
pairCards(...cards) {
cards.map(card => {
card.classList.add('paired')
})
}

CSS動畫特效

在 CSS3 有提供一套 CSS animations 的現成動畫工具,需要透過關鍵影格 @keyframes 來設定動畫的漸變流程

1
2
3
4
5
6
7
8
9
10
.wrong {
animation-name: wrongAnimation;
animation-duration: 0.2s;
animation-iteration-count: 5;
}
@keyframes wrongAnimation {
to {
border: 2px solid #ffd54f;
}
}

用 @keyframes 指定了一個叫 wrongAnimation 的動畫流程,在 keyframes 裡可以用 from 和 to 來指定動畫漸變起點/終點,使用 to 來指定「當動畫發生時,該元素要逐漸加上黃色邊框」。

設定好 keyframes 之後,要呼叫它。呼叫的位置在 .wrong 類別裡,在呼叫動畫時我們一併指定了:

animation-duration: 0.2s - 動畫要持續 0.2 秒,也就是「逐漸加入黃色邊框」的效果要用 0.2 秒來跑完。
animation-iteration-count: 5 - 動畫要重覆 5 次

整合動畫進JS

回到 JavaScript 來整合,我們先加入一個函式 view.appendWrongAnimation:

1
2
3
4
5
6
7
// view
appendWrongAnimation(...cards) {
cards.map(card => {
card.classList.add('wrong')
card.addEventListener('animationend', event => event.target.classList.remove('wrong'), { once: true })
})
},

這個函式裡會為卡片加入 .wrong 類別,一旦加入就會開始跑動畫。另外我們用事件監聽器來綁定**動畫結束事件 (animationend)**,一旦動畫跑完一輪,就把 .wrong 這個 class 拿掉。

最後的 {once: true} 是要求在事件執行一次之後,就要卸載這個監聽器。因為同一張卡片可能會被點錯好幾次,每一次都需要動態地掛上一個新的監聽器,並且用完就要卸載。

最後到 controller.dispatchCardAction 中,在配對失敗的流程中呼叫 view,注意這一行要加在 setTimeout 之前 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 判斷配對是否成功
if (model.isRevealedCardsMatched()) {
// 配對成功
view.renderScore(model.score += 10)
this.currentState = GAME_STATE.CardsMatched
view.pairCards(...model.revealedCards)
model.revealedCards = []
this.currentState = GAME_STATE.FirstCardAwaits
} else {
// 配對失敗
this.currentState = GAME_STATE.CardsMatchFailed
view.appendWrongAnimation(...model.revealedCards) // add this
setTimeout(this.resetCards, 1000)
}
break

Welcome to my other publishing channels