F2E 2nd 第二關「新接龍」 挑戰筆記

本作品更新的進度到能夠進行基本遊戲,但有部分邏輯上的 bug ,且暫無拖曳動畫。
短期內不會進行更新。

作品

Lynn 的 F2E 2nd 作品列表 / 第二關:新接龍

設計稿

採用Daphne 的設計稿

排版

由於這次的題目是新接龍,排版比較單純,所以先一鼓作氣把 HTML 、 CSS 寫好!
從挑設計稿到排版完,大概花了三四個小時。

網頁分為主要遊戲畫面和四個提示視窗(獲勝、失敗、重新開始和規則說明)。

主要遊戲畫面

結構大致如下:

.game 結構

相較於番茄鐘,新接龍的樣式設定也比較單純,全部使用 flexbox 、 jcc 就幾乎沒有問題了。
圖片的部分,有了番茄鐘的經驗,一律用 background 省去一堆問題。

.game 畫面

提示視窗

提示視窗的排版大同小異,只有按鈕數量不同,以及 .rule 的文字區塊需要顯示卷軸。
架構如下:

.alert 結構

獲勝、失敗和重新開始的差別只有按鈕不同。

.win 畫面
.lose 畫面
.newGame 畫面

規則說明的文字區塊需要修改卷軸樣式,搜尋到一篇可以參考的文章:CSS3自定义滚动条样式

.rule 畫面

程式邏輯

初始化、發牌、開啟新局

為了讓卡片的元素可以利用迴圈放入畫面中,需要先將卡片的基本資料產生在陣列中。
首先先產生一個包含所有卡片的線性一維陣列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 每張卡片包含 id 、 number 、 suit 和 color 四個屬性
function initialize() {
const suit = ['s', 'h', 'd', 'c'];
let result = [];
for (let i = 0; i < 4; i++) {
for (let j = 1; j <= 13; j++) {
let obj = {
id: suit[i] + j,
number: j,
suit: suit[i],
color: ((suit[i] == 's' || suit[i] == 'c') ? 'black' : 'red'),
}
result.push(obj);
}
}
return result;
}

cardsList 是一個含有 13 個陣列的二維陣列,第 0 個子陣列是 cell 、 1~8 是主要牌區,而 9~12 是 foundation 。
一般開啟新局的狀況下,會將一維陣列的撲克牌發入 cardsList 的主要牌區中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let cardsList;
function shuffle() {
let result = [[],
[], [], [], [], [], [], [], [],
[], [], [], []];
// 0: cell, 1~8: row, 9~12: foundation
let initial = initialize().concat();
while (initial.length > 0) {
for (let i = 1; i <= 8 && initial.length > 0; i++) {
result[i].push(initial.splice(Math.floor(Math.random() * 1000) % initial.length, 1)[0]);
}
}
return result;
}

開啟新局時,需要重新計時、分數歸零、重新發牌並記錄發牌的結果,以供 RESTART 使用。
接著將卡片的資料放入畫面中,並用 id 和 class 將元素分類。
cardsList 的 cell 和 foundation 部分在測試狀況或有存檔功能時可以使用,所以在產生畫面時也要考慮到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
let main = document.querySelector('.main');
let rows = document.querySelectorAll('.main .row');
let cells = document.querySelectorAll('.cell');
let foundations = document.querySelectorAll('.foundation');

