發佈日期: 發佈留言

[4 – 遊戲邏輯] 產生初始盤面

棋盤設計

用一個陣列來代表盤面,裡面儲存1~N來代表不同的圖形

隨機產生盤面

觀察連連看遊戲裡,同樣的圖形通常會有四個,因此如果盤面是10×10的話,總共會有100的icon,一種icon會有四個,則代表會有25種不同的icon。

一開始為了方便測試,先製作6*6=36的棋盤,這樣會有36/4=9種不同的icon。

var boardContent = [1,2,3,4,5,6,7,8,9];
//產生初始局面
boardContent = boardContent.concat(boardContent).concat(boardContent).concat(boardContent).sort(() => Math.random() > .5);
boardContent = [boardContent.slice(0,6),
                boardContent.slice(6,12),
                boardContent.slice(12,18),
                boardContent.slice(18,24),
                boardContent.slice(24,30),
                boardContent.slice(30,36)];

這時候可以看到已經會有一個隨機產生不同資料的6×6盤面了

呈現陣列內容

接著在邏輯撰寫的版本因為為了測試方便,我們使用angularJS來呈現這個棋盤,因為angular有bindle資料的功能,可以讓棋盤消除更加的容易。
html檔案的內容如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  <style>.red{background:red;}</style>
<script src="https://code.jquery.com/jquery-3.1.0.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.15/angular.min.js"></script>
</head>
<body ng-app="myApp" ng-controller="myCtrl">
 select1=<input type="text" ng-model="select1" disabled>
 select2=<input type="text" ng-model="select2" disabled>
<table border="1" width="200">
  <tr ng-repeat="lines in boardContent track by $index">
    <td ng-repeat="x in lines track by $index"
        ng-class="{'red':(select1&#91;0&#93;==$parent.$index&&select1&#91;1&#93;==$index && selected)}"
        ng-click="click($parent.$index,$index)">{{x}}</td>
  </tr>
</table>
</body>
</html>

js檔案的內容如下:

var boardContent = [1,2,3,4,5,6,7,8,9];
//產生初始局面
boardContent = boardContent.concat(boardContent).concat(boardContent).concat(boardContent).sort(() => Math.random() > .5);
boardContent = [boardContent.slice(0,6),
                boardContent.slice(6,12),
                boardContent.slice(12,18),
                boardContent.slice(18,24),
                boardContent.slice(24,30),
                boardContent.slice(30,36)];

//產生盤面
var app = angular.module('myApp', []);
app.controller('myCtrl', function($scope) {
    $scope.select1 = [-1,-1];
    $scope.select2 = [-1,-1];
    $scope.selected = false;

    $scope.boardContent= boardContent;
    $scope.click = function(x,y) {
       if($scope.selected){
         console.log(boardContent[$scope.select1[0]][$scope.select1[1]])
         console.log(boardContent[x][y])
         if(boardContent[$scope.select1[0]][$scope.select1[1]]
           == boardContent[x][y]){
           $scope.select2 = [x,y];
         }
         $scope.selected = false;
       }else{
        $scope.select1 = [x,y];
        $scope.selected = true;
       }
    };
});

上面的程式碼可以產生一個符合陣列內容的table盤面,裡面標明數字,如果任選一個數字,底部會反紅做提示,若選擇的第二個數字與第一個所選的數字相同,則會把所選的值存在select2,並且把selected的標籤設定為false。
上面程式碼的產出如下:

這邊是js bin的連結:https://jsbin.com/raqilezuye/edit?html,js

改使用Typescript開發

因為後面的程式邏輯將會越來越複雜,因此要將上面在JS Bin開發的程式碼搬到本地端的project上。

改好後的專案在此:ironman2018-3

下載後請執行下列指令安裝
npm install

安裝後可以用下列指令來打開網站
gulp default

就可以成功打開網頁看到專案的結果了。

發佈日期: 發佈留言

[3 – 環境設定] 開發環境介紹

VS Code

Visual Studio Code(簡稱VS Code)是一個由微軟開發的IDE,它最大的優點就是它是完全免費且Open Source的。它支援偵錯,並內建了Git版本控制功能,同時也具有開發環境功能。我個人覺得他在算是滿方便好用的,自動提示、自動補完和顏色選擇功能都很強大。

這邊是下載連結:Download Visual Studio Code

NPM

NPM 是 Node Package Manager 的簡稱,它是一個線上套件庫,可以下載各式各樣的 Javascript 套件來使用。

過去如果我們想要使用jQuery,就會需要去下載一個JQuery的Library檔案放進專案目錄裡。但是當這樣的Library庫檔案越來越大,而且越來越多時,就會較難管理,也會造成svn或git等版本管理系統要多去管控一些根本不屬於這個專案的程式碼,Library之間的版本管理也會較為困難。另外很多元件庫或許只需要在開發時期用到,在部署時不需要用到。這些都會讓套件的管理增加複雜度。因此現在大多數的前端工程師都會使用npm來做套件管理的工具,能夠解決上述的那些問題。

安裝npm要先去安裝Node.js。Node.js 在 0.6.3 版本開始內建 npm,讀者安裝的版本若是此版本或更新的版本,就可以略過以下安裝說明。

若要檢查 npm 是否正確安裝,可以使用以下的指令:
npm -v

要初始化一個npm專案,則使用下列指令,然後依序出現相關項目來讓我們填寫
npm init

按下enter後就會在資料夾目錄裡面看到資料夾內增加了一個名為package.json的檔案,這個檔案會紀錄這個專案的許多相關資訊。

下面為一個簡單的package.json的範例
{
“name”: “lianliankan”//專案名稱,
“version”: “0.0.1”,//版本號
“scripts”: {//撰寫一些要跑專案要用的指令在這邊,例如這個範例可以用npm lunch去跑gulp launch-web這個指令
“launch”: “gulp launch-web”
},
“license”: “ISC”,
“dependencies”: {//套件管理,這個專案需要用到那些套件在這邊設定
“browser-sync”: “^2.24.7”,
“gulp”: “^3.9.1”,
“gulp-typescript”: “^5.0.0-alpha.3”
}
}

TypeScript

TypeScript是一種由微軟開發的自由和開源的程式語言。它是JavaScript的一個嚴格超集,並添加了靜態型別和類別基礎的物件導向特性。TypeScript是由C#的首席架構師以及Delphi和Turbo Pascal的創始人安德斯·海爾斯伯格參與了TypeScript的開發。

TypeScript設計目標是開發大型應用,然後轉譯成JavaScript。由於TypeScript是JavaScript的嚴格超集,任何現有的JavaScript程式都是合法的。TypeScript程式物件導向的特性能讓我們在寫TypeScript的時候,有像是寫強型別的語言一樣輕鬆自在,IDE 也可以幫你檢查基本的錯誤。在將TypeScript編譯成JS時,也可以設定是要轉成那種版本的JS,如:EC5、EC6。避免在寫程式還要去注意不同版本JS的兼容性。

安裝TypeScript的命令行工具安裝方法如下:
npm install -g typescript
編譯ts文件的方法如下
tsc hello.ts
但是當我們在開發一個較大型的專案時,一般我們不可能一個一個檔案用上面的指令去complier,這時候我們會設定一個typescript的config檔案:tsconfig.json
這邊是關於這個config的設定教學:tsconfig.json

下面是一個tsconfig.json的簡單範例,compilerOptions用來設定如何來編譯ts文件
{
“files”: [
“src/*.ts”//要complier那些ts檔案
],
“compilerOptions”: {
“target”: “es5”,//要complier成的js版本
“module”: “system”,//module用於指定模塊的代碼生成規則,可以使用commonjs,amd,umd,system,es6,es2015,none這些選項。
//選擇commonJS,會生成符合commonjs規範的文件,使用amd,會生成滿足amd規範的文件,
//使用system会生成使用ES6的system.import的代码。使用es6或者是es2015會產生包含ES6特性的代碼。
“moduleResolution”: “node”,
“sourceMap”: false,//sourceMap是當我們使用chrome dev tools時,當有錯誤是否我們能夠直接在js連回ts檔案格式去偵錯
//通常只會在dev環境開啟(為方便偵錯),不然會讓產出的檔案變大
“removeComments”: true,//設置為true代表不輸出注解
“noImplicitAny”: false,//是否在型別設定為any時發出警告
“rootDir”: “src/”,//根目錄
“lib”: [ “es6”, “dom” ],
“allowSyntheticDefaultImports”: true
}
}
這個手冊有更詳細的解說每一個設定的意義:TypeScript配置文件tsconfig简析

Gulp

安裝完node.js後,就是使用 windows 的 cmd 或 Mac 的 Termial 來安裝 Gulp,首先我們先輸入以下的指令,把 Gulp 安裝好,並且設定是只有在開發時期會使用到。
首先要先將gulp安裝到全域領域,這樣我們才能使用cmd等去呼叫gulp執行工作(若mac環境前面要加sudo)
npm install -g gulp
然後在剛剛設定好的專案裡,設定我們的專案要使用到gulp模組。至於為什麼要有-save-dev呢?當我們寫-save-dev,會將這個模組添加到package.json的devDependencies裏頭,如果寫-save,就會添加到dependencies裡,這兩個的差異在於讓使用具備這個package.json專案的人,可以清楚的知道這個模組,是開發使用,還是執行專案時使用的。
npm install gulp –save-dev
因為我們是使用typescript,因此也要安裝gulp-typescript
npm install gulp-typescript –save-dev

下面是我們所設定的gulpfile.js,這個檔案主要是要設定我們部署時要做的動作。
例如下面的範例可以在呼叫gulp default時自動做下面的幾個步驟

  • 編譯ts檔案
  • copy相關資源/li>
  • bundle
  • 使用browserSync來開啟並同步更新網頁
var gulp = require("gulp");
var browserify = require("browserify");
var source = require('vinyl-source-stream');
var tsify = require("tsify");
var browserSync = require('browser-sync');

gulp.task("copy-html", function () {
    return gulp.src(['src/*.html','src/libs/*'])
        .pipe(gulp.dest('build'));
});

gulp.task("build", ["copy-html"], function () {
    browserify({
        basedir: '.',
        debug: true,
        entries: ['src/main.ts'],
        cache: {},
        packageCache: {}
    })
    .plugin(tsify)
    .bundle()
    .pipe(source('bundle.js'))
    .pipe(gulp.dest("build"));
});

gulp.task('default',['server'], function() {
    gulp.watch(["src/**/*"], ['build']);
});

gulp.task('server', ['build'], function () {
    browserSync({
        open: true,
        port: 8001,
        files: ["build/**/*.{html,htm,css,js,json}"],
        server: {
            "baseDir": "./build"
        }
    });
});

GitHub

這一個專案的所有程式碼我都會放在這個gitHub儲存庫:https://github.com/cochiachang/ironman2018

因為Git使用主題太大了,所以不熟悉的讀者可參考此文:30 天精通 Git 版本控管

參考資料

發佈日期: 發佈留言

[2 – 演算法] 演算法介紹

何為演算法

演算法的簡單定義輸入 + 演算法 = 輸出
這一篇文章有非常詳細的介紹演算法是什麼:初學者學演算法-談什麼是演算法和時間複雜度

一般判斷演算法的好壞是使用『空間複雜度』和『時間複雜度』來評估,

時間複雜度

所謂時間複雜度,指的是執行一段演算法,跑完整個運算邏輯所要花的時間。
雖然這個複雜度名為『時間複雜度』,但實際上,我們要真正衡量某個演算法時,是以步驟次數來看。

如果一個演算法執行的步驟是固定的,無關輸入的值而改變,那我們會記成 O(1),例如:

function(int n){
    print(n);
}