function newGame() {
timeId = timer();
score = 0;
cardsList = shuffle();
origin = cardsList.concat();

for (let i = 0; i < 4; i++) {
cells[i].innerHTML = '';
foundations[i].innerHTML = '';
if (i < cardsList[0].length) {
cells[i].innerHTML = cardsList[0][i];
}
for (let j = 0; j < cardsList[i + 9].length; j++) {
foundations[i].innerHTML += '<div id="'
+ cardsList[i + 9][j].id +
'" class="card '
+ cardsList[i + 9][j].suit
+ ' '
+ cardsList[i + 9][j].color
+ '" draggable="true"></div>';
}
foundations[i].innerHTML += '</div>';
}
for (let i = 1; i <= 8; i++) {
rows[i - 1].innerHTML = '<div class="rectangle"></div>';
let rowLength = cardsList[i].length;
for (let j = 0; j < rowLength; j++) {
rows[i - 1].innerHTML += '<div id="'
+ cardsList[i][j].id +
'" class="card '
+ cardsList[i][j].suit
+ ' '
+ cardsList[i][j].color
+ '" draggable="true"></div>';
}
rows[i - 1].innerHTML += '</div>';
}
}

到此為止就有新接龍該有的畫面了:

開啟新局

畫面切換

此遊戲的畫面切換較為簡單,只要將 .game 和所有提示視窗的 display 設為 none,再將想要打開的提示視窗設為 flex (有排版需求);或是將提示視窗都關掉,打開 .game 即可。
監聽事件的對象包含 .newGame 、 .restart 、 .rule 、 .close 等按鈕。

拖曳元素

拖曳功能算是這一關核心的挑戰,這個功能改變了我原本的資料結構。
原本我以為每次移動撲克牌就要重新安排一次畫面,所以需要連陣列一起改變,也要記錄每張牌的位置;後來發現只要能夠拖曳,真的是整個元素會直接被移動到不同的位置,根本不用自己記錄(長見識了!)。

參考文章:[PJCHENder 那些沒告訴你的小細節:筆記] 製作可拖曳的元素(HTML5 Drag and Drop API)

拖曳功能的作法簡單分為「被拖的」跟「被丟的」:

Drag Source

首先要在所有要拖曳的元素上加上「 draggable=“true” 」,於是我的撲克牌元素長成這樣:

1
<div id="h7" class="card h red" draggable="true"></div>

接著在 CSS 中設定 user-select 為 none ,避免拖曳時選取到元素的內容:

1
2
3
4
5
6
[draggable="true"] {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}

然後在撲克牌元素上綁定 dragstart 事件,利用 dataTransfer.setData() 丟出需要的資料。

1
2
3
4
5
6
7
let cards = document.querySelectorAll('.card');
for (let i = 0; i < 52; i++) {
cards[i].addEventListener('dragstart', function () {
event.dataTransfer.setData('id', event.target.id);
event.dataTransfer.setData('classList', event.target.classList);
}, false);
}

Drop Target

要被放入元素的容器需要綁定 drop 事件,並用 event.dataTransfer.getData() 取得資料,指定要放入的元素。
再來需要綁定 dropenter (有元素被拖入容器) 和 dropover (有元素被拖路過容器上空) 以取消預設和冒泡事件。

另外在放撲克牌到 .foundation 或 .row 時,會有 .card 被放入 .card 的狀況,所以需要先判斷 event.target 是哪個元素,再決定放到哪個容器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let foundations = document.querySelectorAll('.foundation');
for (let i = 0; i < 4; i++) {
foundations[i].addEventListener('drop', foundationDropped);
foundations[i].addEventListener('dragenter', cancelDefault);
foundations[i].addEventListener('dragover', cancelDefault);
}
function foundationDropped(event) {
cancelDefault(event);
if (event.target.parentElement.className == 'foundations') {
event.target.appendChild(document.querySelector('#' + event.dataTransfer.getData('id')));
}
else {
event.target.parentElement.appendChild(document.querySelector('#' + event.dataTransfer.getData('id')));
}
}
function cancelDefault(event) {
event.preventDefault();
event.stopPropagation();
return false;
}

到此拖曳的功能就完成了:

拖曳元素

遊戲規則

在拖曳功能毫無限制的狀況下,想怎麼贏就怎麼贏(?)。
因此必須在各個容器的綁定 drop 事件的 function 中加入一些限制,以符合遊戲規則。

這部分如有機會再做更新。