而下面這個演算法:
function(int n){
for(i=0;i O(n)。

有時若跑的次數較不固定,如:
function(int n){
for(i=0;i O(n2) ,也就是說,只要找出最高次方,並且把係數拿掉即可。

空間複雜度

而一個程式的空間複雜度是指完全地執行程式所需的記憶體量

例如下面這個函式,不管程式跑了幾遍,都不會影響使用的變數數量:
function(int n){
for(int i=0;iO(1)。

但下面這個函式,會隨著丟進去的數字而影響變數的量,例如:
function(int n){
int c[n];
for(int i=0;iO(n)。

遞迴的空間複雜度一般是與最深的呼叫深度成正比,若遞迴中有局部的變數或參數,所需的空間複雜度會更高

演算法的經典例子

人工智慧也是演算法的一種,如著名的旅行推銷員問題(Travelling salesman problem, TSP),在假定已知每個城市間的距離,在不重複訪問同一個城市下,如何求出最快速的行程組合,這就已經是電腦科學中著名的難解問題。

(Source:By Saurabh.harsh)

一筆畫問題也是很經典的演算法議題。一筆畫問題是柯尼斯堡七橋問題經抽象化後的推廣,是圖遍歷問題的一種。在柯尼斯堡問題中,如果將橋所連接的地區視為點,將每座橋視為一條邊,那麼問題將變成:對於一個有著四個頂點和七條邊的連通圖G(S,E),能否找到一個恰好包含了所有的邊,並且沒有重複的路徑。歐拉將這個問題推廣為:對於一個給定的圖,怎樣判斷是否存在著一個恰好包含了所有的邊,並且沒有重複的路徑?這就是一筆畫問題。

(Source:By 維基百科)

這個問題的解答是:如果一個圖能一筆畫成,那麼對每一個頂點,要麼路徑中「進入」這個點的邊數等於「離開」這個點的邊數:這時點的度為偶數。要麼兩者相差一:這時這個點必然是起點或終點之一。注意到有起點就必然有終點,因此奇頂點的數目要麼是0,要麼是2

程式之美-微軟技術面試心得裡,也給了讀者了許多很經典的演算法題目,例如:構造數獨遊戲、一疊蔥油餅的排序、一排石頭的遊戲、俄羅斯方塊遊戲、踩地雷遊戲的機率、連連看遊戲,都是我感到十分有興趣的演算法議題。

連連看遊戲的演算法設計方向

後來因為我小時候很喜歡玩kawai連連看,所以就選擇這款遊戲做為這次鐵人賽的題目,書中對於連連看遊戲所給的思考方向如下:

假如讓你來設計一個連連看遊戲的演算法,你會怎麼做呢?要求如下:

  • 如何用簡單的電腦模型來描述這個問題?
  • 如何判斷兩個圖形能否相消?
  • 如何求出兩個相同圖形間的最短路徑?(轉彎數最少、路徑經過的格子數目最少)
  • 如何確定目前是處於僵局狀態,並設計演算法來解除僵局?

在經典的最短路徑當中,需要求出經過格子數目最小的路徑。這邊為了確保轉彎次數最少,需要把最短路徑問題的目標函數修改為從一個點到另一個點。雖然目標函數修改了,但演算法的框架仍然可以保持不變。廣度優先搜尋是解決經典最短路徑問題的一個思考方向。

根據上面的說明,我們若想要做一個連連看的遊戲,需要思考解決下列問題:

  • 產生遊戲初始局面:如何用電腦資料格式來儲存一個圖形資料
  • 判斷是否可相消:每次使用者選擇兩個圖形,如果圖形滿足一定條件(兩個圖形相同,且這兩個圖形之間存在少於三個轉彎以下的路徑)則可以消除圖形。
  • 判斷僵局:搜尋全部現有場景上的圖案,並判斷是否能夠相消。當玩家不可能再去消除任意兩個圖像的時候,遊戲進入僵局狀態,就隨機打亂局面,打破僵局。

因此,我們至少會需要使用到圖形(Graph)演算法以及搜尋(包含深度與廣度搜尋)演算法

下面我們便先針對這兩個部份的演算法來做介紹。

Graph 資料結構

一張圖由數個點( vertex )以及數條邊( edge )所構成。點與點之間,得以邊相連接,表示這兩點有關聯、關係。

那要如何利用程式語言來表示一張圖?有下列幾種方法(資料來源於此):

  • 相鄰矩陣(Adjacency Matrix):圖形中最常用的是相鄰矩陣。將各節點當做矩陣的行和列的 index,若頂點間有連接則 array[i][j] = 1,反之 array[i][j] = 0。這個方法的缺點在於若遇到稀疏矩陣將浪費許多空間給不存在邊,且頂點可能數量會改變,使用二維矩陣就相對不彈性。
  • 相鄰串列(Adjacency Lists):把一張圖上的點依序標示編號。每一個點,後方串連所有相鄰的點。例如第 4 列之中所列的數字,即是與第 4 點相鄰的點。
  • 關聯矩陣:最簡單的方式,就是用個陣列,記錄所有點與點之間的邊。這種方式相當直觀,也非常節省空間。

搜尋演算法

圖的遍歷,也就是指通盤地讀取圖的資訊:決定好從哪裡開始讀,依照什麼順序讀,要讀到哪裡為止。詳細地設計好流程,始能通盤地讀取圖的資訊;如果設計得漂亮,在解決圖的問題時,還可以一邊讀取圖的資訊,一邊順手解決問題呢!利用最簡單的資料結構 queue 和 stack ,就能製造不同的遍歷順序,得到兩種遍歷演算法: Breadth-first Search(廣度優先搜尋) 和 Depth-first Search (深度優先搜尋)。

深度優先搜尋法

下面是這篇文章裡對深度優先搜尋法所給的解釋:

深度優先搜尋法,是一種用來遍尋一個樹(tree)或圖(graph)的演算法。由樹的根(或圖的某一點當成 根)來開始探尋,先探尋邊(edge)上未搜尋的一節點(vertex or node),並儘可能深的搜索,直到該節點的所有邊上節點都已探尋;就回溯(backtracking)到前一個節點,重覆探尋未搜尋的節點,直到找到目 的節點或遍尋全部節點。

深度優先搜尋法屬於盲目搜索(uninformed search)是利用堆疊(Stack)來處理,通常以遞迴的方式呈現。

範例: 以深度優先搜尋法找出下圖的所有節點順序:

假設起始點為 A,且每一節點由左至右的順序來搜尋下個節點,則結果為: A, B, E, F, D, C, G

廣度優先搜尋法

下面是這篇文章裡對廣度優先搜尋法所給的解釋:

廣度優先搜尋法,是一種圖形(graph)搜索演算法。從圖的某一節點(vertex, node)開始走訪,接著走訪此一節點所有相鄰且未拜訪過的節點,由走訪過的節點繼續進行先廣後深的搜尋。以樹(tree)來說即把同一深度(level)的節點走訪完,再繼續向下一個深度搜尋,直到找到目的節點或遍尋全部節點。

廣度優先搜尋法屬於盲目搜索(uninformed search)是利用佇列(Queue)來處理,通常以迴圈的方式呈現。

範例: 廣度優先搜尋法找出下圖的所有節點順序:

假設起始點為 A,且每一節點由左至右的順序來搜尋下個節點,則結果為: A, B, C, D, E, F, G

連連看遊戲的演算法構思

圖形儲存方式

以連連看來說,因為在連連看的棋盤裡,任意兩個同一排或同一列的點,都可以做直線相連,我們若用一個二維陣列直接來儲存棋盤內容,可以用該陣列第一個索引值或第二個索引值是否相同,來判別是否在同一條線上

若圖形為10X10的棋盤,則陣列設計如下:

用這樣的陣列的儲存方式,我們可以注意到,上圖的x,y軸的位置,和一般我們在做遊戲時的x,y軸的方向會剛好相反,這一點要特別注意,才不會在計算點與點間的位置時出錯。

而路徑的部份則使用上面圖演算法中的關連矩陣去紀錄,如[[0,0],[-1,0],[-1,8],[0,8]],在連連看遊戲裡,每一個合法的路徑都應由四個或以內的點所組成成。並且每一個點之間,必定要有相同的x或相同的y。我們可以用這樣的條件去搜尋判斷可能存在的路徑。

路線搜尋方式

在一般我們做最短路徑搜尋的情況,都會以廣度優先搜尋為主。

在連連看裡面,連線的線條不可大於兩個轉彎處,兩個轉彎處的意思,代表連接的線最多只能由三條直線來組成

由上圖可以發現,我們可以以直線做為搜尋單元,先搜尋A點上、下、左、右最遠能到達的點,對比B點上、下、左、右最遠可以到達的點,判斷是否有可能可以形成一條連線,若有可能,再去尋找是否中間存在可能的第二條線。

參考資料

發佈日期: 發佈留言

[1 – 前言] 連連看遊戲開發

連連看遊戲起源

遊戲《連連看》顧名思義就是找出相關聯的東西連起來,做關連配對的一種益智遊戲

最早是使用在幼兒教育的教具上,由於玩法簡單,常用作兒童啟蒙教育遊戲,建立兒童對物品之間的關連性連結。有一種字圖連連看,是專供幼童識字認圖的遊戲,與一般連連看不同的是它並非以一對相同圖案成對,而是以字配圖成對。相關內容連連看則是以兩張內容相關的卡片(可以是字或圖)配成對代替相同圖案。

後來出現了桌面遊戲的連連看,最早期的形式是一副卡片中每種圖案有相同的兩張,先洗牌,然後排好卡片,背面朝上,玩家輪流揭開卡片,每次揭兩張,如兩張圖案不同則回復背面朝上的狀態,如揭到兩張圖案相同則取走卡片,到桌上所有卡片都被取走時即結束遊戲,手上最多卡片者為勝利者。

隨著電腦的普及,連連看遊戲也成為一款經典的電腦小遊戲。在電腦遊戲中的一種規則則是在找到兩幅相同圖案後,若能用三條以內的直線將兩幅圖案連接起來,分別點一下兩幅圖案,即可消除。連連看遊戲後來經歷了桌面遊戲、在線遊戲、社交遊戲三個過程。

連連看電腦版最初是由台灣的陳一進和簡誠志從街機里的四川省(四川麻將)中國龍改進、移植到PC上的,現在有了各種不同的版本。

台灣的連連看流入大陸以後風靡一時,也吸引眾多程式師開發出多種版本的連連看,其中kawai所開發的《寵物連連看》受到很大的歡迎。隨著Flash應用的流行,網上出現了多種線上Flash版本連連看。如:水晶連連看、果蔬連連看、阿達連連看等,連連看以簡單不無聊的遊戲特色吸引了一大批的女性玩家。2008年,隨著網際網路的普及和開放平台的興起,《連連看》被引入了社交網站,與個人空間相結合,被快速的傳播,成為一款熱門的社交遊戲。

益智遊戲發展介紹

益智遊戲可以是一人玩家,也可以是多人對戰。

連連看是一種益智型遊戲,屬消除型益智遊戲。益智遊戲要求玩家用自己的智慧來解決遊戲中的難題,達到過關的目的。新的商業遊戲通常會加上動作要素,要讓玩家手腦並用,訓練協調性,經典的代表作有俄羅斯方塊、泡泡龍系列、憤怒鳥、Candy Crush以及幾年前很紅的2048。傳統的益智遊戲以動腦為主,如數獨、推箱子等。這類遊戲對玩家而言較難過關,導致大量玩家中途放棄。

後來俄羅斯方塊出現,為落下型遊戲始祖,此遊戲最初是由阿列克謝·帕基特諾夫在蘇聯設計和編寫。此遊戲除了成為一個熱門的家用電腦和街機遊戲外,還成為Game Boy史上最受歡迎的遊戲。《電子遊戲月刊》在2007年將此遊戲列為「最偉大的100個遊戲」中的第1位。直到今日,俄羅斯方塊是有史以來最暢銷的電子遊戲。

最新的許多益智遊戲結合了消除型與落下型遊戲的特色,利用落下隨機元素增加了遊戲的隨機性,並增加遊戲特殊道具,讓遊戲更加有趣。如Candy Crush便為一例,Candy Crush與連連看相同皆為消除遊戲,三個相同顏色的方塊可以互相消除,並會隨機落下新的不同顏色的方塊,特殊的消除模式可以產生特殊道具。這樣的遊戲方式有挑戰性,而且適合打發時間,因此成為Facebook平台上最受歡迎遊戲,擁有4億5千6百萬每月玩家。

後來的神魔之塔Dragon Puzzle更結合了角色扮演與卡牌遊戲及線上對戰的特色,使得益智型遊戲的樣貌更加的多元化。植物大戰殭屍則添加了即時戰略遊戲及卡牌遊戲的特色,讓益智型遊戲的面向變得更多元化,此作品獲得了相當正面的評價,在GameRankings的評分達到89.5%,Metacritic的評分則是88。

下圖為不同遊戲中所包含的遊戲性元素:

由上圖可知益智型遊戲在現今的手機市場上,因手機版面限制與操作便利性的關係,益智遊戲比起傳統RPG遊戲或動作遊戲在手機市場上占上更多的優勢。也因著益智遊戲的市場越來越大,後期的益智遊戲所包含的遊戲元素的面向越來越廣,不論是卡牌元素、即時戰略遊戲元素、操作手感元素等,都可以讓遊戲的趣味性更加提升,線上多人遊戲的模式更能增加玩家的黏著度。

設計一款有趣的益智遊戲,其中的演算法設計十分重要,例如物理遊戲的物理公式設計、相消遊戲的消除條件、每個關卡的過關條件、道具設計、遊戲競爭性,上述的每一個項目若能夠越符合大自然的法則,會讓玩家玩起來更加的直覺,也是遊戲好玩的必要關鍵。當然開發一款大型的遊戲,美術、音效、程式、企劃缺一不可,對於程式開發者而言,學習適合的語言工具也十分重要。這些與遊戲設計相關的元素,在這次的鐵人賽裡面,我都會在不同的章節裡提到。

連連看開發模式規劃

這一次的鐵人賽主題裡,我主要會分為兩個教學線。

主線為連連看遊戲邏輯的分析與程式碼撰寫,目標為撰寫出正確的邏輯,需能正確判別兩個圖形間是否存在小於兩個轉彎處的連線存在,並且能夠隨機產生隨機的遊戲牌面,以陣列來儲存遊戲現在的資料。

副線則為使用Pixi.js來實際開發一款具有畫面、效果及音效,真正具有遊戲性的網頁H5遊戲

在這邊所使用的PixiJS是一款2D的HTML5遊戲引擎,可以利用WebGL的功能來做2D的圖像處理,因此在網頁上能夠有著很不錯的效能表現。PixiJS是採用Javascript來開發的,但我在這邊為了開發容易度及順暢度以及不同版本JS的兼容性考量,我採用了TypeScript版本的PixiJS來做開發,並用npm來做套件管理、利用gulp來做自動化管理工具。

在美術素材來源上,我是至Unity Asset Store尋找並下載免費素材來使用。在遊戲畫面設計及圖片動畫處理上,則是利用Adobe Animate CC來繪圖並產生連續圖檔(同Texture Packer的功能),動態效果則使用了GSAP的Tween工具來實現。

在音效的部份,我滿推薦小森平的免費音效,裡面的音效都有分門別類的整理好,並且可以線上試聽後直接下載,可以很容易的找到適合的音效。程式上的音效處理我則使用howler來做音效載入與播放管理的工具。背景音樂的部份,我則是至Youtube音樂音效庫尋找並下載適合的背景音樂。

在IDE的部份,我是使用VS Code來做開發,配合PixiJS devtoolschrome dev tools來做除錯、測試和畫面檢查。

教學項目

下圖為這30天內,我預計會提到的必要項目,也就是連連看邏輯的教學項目:

下圖為製作真正有音效有畫面的H5遊戲的教學項目:

鐵人賽30天內容規劃

以下為預先規劃的30天的內容,有可能會隨實際情況做更動:

[1 - 前言] 連連看遊戲開發
[2 - 演算法] 演算法介紹
[3 - 環境設定] 開發環境介紹
[4 - 遊戲邏輯] 產生初始盤面
[5 - 遊戲邏輯] 圖形連線消除邏輯發想
[6 - 遊戲邏輯] 連線消除程式撰寫
[7 - 遊戲邏輯] 電腦搜尋路徑
[8 – 遊戲介紹] 遊戲歷史簡介
[9 - 遊戲介紹] 遊戲開發技術介紹
[10- 遊戲製作] PixiJS介紹
[11- 遊戲製作] 使用模組介紹
[12- 遊戲製作] 介面設計
[13- 遊戲製作] 素材處理
[14- Pixi教學] PIXI場景設定
[15- Pixi教學] 載入素材
[16- Pixi教學] 與網頁互動-控制loading page
[17- Pixi教學] 音樂音效設定
[18- Pixi教學] 按鈕製作
[19- Pixi教學] 連連看盤面實作
[20- Pixi教學] 連連看公仔實作
[21- Pixi教學] 連線效果實作-Graphics
[22- Pixi教學] 按鈕動態- Tween
[23- Pixi教學] 復原按鈕功能實作
[24- Pixi教學] 提示按鈕功能實作
[25- Pixi教學] 遊戲開始、結束與過關設定
[26- Pixi教學] 遊戲功能完成
[27- Pixi教學] 連線消除效果- Particles
[28- 相關工具] PixiJS devtools
[29- 相關工具] 效能評估工具
[30- 相關工具] 手機遠程測試

參考資料

發佈日期: 發佈留言

單元測試 – 重構測試

重構test的重要性

好的測試應該要容易維護,容易閱讀,
不應包含程式邏輯在內,因此像是if, while, for迴圈等都不應該出現在測試裡
如果我們驗證的內容會和資料有關,則建議使用Substitute,這樣可以讓我們能夠在每一個測試裡增加不同的資料
而且可以直接在每個測試裡看到資料的內容是什麼
下面是一個範例
private void AmountShouldBe(int expected, DateTime start, DateTime end)
{
IList data = new List()
{
new Budget() {Amount = 310, YearMonth = “201801”},
new Budget() {Amount = 620, YearMonth = “201803”},
new Budget() {Amount = 900, YearMonth = “201804”}
};

var budgetCalculator = new BudgetCalculator(new TestDataBudgetRepository(data));
var budget = budgetCalculator.TotalAmount(start, end);
Assert.AreEqual(expected, budget);
}
91的課程中建議每一次寫成功一個測試案例,就可以先做重構,這樣之後的的開發速度可以更加速

重構測試的步驟

  • 抽出field: mock object(繼承假物件去創建假物件), stub(用Substitute)
  • Setup: SUT初始化(或者[TestInitialize])
  • 抽出方法(用Given為開頭):定義mock object行為,代表假如在跑這個scenario時…
  • Extra Method with “Shouldxxx()” => SUT行為+Assertion

也就是盡可能讓我們的重構能夠符合3A原則,
3A pattern: Arrange, Act, Assert
上面重構後Arrange就用Given…,然後Act就是SUT行為,Assert就是Assertion。

重構的測試的範例

下面為重構後的程式碼:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NSubstitute;
using NUnit.Framework;
using RsaSecureToken;
using Assert = NUnit.Framework.Assert;

namespace RsaSecureToken.Tests
{
[TestFixture]
public class AuthenticationServiceTests
{
private IProfile _fakeProfile;
private IRsaToken _fakeToken;
private ILogger _logger;
private AuthenticationService _authenticationService;

[SetUp]
public void Given()
{
_fakeProfile = Substitute.For();

_fakeToken = Substitute.For();

_logger = Substitute.For();

_authenticationService = new AuthenticationService(_fakeProfile, _fakeToken, _logger);
}

private void GivenToken(string token)
{
_fakeToken.GetRandom(“”).ReturnsForAnyArgs(token);
}

private void GivenPassword(string account, string password)
{
_fakeProfile.GetPassword(account).Returns(password);
}

private void ShouldBeValid(string account, string password)
{
var actual = _authenticationService.IsValid(account, password, _logger);
Assert.IsTrue(actual);
}

[Test()]
public void IsValidTest()
{
GivenPassword(account: “joey”, password: “91”);
GivenToken(token: “000000”);

ShouldBeValid(account: “joey”, password: “91000000”);
}

[Test()]
public void IsInValidTest()
{
GivenPassword(account: “joey”, password: “91”);
GivenToken(token: “000000”);

ShouldBeInValid(account: “joey”, errorPassword: “error password”);
}

private void ShouldBeInValid(string account, string errorPassword)
{
var actual = _authenticationService.IsValid(account, errorPassword, _logger);
Assert.IsFalse(actual);
}

[Test()]
public void ShouldLog()
{
GivenPassword(account: “joey”, password: “91”);
GivenToken(token: “000000”);

ShouldBeInValid(account: “joey”, errorPassword: “error password”);

//這個可能會有過度指定的問題,或許加一個痘號就會導致測試失敗
//_logger.Received(1).Save(Arg.Is(“account: joey try to login failed”));
_logger.Received(1).Save(Arg.Is(m => m.Contains(“joey”) && m.Contains(“login failed”)));
}
}
}

發佈日期: 發佈留言

單元測試 – 隔離框架Substitute.For

STUB的功能

這邊是NSubstitute的說明:http://nsubstitute.github.io/help/getting-started/
Substitute是.NET裡的一個隔離框架,若要使用,需要額外在測試專案用NUGET去安裝NSubstitute

1. 動態產生假物件
2. 模擬回傳值
3. 測試監聽事件
4. 驗證傳入參數是否正確

使用Subsitute(Sub)
使用方法如下
calculator = Substitute.For();
設定呼叫某個方法應該回傳的值
calculator.Add(1, 2).Returns(3);
Assert.That(calculator.Add(1, 2), Is.EqualTo(3));
下面可以驗證是否Add這個FUNC有被呼叫到
calculator.Add(1, 2);
calculator.Received().Add(1, 2);
calculator.DidNotReceive().Add(5, 7);
下面的程式能夠判別傳入的參數是不是正確
calculator.Add(10, -5);
calculator.Received().Add(10, Arg.Any());
calculator.Received().Add(10, Arg.Is(x => x < 0));[/code] 驗證回傳值是否正確 [code lang="C#"]calculator .Add(Arg.Any(), Arg.Any())
.Returns(x => (int)x[0] + (int)x[1]);
Assert.That(calculator.Add(5, 10), Is.EqualTo(15));

使用Substitute來針對不同狀況實作假介面

這是一個用MOCK方法的範例

using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
//using Microsoft.VisualStudio.TestTools.UnitTesting;
using NUnit.Framework;
using RsaSecureToken;
using Assert = NUnit.Framework.Assert;

namespace RsaSecureToken.Tests
{
    [TestFixture]
    public class AuthenticationServiceTests
    {
        [Test()]
        public void IsValidTest()
        {
            var target = new AuthenticationService(new FakeProfile(), new FakeToken());

            var actual = target.IsValid("joey", "91000000");

            //always failed
            Assert.IsTrue(actual);
        }
    }

    public class FakeProfile:IProfile
    {

        public string GetPassword(string account)
        {
            if (account == "joey")
            {
                return "91";
            }

            throw new Exception();
        }
    }

    public class FakeToken:IRsaToken
    {
        public string GetRandom(string account)
        {
            return "000000";
        }
    }
}

上面做法有什麼問題?

  • 每一個不同的依賴案例就要製作一個不同的FakeObject,會讓寫測試的時間太久
  • 沒辦法直接從程式碼知道為什麼這樣會是Vaild

動態產生物件的使用完整範例

Subsitute.For()
定義假物件行為(stub)
fake.方法(參數).Returns(值)
[Test()]
public void IsValidTest()
{
var fakeProfile = Substitute.For();
fakeProfile.GetPassword(“joey”).Returns(“91”);

var fakeToken = Substitute.For();
fakeToken.GetRandom(“”).ReturnsForAnyArgs(“000000”);

var target = new AuthenticationService(fakeProfile, fakeToken);
var actual = target.IsValid(“joey”, “91000000”);

//always failed
Assert.IsTrue(actual);
}
}

驗證某個函數是否被呼叫

使用mock object assertion
需求:驗證是非法的時候要記一個log
fake.Receive(次數).方法(參數驗證)
不要用太多mock就算要使用要避免過度指定,也就是當prod code小小變動就導致測試程式壞掉

下面的程式碼可以驗證當呼叫SyncBookOrders()時是不是會呼叫Insert這個函數兩次:
[Test]
public void Test_SyncBookOrders_3_Orders_Only_2_book_order()
{
var result = new List
{
new Order
{
Type = “Book”
},
new Order
{
Type = “Book”
},
new Order
{
Type = “Item”
}
};

var target = new OrderServiceForTest();
target.SetOrder(result);

var fakeBookDao = Substitute.For();
target.SetDao(fakeBookDao);

target.SyncBookOrders();

fakeBookDao.Received(2).Insert(Arg.Is(m => m.Type == “Book”));
}

驗證傳入參數

驗證傳入的參數是否包含某些關鍵字
_logger.Received(1).Save(Arg.Is(m => m.Contains(“joey”) && m.Contains(“login failed”)));

發佈日期: 發佈留言

單元測試 – Code Coverage的意義

如何用Code Coverage來衡量單元測試的成果

  • 在build的時候要自動去跑測試(使用CI,CD當作工具)> 大於0%
  • 關心相對趨勢 大於 絕對數字,衡量相對的數字,評估code coverage的數值有沒有比昨天高,每一次針對PROD的程式碼修改都要加上測試,這樣這個數值只會往上不會往下。鼓勵持續的COMMIT > 持續整合,每天都要COMMIT個5~6次或7~8次。
  • 那些測試的投資報酬率高:為什麼要寫測試?目標是要提升產品品質。假設這段PROD CODE沒有BUG,其實不需要為它寫測試。那什麼東西的投資報酬率最高?
    • 1. 要修正的BUG(BUG越晚發現修正成本就更高)
    • 2. 實務上常跑到的scenario
    • 3. 最主要的情境
    • 4.和錢有關的
    • 5.和人命有關的(EX:自動駕駛系統)
    • 6. 最常改到的CODE,可以避免被改錯
  • 那code coverage的意義如下:
    • 觀察沒有被含蓋到的情境:判斷要不要補測試
    • Dead Code:根本就不會跑到的Code(代表這一段PROD CODE不會跑到)

最好導入的方式

從上面看來,兩個最好導入的方式:1. 針對所有BUG的修改去寫測試,2. 針對新的專案去寫測試
然後要確認code coverage不可以往下掉。
假如現在有一陀爛CODE,要增加新功能在那堆爛CODE裡,先把爛CODE抽成method,然後再抽成新的Class
針對爛CODE的public的情境先寫測試,會讓需要寫測試的範圍變小很多。

測試的品質

  • 測試的程式一定要重構
  • 測試的語意一定要清楚明白,一般Assert都會抽出成一個func,這是為了讓測試更容易理解,可以很簡單的的從測試的程式碼由語意就能理解這個測試要做什麼
  • 寫測試的難度會反應程式的好壞
發佈日期: 發佈留言

單元測試 – 使用Fake Object

抽取相依的物件並且覆寫

下面是一個範例,這個範例的SUT是Holiday.cs,如果今天是9/1就傳HappyBirthday,否則就傳No
因為單元測試應該要能夠具有隔離的特性,不可因為今天的日期不一樣而有不同的結果
所以”取得今天日期”,就會讓程式碼變得不可測試。

在下面的範例裡,我們可以看見如何使用假物件去測試不可測試的程式碼:
Holiday.cs
using System;

namespace TestProject1
{
public class Holiday
{
public string IsTodayJoeyBirthday()
{
var date = GetToday();
return (date.Month == 9 && date.Day == 1) ? “HappyBirthday” : “No”;
}
//將有相依的部份抽出來
protected virtual DateTime GetToday()
{
return DateTime.Today;
}
}
}
Test2Cs.cs
using System;
using NUnit.Framework;
using TestProject1;

namespace TestProject1
{
[TestFixture]
public class Test2Cs
{
[Test]
public void today_is_not_joey_birthday()
{
var target = new HolidayForTest();
target.SetToday(new DateTime(2015,1,1));
Assert.AreEqual(target.IsTodayJoeyBirthday(), “No”);
}

[Test]
public void today_is_joey_birthday()
{
var target = new HolidayForTest();
target.SetToday(new DateTime(2015,9,1));
Assert.AreEqual(target.IsTodayJoeyBirthday(), “HappyBirthday”);
}
}
}
//創建一個假物件並且繼承Holiday,替換掉取得今日日期這件事
internal class HolidayForTest : Holiday
{
private DateTime _today;
protected override DateTime GetToday()
{
return _today;
}

public void SetToday(DateTime date)
{
_today = date;
}
}

如何對現行沒有測試的PROD CODE加上unit test


這一個測試的重點在於:針對依賴的code,找出不可控制的地方並抽出來

  • 找到不可控制的依賴
  • 抽出方法
  • 把private改成protect virtual
  • 在測試專案新增子類繼承SUT
  • override protected方法並加開set方法
  • 測試SUT改測子類
  • set依賴的值

使用依賴注入

使用依賴注入來達成低藕合高內聚的程式碼
1. 針對相依的物件抽出interface,針對相依的值抽出field
2. 產生contructor,選要注入的field去依賴注入
3. 產生無參數的constructor,確保本來的程式無誤
4. 測試程式新增fake物件做interface,決定行為並注入SUT

所謂低藕合高內聚的程式碼也可以讓程式更容易被測試

用Fake Object來取代相依物件的方法

如果測試的資料都是固定的,可以在測試裡面創建假物件來注入SUT(測試目標)

以下為範例程式
AuthenticationServiceTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
//using Microsoft.VisualStudio.TestTools.UnitTesting;
using NUnit.Framework;
using RsaSecureToken;
using Assert = NUnit.Framework.Assert;

namespace RsaSecureToken.Tests
{
[TestFixture]
public class AuthenticationServiceTests
{
[Test()]
public void IsValidTest()
{
var target = new AuthenticationService(new FakeProfile(), new FakeToken());

var actual = target.IsValid(“joey”, “91000000”);

//always failed
Assert.IsTrue(actual);
}
}

public class FakeProfile:IProfile
{

public string GetPassword(string account)
{
if (account == “joey”)
{
return “91”;
}

throw new Exception();
}
}

public class FakeToken:IRsaToken
{
public string GetRandom(string account)
{
return “000000”;
}
}
}
AuthenticationService.cs
using System;
using System.Collections.Generic;

namespace RsaSecureToken
{
public class AuthenticationService
{
private IProfile _profileDao;
private IRsaToken _rsaToken;

public AuthenticationService()
{
_profileDao = new ProfileDao();
_rsaToken = new RsaTokenDao();
}
//for test
public AuthenticationService(IProfile profile, IRsaToken rsaToken)
{
_profileDao = profile;
_rsaToken = rsaToken;
}

public bool IsValid(string account, string password)
{
// 根據 account 取得自訂密碼
var passwordFromDao = _profileDao.GetPassword(account);

// 根據 account 取得 RSA token 目前的亂數
var randomCode = _rsaToken.GetRandom(account);

// 驗證傳入的 password 是否等於自訂密碼 + RSA token亂數
var validPassword = passwordFromDao + randomCode;
var isValid = password == validPassword;

if (isValid)
{
return true;
}
else
{
return false;
}
}
}

public interface IProfile
{
string GetPassword(string account);
}

public class ProfileDao : IProfile
{
public string GetPassword(string account)
{
return Context.GetPassword(account);
}
}

public static class Context
{
public static Dictionary profiles;

static Context()
{
profiles = new Dictionary();
profiles.Add(“joey”, “91”);
profiles.Add(“mei”, “99”);
}

public static string GetPassword(string key)
{
return profiles[key];
}
}

public interface IRsaToken
{
string GetRandom(string account);
}

public class RsaTokenDao : IRsaToken
{
public string GetRandom(string account)
{
var seed = new Random((int)DateTime.Now.Ticks & 0x0000FFFF);
var result = seed.Next(0, 999999).ToString(“000000”);
Console.WriteLine(“randomCode:{0}”, result);

return result;
}
}
}

發佈日期: 發佈留言

單元測試 – 寫測試的基本原則

熱鍵

  • Alt+Enter: Quick+Action
  • Ctrl+R,M 抽方法出來
  • code snippets / live template
  • constructor: ctor
  • property:prop
  • console.writeLine)():cw
  • ctrl+R,F: Extract Field
  • Alt+Insert
  • 循環剪貼簿:ctrl+shift+V

測試替身有下面三種

  • stub:不做驗證,單純只做模擬相依物件的行為
  • mock:一開始就要把所有的都定義清楚,應該要呼叫那個方法,所有的值一定要一模一樣,否則就會報錯(嚴格,敏感,不穩定)
  • spy: 則是把所有的互動先做完,只驗要驗的,剩的沒有測就是都算過,差異是一個從嚴一個從寬。(寬鬆)

因此mock和spy本身含有驗證(Assertion),而stub本身只有在模擬相依的物件而已。

比較物件屬性的方式

比較兩邊的物件包含子物件完全相同

expected.ToExpectedObject().ShouldEqual(actual);

比較以expected為主的屬性去比較相對應的actual是否相同

expected.ToExpectedObject().ShouldMatch(actual);

測試範例如下
namespace AssertionSample
{
[TestFixture]
public class AssertionSample
{
private CustomerRepo customerRepo = new CustomerRepo();

//比較物件屬性的方式
[Test]
public void CompareCustomer()
{
var actual = customerRepo.Get();
var expected = new Customer
{
Id = 2,
Age = 18,
Birthday = new DateTime(1990, 1, 26)
};

//CollectionAssert也是在比較物件的位置,所以也會有相同的問題
expected.ToExpectedObject().ShouldEqual(actual);
}

//比較集合的方式
[Test]
public void CompareCustomerList()
{
var actual = customerRepo.GetAll();
var expected = new List
{
new Customer()
{
Id = 3,
Age = 20,
Birthday = new DateTime(1993,1,2)
},
new Customer()
{
Id = 4,
Age = 21,
Birthday = new DateTime(1993,1,3)
}
};

expected.ToExpectedObject().ShouldEqual(actual);
}

//組合式物件的比較
[Test]
public void CompareComposedCustomer()
{
var actual = customerRepo.GetComposedCustomer();

var expected = new Customer()
{
Age = 30,
Id = 11,
Birthday = new DateTime(1999, 9, 9),
Order = new Order {Id = 19, Price = 91},
};

expected.ToExpectedObject().ShouldEqual(actual);
}

//回傳的資料的PROPERITY很多,但是我們只想比其中幾項
[Test]
public void PartialCompare_Customer_Birthday_And_Order_Price()
{
var actual = customerRepo.GetComposedCustomer();

//有自定型別的一定要改成匿名型別
var expected = new
{
Birthday = new DateTime(1999, 9, 9),
Order = new {Price = 91},
};

//使用匿名型別 去比較以expected為主去比較相對應的actual是否相同
expected.ToExpectedObject().ShouldMatch(actual);
}
}

寫測試的規則


測試程式不含商業邏輯,所有的測試都應該是直述句
不應包含以下的元素:

  • prod business logic
  • 不含if, else, switch case等邏輯程式碼
  • 更不含try..catch
  • 不含for, while, foreach, do..while

善用assertion package

  • C#: expectedObjects,FluentAssertions
  • Java: Assert J

不要攤開properity做比較

[Test]
public void Divide_Zero()
{
var calculator = new Calculator();
var actual = calculator.Divide(5, 0);
Action action = () =>
{
calculator.Divide(5, 0);
};
action.Should().Throw();
//never use try/catch in unit test
}

寫測試一定要重構,不然在測試需求異動時和寫測試時會太花時間
因此不應該要有太多不會用到的資訊
讓測試的目標的意圖可以很明顯
要如何加快單元測試撰寫的速度很重要,這樣才有可能可以實踐單元測試
沒有時間是個問題,但是我們要去面對如何解決這個問題
要知道怎麼用工具怎麼寫比較快

發佈日期: 發佈留言

單元測試的藝術-單元測試基礎

單元測試

一個單元代表的是系統中的工作單元或是一個使用案例
被測試的系統(System Under Test)我們稱做SUT或者Class Unit Test(CUT)
一個單元測試是一段程式呼叫一個工作單元,並驗證工作單元的一個具體最終結果。如果對這個最終結果的假設是錯誤的,那單元測試就失敗了。一個單元測試的範圍,可以小到一個方法,大到多個類別。

優秀單元測試的特質

  • 自動化,可被重覆執行的
  • 很容易被實現
  • 非臨時性的
  • 任何人都可以按鈕執行他
  • 執行速度快
  • 執行結果每次都是一致的
  • 能完全掌控被測試的單元
  • 完全被隔離的
  • 若執行失敗會有清楚的原因

整合測試

整合測試是一個有順序的測試過程,將軟硬體相結合並進行測試直到整個系統被整合在一起。也就是這個測試對被測試的單元並沒有完全的控制,而是使用該單元一個或多個真實依賴的相依物件,例如:時間,網路,資料庫,執行緒,亂數產生器等等。

一個單元測試通常包含了三個行為

  • 準備(Arrange):物件,建立物件,進行必要的設定
  • 操作(Act): 物件
  • 驗證(Asset): 某件事符合預期

Assert類別

  • Assert.True: 驗證一個布林條件,見Assert.False
  • Assert.AreEqual: 驗證傳回的值應相同
  • Assert.AreSame: 驗證兩個參數應指向同一個物件

使用參數來測試

使用TestCase標籤

Setup和tesrdown