[翻譯]使用AngularJS開(kāi)發(fā)2048

本文從 這里 翻譯過(guò)來(lái)的郑气。

2048這個(gè)游戲有一段時(shí)間特別火,Github上有其原始版本腰池,游戲看起來(lái)很簡(jiǎn)單尾组,但是很耐玩,要玩通關(guān)卻也需要一番技巧與耐心示弓。

剛開(kāi)始學(xué)習(xí)Angular讳侨,本想著依葫蘆畫(huà)瓢實(shí)現(xiàn)ng2048,結(jié)果在github上搜了搜奏属,發(fā)現(xiàn)別人已經(jīng)有實(shí)現(xiàn)了跨跨,而且作者還將其開(kāi)發(fā)過(guò)程非常詳細(xì)的寫(xiě)出來(lái),這才有了下面硬著頭皮的翻譯囱皿。

注意 :強(qiáng)烈建議您參考原文試讀一下勇婴,譯者水平有限,要是惡心到了您嘱腥,這個(gè)實(shí)非我所愿耕渴。

本想著寫(xiě)一行大字,最好是要醒目的:"轉(zhuǎn)載請(qǐng)標(biāo)明出處"齿兔,后來(lái)看過(guò)coolshell上一篇文章后:互聯(lián)網(wǎng)之子 – Aaron Swartz 橱脸,覺(jué)得沒(méi)什么必要了.

翻譯的過(guò)程中的確碰到了讓人頭疼不已的事情:

  • 有些詞語(yǔ)到底要不要翻譯呢?
    我會(huì)根據(jù)自己的理解分苇,盡量避免不必要的翻譯添诉,對(duì)于不得不翻譯,但是感覺(jué)譯過(guò)之后似有不妥的詞组砚,我會(huì)補(bǔ)充到文章開(kāi)始吻商,以便作為提醒。
  • 翻譯成什么樣更好呢糟红?
    這個(gè)就因能力和精力而定了艾帐,
  • 是直譯還是意譯呢乌叶?
    漢語(yǔ)博大精深,有些英語(yǔ)長(zhǎng)句要講明白的事情柒爸,漢語(yǔ)可以很簡(jiǎn)練的表達(dá)相同的意思准浴,同時(shí)并非人人都是文豪,文章可能會(huì)啰嗦捎稚,這時(shí)為了閱讀流暢乐横,我會(huì)有意去掉部分語(yǔ)句,當(dāng)然前提是不給讀者的理解帶來(lái)影響今野。

Game Board:游戲面板葡公,
grid:游戲面板上切分出來(lái)的格子,簡(jiǎn)稱格子
tile:在格子上移動(dòng)的方塊条霜,簡(jiǎn)稱方塊


我經(jīng)常被問(wèn)到的一個(gè)問(wèn)題是:什么情況下使用Angular會(huì)被認(rèn)為是一種很2的選擇催什,這個(gè)問(wèn)題的答案通常是:游戲制作,Angular有它自己的事件循環(huán)操作($digest 循環(huán))宰睡,通常游戲需要大量的底層DOM操作蒲凶。由于Angular能夠支持很多種類(lèi)型的游戲,上面的理由有些牽強(qiáng)拆内,即使需要大量DOM操作的游戲旋圆,Angular也是可以用來(lái)開(kāi)發(fā)游戲的靜態(tài)內(nèi)容,比如跟蹤分?jǐn)?shù)排行榜和創(chuàng)建游戲菜單麸恍。

如果你和我一樣灵巧,也癡迷于2048這個(gè)流行的游戲,這個(gè)游戲的目標(biāo)是通過(guò)消除相同得分的方塊最終獲得2048這個(gè)方塊抹沪。

在我的文章中孩等,我們準(zhǔn)備開(kāi)發(fā)一個(gè)AngularJS版本的2048,從開(kāi)始到結(jié)束采够,解釋開(kāi)發(fā)的整個(gè)過(guò)程,由于2048是一個(gè)相對(duì)復(fù)雜的應(yīng)用程序冰垄,所以本文也可以看做是教授如何使用AngularJS構(gòu)建復(fù)雜應(yīng)用程序的例子蹬癌。

文章太長(zhǎng)了,不讀了:
所有的源碼放在github上虹茶,點(diǎn)擊這里跳轉(zhuǎn)

Index

  1. Planning the app
  2. Modular structure
  3. GameController
  4. Testing testing testing
  5. Building the grid
  6. SCSS to the rescue
  7. The tile directive
  8. The Boardgame
  9. Grid theory
  10. Gameplay (keyboard)
  11. Pressing the start button
  12. The game loop
  13. Keeping score
  14. Game over and win screens
  15. Animation
  16. Customization
  17. Demo

First steps: planning the app

不論程序規(guī)模大小逝薪,是復(fù)制別人的還是自己原創(chuàng)的,第一步要做的都是:高屋建瓴式的設(shè)計(jì)蝴罪。

玩過(guò)2048的人應(yīng)該清楚董济,游戲有一個(gè)面板(board),上面是一些格子要门,每個(gè)格子就是一個(gè)位置虏肾,標(biāo)上數(shù)字的方塊可以在這些格子上移動(dòng)廓啊,根據(jù)這個(gè)事實(shí),可以不依賴于javascript封豪,讓CSS3負(fù)責(zé)處理方塊在面板上的移動(dòng)谴轮,


3d-board.png

由于只有一個(gè)頁(yè)面,所以只需要一個(gè)controller管理頁(yè)面吹埠。

玩游戲期間只有一個(gè)游戲面板第步,我們會(huì)把相關(guān)操作grid的邏輯包含在一個(gè)GridService中,GridService services是單例對(duì)象缘琅,在它里面保存方塊是合適的粘都,GridService將會(huì)用來(lái)操作方塊的放置,移動(dòng)以及遍歷方塊尋找可能的位置刷袍。

我們會(huì)在GameManager sevice中存儲(chǔ)游戲的邏輯和處理程序翩隧,GameManager負(fù)責(zé)管理游戲的狀態(tài),操作格子的移動(dòng)做个,以及維護(hù)用戶得分(包括當(dāng)前得分以及最高得分)

最后鸽心,需要一個(gè)管理鍵盤(pán)的組件,我們叫她KeyboardService居暖,本文中我們僅實(shí)現(xiàn)PC端的操作顽频,但是也可以重用這個(gè)service去管理觸摸操作,以便在移動(dòng)設(shè)備上能夠正常工作太闺。

Building the app

先要使用yeoman angular generator生成應(yīng)用的文件結(jié)構(gòu)糯景,這個(gè)不是必須的,我們會(huì)放置一個(gè)test目錄省骂,這個(gè)目錄和app目錄是平級(jí)的蟀淮。

下面使用yuoman建立項(xiàng)目,如果你更愿意手工操作钞澳,可以跳過(guò)相應(yīng)的內(nèi)容怠惶。

首先確保安裝了yeoman,yeoman依賴于NodeJS和npm轧粟,安裝NodeJS超出了本文的范圍策治,你可以參考NodeJS官網(wǎng)的指導(dǎo)進(jìn)行安裝。

npm安裝好之后兰吟,就可以安裝yeoman工具yo和angular generator(yo會(huì)使用generator去創(chuàng)建Angular app):

$ npm install -g yo
$ npm install -g generator-angular

安裝好之后通惫,就可以使用yeoman工具創(chuàng)建應(yīng)用了:

$ cd ~/Development && mkdir 2048
$ yo angular twentyfourtyeight

執(zhí)行過(guò)程中會(huì)被問(wèn)一些問(wèn)題,除了選擇angular-cookies作為依賴這一項(xiàng)混蔼,其他只要回答yes就可以了履腋,

Our angular module

現(xiàn)在創(chuàng)建程序的入口文件scripts/app.js:

angular.module('twentyfourtyeightApp', [])

Modular structure

我們推薦Angular應(yīng)用的目錄結(jié)構(gòu)采用功能分類(lèi),而不是類(lèi)型分類(lèi),也就是說(shuō)不要依照controllers遵湖,services悔政,directives分割項(xiàng)目組件,而是應(yīng)該根據(jù)模塊功能定義模塊結(jié)構(gòu)奄侠,例如我們的應(yīng)用中定義了Game模塊和KeyBoard模塊卓箫。

模塊結(jié)構(gòu)體現(xiàn)出一個(gè)清晰的文件和職責(zé)對(duì)應(yīng)關(guān)系,這有助于構(gòu)建大型的復(fù)雜Angular應(yīng)用程序垄潮,同時(shí)也更加容易得共享模塊功能烹卒。

scripts_dir.png
The view

最容易開(kāi)始的地方就是寫(xiě)頁(yè)面了,在這個(gè)應(yīng)用中不需要多個(gè)頁(yè)面弯洗,因此創(chuàng)建一個(gè)div元素裝載應(yīng)用程序的內(nèi)容就可以了旅急。

在app/index.html文件中,需要包含所有依賴(包括angular.js以及我們自己編寫(xiě)的javascript文件-到現(xiàn)在為止牡整,只有一個(gè)script/app.js):

index.html

后續(xù)我們只需要修改app/views/main.html文件就可以了藐吮,當(dāng)需要引入資源文件時(shí),才會(huì)去修改app/index.html文件

打開(kāi)app/views/main.html文件逃贝,其中將放置游戲需要的頁(yè)面元素谣辞,使用controllerAs語(yǔ)法,它告訴了$scope去哪里找到數(shù)據(jù)沐扳,哪個(gè)controller負(fù)責(zé)操作哪個(gè)component泥从。

<!-- app/views/main.html -->
<div id="content" ng-controller='GameController as ctrl'>
  <!-- Now the variable: ctrl refers to the GameController -->
</div>

controllerAs語(yǔ)法來(lái)自1.2版本,當(dāng)在頁(yè)面上操作多個(gè)controller時(shí)使用它非常有用

view中沪摄,我們至少要包含下面幾項(xiàng)

  1. 游戲的標(biāo)題
  2. 當(dāng)前得分和最高得分
  3. 游戲面板

游戲的靜態(tài)頭部信息簡(jiǎn)單就是下面這樣子:

游戲的靜態(tài)頭部信息

The GameController

項(xiàng)目框架搭起來(lái)了躯嫉,接下來(lái)創(chuàng)建GameController去裝載需要在頁(yè)面中展示的元素,在app/scripts/app.js文件中杨拐,使用下面的語(yǔ)句在twentyfourtyeightApp模塊上創(chuàng)建controller祈餐。

angular.module('twentyfourtyeightApp', [])
.controller('GameController', function() {
});

在頁(yè)面上引用了將要給GameController設(shè)置的game對(duì)象,game對(duì)象將會(huì)引用到模塊中的main game對(duì)象哄陶,main game對(duì)象將會(huì)在新的模塊中創(chuàng)建

.controller('GameController', function(GameManager) {
  this.game = GameManager;
});

由于這個(gè)模塊還沒(méi)有被創(chuàng)建帆阳,所以我們的應(yīng)用還不能在瀏覽器中運(yùn)行,在Controller內(nèi)部我們添加GameManager依賴屋吨。

記住舱痘,應(yīng)用程序的不同模塊之間有依賴,為了確保依賴能夠被加載离赫,需要將依賴的模塊注入到Angular的應(yīng)用中,為了使Game模塊成為twentyfourtyeightApp的依賴塌碌,在模塊定義地方將其注入進(jìn)來(lái)渊胸。

我們整個(gè)app/scripts/app.js文件看起來(lái)應(yīng)該是下面這樣子

angular
.module('twentyfourtyeightApp', ['Game'])
.controller('GameController', function(GameManager) {
  this.game = GameManager;
});
The Game

接下來(lái)開(kāi)發(fā)游戲自身的邏輯,創(chuàng)建app/scripts/game/game.js文件台妆,新建Game模塊

angular.module('Game', []);

Game模塊提供一個(gè)核心的組件:GameManager
GameManager 負(fù)責(zé)管理游戲的狀態(tài)翎猛,用戶發(fā)出的不同運(yùn)動(dòng)指令胖翰、跟蹤記錄游戲得分,判斷游戲是否結(jié)束以及用戶贏了還是輸了

GameManager需要支持的功能就有:

  1. 創(chuàng)建新游戲
  2. 處理循環(huán)和移動(dòng)操作
  3. 更新游戲得分
  4. 監(jiān)控游戲狀態(tài)

這樣GameManager的基本框架就有了

GameManager
Back to the GameManager

movesAvailable()用于檢查是否還有可用的格子切厘,以及是否存在可以合并的方塊


movesAvailable

Building the game grid

接下來(lái)創(chuàng)建GridService去管理游戲面板

回想一下萨咳,如何處理游戲面板呢,我們用到了兩個(gè)數(shù)組疫稿,grid和tile數(shù)組
在app/scripts/grid/grid.js文件培他,讓我們創(chuàng)建對(duì)應(yīng)的service

GridService

當(dāng)開(kāi)始新游戲時(shí),需要將grid和tile數(shù)組元素置為null遗座,grid數(shù)組是靜態(tài)的舀凛,它只用于DOM元素的占位使用。

tile數(shù)組是動(dòng)態(tài)的途蒋,它保存著前游戲中的方塊猛遍。

在app/views/main.html文件中添加grid指令,將controller上的GameManager對(duì)象實(shí)例傳遞到視圖里面

在app/scripts/grid/目錄里面添加文件grid_directive.js号坡,grid指令基本不需要什么變量懊烤,她的職責(zé)只是封裝對(duì)應(yīng)的視圖。

grid_directive.js

這個(gè)指令只是負(fù)責(zé)創(chuàng)建游戲的grid視圖宽堆,其中不需要任何的邏輯腌紧。

grid.html

在指令模板中,有兩次ngRepeat的調(diào)用日麸,分別顯示的是grid和tile數(shù)組的內(nèi)容寄啼。


main.html

第一個(gè)ng-repeat指令相當(dāng)直接,它簡(jiǎn)單迭代grid數(shù)組代箭,放置一個(gè)空的包含class為grid-cell的div元素

在第二個(gè)ng-repeat指令中墩划,我們?yōu)槊恳粋€(gè)展示在屏幕上被叫做tile的元素創(chuàng)建了第二個(gè)指令,tile指令負(fù)責(zé)元素可視化展示的創(chuàng)建嗡综,稍后我們會(huì)創(chuàng)建乙帮。

聰明的讀者會(huì)注意到,我們使用一維的數(shù)組去展示一個(gè)二維的方格极景,當(dāng)視圖被渲染時(shí)察净,我們就可以看到想要的形式了。

Enter SCSS

在項(xiàng)目中會(huì)使用SCSS盼樟,SCSS增強(qiáng)了CSS氢卡,提供了動(dòng)態(tài)創(chuàng)建CSS的能力。

為了創(chuàng)建二維的游戲面板晨缴,我們使用了CSS3中的transform译秦,使用它去將方塊定位到指定的位置上。

CSS3 transform property

transform屬性可以對(duì)元素使用2D和3D變換,例如:移動(dòng)元素筑悴,旋轉(zhuǎn)元素等等们拙。

看下面的demo,是一個(gè)寬度為40px的正方形盒子阁吝,通過(guò)使用transformX(300px)就可以使這個(gè)元素沿X軸移動(dòng)300px砚婆,

transformX
.box.transformed {
  -webkit-transform: translateX(300px);
  transform: translateX(300px);
}

可以簡(jiǎn)單的通過(guò)給元素添加class的形式達(dá)到移動(dòng)tiles方塊的目的,剩下來(lái)的工作就是:如何給游戲面板上的方塊創(chuàng)建class突勇。

這就是SCSS閃耀的地方装盯,我們首先創(chuàng)建一些變量,例如每一行有幾個(gè)格子与境,然后使用這些變量構(gòu)建SCSS验夯,下面這些變量可以用來(lái)給游戲面板定位:

$width: 400px;          // The width of the whole board
$tile-count: 4;         // The number of tiles per row/column
$tile-padding: 15px;    // The padding between tiles

通過(guò)在SCSS使用這些變量就可以為我們計(jì)算位置了,首先需要計(jì)算每一個(gè)方塊的寬度:

$tile-size: ($width - $tile-padding * ($tile-count + 1)) / $tile-count;

接下來(lái)是為游戲面板設(shè)置合適的寬度和高度摔刁,我們?yōu)槠鋬?nèi)部的容器設(shè)置絕對(duì)定位挥转,這里貼出了部分SCSS文件的內(nèi)容,完整內(nèi)容可以在項(xiàng)目源碼中找到共屈。

SCSS文件

注意一點(diǎn)就是:為了使tile-container放置到grid-container之上绑谣,必須給tile-container設(shè)置一個(gè)高于grid-container的z-index值,不然瀏覽器會(huì)認(rèn)為他們處于同一個(gè)z-index上拗引,這樣效果就不好看了借宵。

接下來(lái)是動(dòng)態(tài)生成方塊的定位,我們需要一個(gè).position-{x}-{y}類(lèi)矾削,x壤玫、y代表的是方塊在游戲面板中的坐標(biāo),例如使用0哼凯,0 表示第一個(gè)tile的位置

下面是計(jì)算過(guò)程:

.tile

現(xiàn)在生成了動(dòng)態(tài)的.position-#{x}-#{y}欲间,接下來(lái)就可以在屏幕上展示tile了

Coloring the different tiles

不同數(shù)值的tile擁有不同的顏色,使用上面用到的技術(shù)断部,通過(guò)迭代color變量猎贴,給tile生成一個(gè)顏色相關(guān)的class,下面的SCSS數(shù)組用于定義不同方塊擁有的顏色

$colors

迭代$color數(shù)組蝴光,給不同數(shù)值的方塊創(chuàng)建對(duì)應(yīng)的顏色類(lèi)她渴,例如擁有數(shù)值2的方塊,我們將添加一個(gè).tile-2的類(lèi)蔑祟,它將擁有背景色#EEE4DA趁耗,使用SCSS可以動(dòng)態(tài)完成這項(xiàng)工作。

.tile-x

The Tile directive

tile指令是視圖的容器疆虚,我們不期望它里面包含很多邏輯苛败,能夠訪問(wèn)到它所占據(jù)的格子就可以了右冻,除此之外,沒(méi)有其他功能需要添加到這個(gè)指令中了

tile directive

一個(gè)有意思的事情是著拭,方塊是如何動(dòng)態(tài)的放置到游戲面板上的,多虧了模板中的ngModel變量牍帚,ngModel指向的是tiles數(shù)組儡遮。

<div ng-if='ngModel' class="tile position-{{ ngModel.x }}-{{ ngModel.y }} tile-{{ ngModel.value }}">
  <div class="tile-inner">
    {{ ngModel.value }}
  </div>
</div>

有了這個(gè)基本的指令,我們幾乎可以在屏幕上顯示了暗赶,每一個(gè)tile都有x和y坐標(biāo)鄙币,x、y的值會(huì)被動(dòng)態(tài)的賦給類(lèi).position-#{x}-#{y}蹂随,瀏覽器在給元素應(yīng)用了這些class之后十嘿,元素就被定位到了期望的位置上。

這也意味值tile對(duì)象需要x岳锁、y和value三個(gè)屬性绩衷。

The TileModel

我們要用到Angular的依賴注入技術(shù),我們創(chuàng)建一個(gè)裝載數(shù)據(jù)的service TileModel service

TileModel
Our first grid

有了TileModel激率,我們可以開(kāi)始給tile數(shù)組添加TileModel對(duì)象的實(shí)例了咳燕,然后他們就會(huì)魔法般的出現(xiàn)在正確的格子上。

gird service

The Board’s ready for the game

現(xiàn)在可以把tile畫(huà)到屏幕上了乒躺,在GridService中需要有一個(gè)功能去準(zhǔn)備游戲的面板招盲,當(dāng)?shù)谝淮渭虞d網(wǎng)頁(yè)的時(shí)候,創(chuàng)建一個(gè)空的游戲面板嘉冒,當(dāng)用戶點(diǎn)擊新建游戲或者重來(lái)一次的時(shí)候曹货,同樣需要?jiǎng)?chuàng)建新的游戲面板。

GridService中buildEmptyGameBoard()函數(shù)就有用來(lái)創(chuàng)建一個(gè)新的游戲面板的,這個(gè)方法負(fù)責(zé)把grid和tile數(shù)組元素置為null伟骨。

buildEmptyGameBoard()

下面是一些用到的工具函數(shù)

工具函數(shù)

Multi-dimensional array in one dimension

參考下面兩個(gè)圖垮庐,如何用一維數(shù)組去表示一個(gè)多維數(shù)組?

圖一
圖二
圖三

圖二中的(0蜕衡,0)映射到圖三中的0,(0,1)對(duì)應(yīng)的是4 设拟, (1慨仿,1)對(duì)應(yīng)的是5,因此得到了下面的公式
i = x + ny
其中i是一維數(shù)組元素的位置纳胧,x镰吆、y是二維數(shù)組的坐標(biāo),n每行擁有的元素的個(gè)數(shù)跑慕。

這樣万皿,位置到坐標(biāo)和坐標(biāo)到位置的轉(zhuǎn)換函數(shù)_positionToCoordinates和_coordinatesToPosition可以就可以表示為下面的形式

位置摧找、坐標(biāo)轉(zhuǎn)化
Initial player positions

游戲開(kāi)始時(shí),隨機(jī)選擇兩個(gè)位置插入tile

buildStartingPosition函數(shù)

randomlyInsertNewTile()方法隨機(jī)選擇一個(gè)可以使用的位置牢硅,用來(lái)插入新生成的方塊對(duì)象蹬耘,但是首先需要知道有哪些位置可以使用。

availableCells函數(shù)

簡(jiǎn)單使用Math.random隨機(jī)獲取一個(gè)可用位置坐標(biāo)

randomAvailableCell函數(shù)

下面就是randomlyInsertNewTile函數(shù)的實(shí)現(xiàn)

randomlyInsertNewTile函數(shù)

Keyboard interaction

現(xiàn)在已經(jīng)可以將方塊添加到游戲面板上了减余,但是游戲還玩不了综苔,接下來(lái)需要把注意力切換到如何給游戲加入交互操作上了。

本文僅僅關(guān)注給游戲添加鍵盤(pán)交互動(dòng)作位岔,觸摸動(dòng)作不在本文中實(shí)現(xiàn)如筛,給游戲添加觸摸動(dòng)作也不是一件難事,我們關(guān)注的觸摸事件ngTouch已經(jīng)提供了抒抬,實(shí)現(xiàn)這個(gè)功能就交給你了杨刨。

使用方向鍵玩游戲(或者a, w, s, d 鍵),我們希望用戶通過(guò)簡(jiǎn)單的方式玩游戲擦剑,不要求用戶集中注意力在游戲面板上的元素(或網(wǎng)頁(yè)上的其他元素)妖胀,這樣用戶就只與聚焦的文檔進(jìn)行游戲互動(dòng)。

為此抓于,需要給document元素綁定事件監(jiān)聽(tīng)器做粤,在Angular中提供了$document服務(wù),我們就將監(jiān)聽(tīng)器綁定到$document上捉撮。為了處理定義好的用戶交互動(dòng)作怕品,我們將鍵盤(pán)事件包裝后綁定到一個(gè)服務(wù)中,頁(yè)面上我們只需要一個(gè)鍵盤(pán)事件處理器巾遭,因此選擇service是正確的肉康。

此外,無(wú)論何時(shí)檢測(cè)到用戶的鍵盤(pán)事件后灼舍,我們要觸發(fā)設(shè)置的自定義動(dòng)作吼和,使用service允許我們將其注入到Angular對(duì)象中,進(jìn)而處理用戶輸入骑素。

在app/scripts/keyboard/keyboard.js文件中創(chuàng)建Keyboard模塊

// app/scripts/keyboard/keyboard.js
angular.module('Keyboard', []);

我們每創(chuàng)建新文件炫乓,都需要考慮將其引入到index.html文件中,現(xiàn)在index.html文件應(yīng)該包含下面的外部文件了

index.html

同時(shí)献丑,每當(dāng)創(chuàng)建新的模塊末捣,也需要告訴Angular,我們的應(yīng)用需要使用這個(gè)新模塊创橄,需要將其作為依賴注入到應(yīng)用中箩做。

.module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard'])

KeyBoard Service的實(shí)現(xiàn)中,我們給$document綁定keydown事件妥畏,用來(lái)捕獲用戶交互邦邦,同時(shí)安吁,我們也會(huì)注冊(cè)一個(gè)處理函數(shù),當(dāng)有用戶交互的時(shí)候這個(gè)處理函數(shù)會(huì)被調(diào)用燃辖。

KeyBoard Service

init函數(shù)中會(huì)將鍵盤(pán)監(jiān)聽(tīng)的任務(wù)交給KeyboardService去處理鬼店,所有我們感興趣的keydown事件會(huì)被過(guò)濾出來(lái)進(jìn)行處理。

任何我們感興趣事件黔龟,我們都會(huì)去阻止其默認(rèn)行為薪韩,然后將其交給keyEventHandlers處理。

keyboard.png

如何知道觸發(fā)的事件是不是我們感興趣的呢捌锭?由于與我們游戲有關(guān)的也就是有限的鍵盤(pán)動(dòng)作,所以我們能夠去檢測(cè)觸發(fā)的事件是否是我們關(guān)心的特定的鍵盤(pán)動(dòng)作觸發(fā)的罗捎。

keyboard

this._handleKeyEvent的職責(zé)是就是去調(diào)用已經(jīng)注冊(cè)的key handler

_handleKeyEvent

我們需要將處理函數(shù)添加到處理函數(shù)隊(duì)列中

on

Using the Keyboard service

現(xiàn)在我們有能力監(jiān)聽(tīng)用戶的鍵盤(pán)輸入了观谦,當(dāng)應(yīng)用啟動(dòng)之后,我們就得做這件事請(qǐng)
桨菜,由于監(jiān)聽(tīng)鍵盤(pán)輸入被封裝為service了豁状,我們只需要在GameController里面做這件事情就行了。

首先需要調(diào)用init()函數(shù)啟動(dòng)對(duì)鍵盤(pán)的監(jiān)聽(tīng)倒得,接著泻红,注冊(cè)處理函數(shù),用它去調(diào)用GameManager進(jìn)而調(diào)用move()函數(shù)霞掺。

回到GameController谊路,我們需要添加newGame()和startGame()函數(shù)newGame函數(shù)簡(jiǎn)單的調(diào)用game service創(chuàng)建新游戲,并且啟動(dòng)鍵盤(pán)事件監(jiān)聽(tīng)操作菩彬。

keyboard-sequence.png

現(xiàn)在將KeyboardService注入到GameController中

GameController

一旦要?jiǎng)?chuàng)建新游戲缠劝,startGame就會(huì)被調(diào)用,startGame函數(shù)會(huì)設(shè)置鍵盤(pán)的事件操作函數(shù)

startGame

Press the start button

最后一個(gè)需要實(shí)現(xiàn)的方法是newGame骗灶,位于GameManager中惨恭,這個(gè)方法做了下面幾件事情:

  1. 創(chuàng)建空的游戲面板
  2. 設(shè)置開(kāi)始位置
  3. 初始化游戲

GridService已經(jīng)實(shí)現(xiàn)了上述的邏輯,現(xiàn)在要做的就是把他們串聯(lián)起來(lái)

1.png

Get your move on (the game loop)

現(xiàn)在將進(jìn)入游戲的核心部分耙旦,當(dāng)用戶按下鍵盤(pán)的方向鍵后脱羡,GridService的move方法就會(huì)被調(diào)用

game-1.png

在開(kāi)始寫(xiě)move方法前,我們需要先定義游戲的約束免都,也就是锉罐,每一次移動(dòng),游戲要如何處理琴昆。

  1. 獲取用戶方向鍵對(duì)應(yīng)的vector向量
  2. 為游戲面板上的每一個(gè)tile找到最遠(yuǎn)可能的位置氓鄙,同時(shí),判斷下一個(gè)位置中的tile是否可以合并业舍。
  3. 對(duì)每一個(gè)tile抖拦,判斷下一個(gè)tile是否與其擁有相同的value
  4. 如果下一個(gè)tile不存在升酣,只需將當(dāng)前的tile移動(dòng)到最遠(yuǎn)可能的位置(這意味著最近的位置就是游戲面板的邊緣)
  5. 如果下一個(gè)tile存在,其value和當(dāng)前tile不同态罪,只需移動(dòng)當(dāng)前的tile到最遠(yuǎn)位置(當(dāng)前tile的下一個(gè)tile就是可移動(dòng)的邊界)
  6. value相同噩茄,找到了一個(gè)可能的合并
    1. 如果是合并后得到的,跳過(guò)
    2. 如果還沒(méi)有合并复颈,那么認(rèn)為這是一個(gè)合并

既然已經(jīng)定義出了功能绩聘,我們就能為構(gòu)造move函數(shù)設(shè)計(jì)出策略。

move函數(shù)

下一步要遍歷Grid找到所有可能的位置耗啦,在GridService上創(chuàng)建一個(gè)新的函數(shù)幫助我們找到所有可能的位置

grid-vectors.gif

為了獲得移動(dòng)方向凿菩,需要有一個(gè)向量vector,用來(lái)描述用戶按鍵的信息帜讲,例如:當(dāng)用戶按下右方向衅谷,這表明用戶想要向右移動(dòng)增加x的位置,可以將這種關(guān)系使用javascript的對(duì)象來(lái)表示似将,就像下面這樣子:

vector

接下來(lái)遍歷可能的位置获黔,使用vector向量判斷將要遍歷的方向

traversalDirections

現(xiàn)在,traversalDirections()函數(shù)定義后在验,在move函數(shù)中玷氏,我們就能夠迭代可能的運(yùn)動(dòng),回到GameManager腋舌,我們將使用這些潛在的位置開(kāi)始遍歷grid

move

現(xiàn)在在position循環(huán)內(nèi)部盏触,我們將會(huì)迭代出可能的位置,尋找該位置存在的tiles块饺,

為了為一個(gè)tile尋找其最遠(yuǎn)可能的位置耻陕,需要走到其下一個(gè)位置檢查當(dāng)前格子是否是面板的邊沿并且該位置是空的。

如果該位置是空的刨沦,并且在格子的范圍內(nèi)诗宣,接下來(lái)就繼續(xù),走到其下一個(gè)位置做相同的檢查想诅。

如果上述的兩個(gè)檢查條件都失敗了召庞,要么是走到格子的邊沿了,要么是找到了下一個(gè)格子了来破,我們將下一個(gè)位置設(shè)置為newPosition篮灼,并且記錄下一個(gè)cell

next-process.gif

將這個(gè)功能放到GridService中

calculateNextPosition

既然可以為tiles計(jì)算下一個(gè)可能的位置,也就可以檢查潛在的合并了徘禁。

合并是這樣定義的:一個(gè)方塊碰到了和它值相同的另一個(gè)方塊诅诱,代碼中將檢查看下一個(gè)位置的方塊是否待移動(dòng)方塊有相同的值,并且之前沒(méi)有被合并過(guò)

現(xiàn)在送朱,如果下一個(gè)位置不滿足條件娘荡,那只需簡(jiǎn)單的將方塊從當(dāng)前的位置移動(dòng)到newPosition這個(gè)位置干旁。

Moving the tile

你可能已經(jīng)猜對(duì)了,將moveTile()放置到GridService中是再合適不過(guò)的了炮沐。

移動(dòng)方塊就是簡(jiǎn)單的更新一下其在一維數(shù)組中的位置争群,還有就是更新TileModel

Moving the tile in the array

GridService的數(shù)組反應(yīng)了在后端方塊定位在了哪里。方塊在數(shù)組中的位置沒(méi)有和其在格子中的位置進(jìn)行綁定

Updating the position on the TileModel

為了前端css放置格子大年,需要更新格子的坐標(biāo)换薄。

moveTile

現(xiàn)在,定義tile.updatePosition() 方法翔试,方法做的事情正如其名字一樣轻要,簡(jiǎn)單的更新方塊自己的x、y坐標(biāo)

updatePosition()
Merging a tile

既然已經(jīng)處理了簡(jiǎn)單的情況垦缅,方塊合并就成為下一個(gè)要處理的事情了伦腐,合并被定義為下面的操作:

一個(gè)方塊在下一個(gè)潛在的位置碰到了和自己相同value的另一個(gè)方塊

當(dāng)方塊被合并后,它就從面板上別移除掉了失都,同時(shí)會(huì)更新游戲的當(dāng)前得分和歷史最高分

合并包含幾個(gè)步驟:

  1. 添加一個(gè)新的方塊到最終的位置上,其value是合并過(guò)的值
  2. 移除原有的方塊
  3. 更新游戲得分
  4. 檢查是否獲勝
in move function

游戲僅僅支持單一的方塊移動(dòng)幸冻,也就是說(shuō)當(dāng)一行出現(xiàn)多個(gè)合并位置時(shí)粹庞,真正的合并只會(huì)發(fā)生一次,所以需要對(duì)已經(jīng)發(fā)生過(guò)的合并進(jìn)行標(biāo)記洽损,代碼中使用merged屬性來(lái)做這件事庞溜。

代碼中有兩個(gè)方法目前還沒(méi)有實(shí)現(xiàn),GridService.newTile()簡(jiǎn)單創(chuàng)建一個(gè)新的TileModel對(duì)象

GridService.newTile

self.updateScore()方法稍后會(huì)講到碑定,接下來(lái)要解決更新游戲得分的問(wèn)題流码。

After tile movement

一次有效的移動(dòng)過(guò)后需要給游戲內(nèi)新加入一個(gè)方塊,通過(guò)檢查移動(dòng)前和移動(dòng)后的位置是否相同判斷移動(dòng)是否有效延刘。

在所有方塊都被移動(dòng)(或嘗試移動(dòng))過(guò)后漫试,需要檢查游戲是否獲勝,如果獲勝碘赖,至此游戲就結(jié)束了驾荣,接著設(shè)置self.win標(biāo)志。

格子發(fā)生碰撞后必然需要移動(dòng)普泡,因此只需簡(jiǎn)單設(shè)置hasMoved=true

最后需要檢查是否發(fā)生移動(dòng)播掷,如果確定移動(dòng)過(guò):

  1. 給游戲添加新的方塊
  2. 檢查是否需要顯示游戲結(jié)束的界面
Reset the tiles

每一次move方法調(diào)用,需要重新設(shè)置方塊的merge狀態(tài)撼班,因?yàn)榇丝桃呀?jīng)不需要知道方塊的merge狀態(tài)了歧匈,將方塊的狀態(tài)擦除掉,認(rèn)為他們可以再運(yùn)行一次砰嘁,在move方法開(kāi)始運(yùn)行時(shí)件炉,執(zhí)行:

GridService.prepareTiles();

prepareTiles()方法簡(jiǎn)單迭代方塊并且重設(shè)其merge狀態(tài)

prepareTiles

Keeping the score

回到updateScore()方法勘究,游戲中需要記錄兩個(gè)得分:

  1. 當(dāng)前得分
  2. 歷史最高得分

currentScore就是一個(gè)變量,每一次游戲只需將其值保存在內(nèi)存中妻率,不需要對(duì)她有任何操作

highScore也是一個(gè)變量乱顾,但是需要在所有游戲中維持這個(gè)變量,有多種方案處理它宫静,localstorage走净,cookies或者兩者的結(jié)合

考慮到cookies是最容易的并且瀏覽器支持友好,我們的代碼里面使用cookies保存highScore變量

在Angular中最容易使用cookies的方式就是使用angular-cookies模塊

為了使用這個(gè)模塊孤里,需要從angularjs.org官網(wǎng)或者包管理器中下載它伏伯,例如bower中可以這樣安裝它:

$ bower install --save angular-cookies

和往常一樣,需要將其引入到index.html文件中捌袜,并且在我們的應(yīng)用中設(shè)置依賴ngCookies

在app/index.html文件中添加:

<script src="bower_components/angular-cookies/angular-cookies.js"></script>

更新game.js文件

angular.module('Game', ['Grid', 'ngCookies'])

使用ngCookies作為依賴后说搅,就可以將$cookieStore服務(wù)注入到GameManager服務(wù)中了,現(xiàn)在就可以在用戶瀏覽器里面獲取和設(shè)置cookies了

為了得到用戶最近的最高得分虏等,我們寫(xiě)一個(gè)函數(shù)從用戶cookie中獲取它

getHighScore

回到updateScore()方法弄唧,代碼將會(huì)更新游戲當(dāng)前得分,如果當(dāng)前得分比用戶歷史最高得分還高霍衫,就需要同時(shí)把它也一起更新了候引。

updateScore
Wrath of track by

現(xiàn)在方塊可以在屏幕上輸出了,一個(gè)bug也隨之而來(lái)敦跌,結(jié)果就是澄干,方塊出現(xiàn)在我們未預(yù)料的位置上

bug的出現(xiàn)原因是:Angular根據(jù)唯一Id知道tiles數(shù)組中都有哪些方塊,我們?cè)趘iew中使用格子在數(shù)組中的位置作為唯一Id柠傍,由于格子在數(shù)組中被我們移來(lái)移去麸俘,$index就不能做為唯一Id來(lái)使用了,我們需要另外一種方案解決這個(gè)問(wèn)題惧笛。

使用方塊自身的uuid區(qū)分方塊而不依賴數(shù)組本身从媚,創(chuàng)建方塊自己的uuid將會(huì)保證Angular將tiles數(shù)組中的方塊做為唯一的對(duì)象對(duì)待,只要uuid不發(fā)生變化患整,Angular將會(huì)將每一個(gè)方塊做為uuid的對(duì)象在視圖中展示出來(lái)静檬。

對(duì)于如何為每一個(gè)格子創(chuàng)建uuid,可以轉(zhuǎn)到StackOverflow并级,其中有人實(shí)現(xiàn)了一個(gè)遵從rfc4122的guid生成器拂檩,我們代碼中將其包裝為一個(gè)factory,對(duì)外提供next()方法

uuid生成器

回到TileModel中嘲碧,為每一個(gè)Tile對(duì)象創(chuàng)建屬性id

Tile

既然每一個(gè)tile對(duì)象擁有了uuid稻励,相應(yīng)的告訴Angular使用uuid去變量tiles數(shù)組,而不是$index.

tile view

上面的方案還存在一個(gè)問(wèn)題,由于tiles數(shù)組在游戲開(kāi)始時(shí)將所有位置都設(shè)置為null望抽,Angular也不管不顧的嘗試將null做為對(duì)象看待加矛,由于null沒(méi)有id屬性,這樣導(dǎo)致瀏覽器拋出一個(gè)無(wú)法操作重復(fù)對(duì)象的錯(cuò)誤

如何解決呢煤篙,可以告訴Angular斟览,當(dāng)當(dāng)前位置為空的話使用$index,否則使用tile對(duì)象的id屬性辑奈,接下來(lái)修改一下tile.html文件苛茂,添加對(duì)于null值的支持:

tile.html

通過(guò)改變底層數(shù)據(jù)結(jié)構(gòu)的方式,這個(gè)問(wèn)題也可以解決鸠窗,例如使用迭代器查找tile的位置妓羊,而不依賴于tiles數(shù)組的索引,或者通過(guò)每次重排數(shù)組稍计,由于簡(jiǎn)單明了的原因躁绸,我們使用了數(shù)組作為其實(shí)現(xiàn),因而帶來(lái)了這個(gè)副作用臣嚣。

We won?!?? Game over

玩原版的游戲時(shí)净刮,如果失敗了,game over的界面會(huì)從游戲面板下方滑動(dòng)上來(lái)硅则,在這個(gè)界面上允許我們選擇重新開(kāi)始游戲并且可以follow作者的twitter淹父。游戲不僅僅給了玩者很酷的視覺(jué)效果,這也是一種中斷游戲的優(yōu)雅方式抢埋。

使用一些基礎(chǔ)的angular技術(shù),我們可以實(shí)現(xiàn)這個(gè)效果督暂,游戲中我們使用gameOver變量來(lái)跟蹤記錄游戲何時(shí)結(jié)束揪垄,簡(jiǎn)單的創(chuàng)建一個(gè)div元素包含我們的游戲結(jié)束界面,將其絕對(duì)定位到游戲面板的位置逻翁。

創(chuàng)建一個(gè)包含游戲結(jié)束或者闖關(guān)成功的div元素饥努,div中顯示的內(nèi)容是根據(jù)游戲的狀態(tài)確定的:

棘手的部分是為其寫(xiě)樣式,實(shí)際上我們僅僅是將其絕對(duì)定位到游戲面板的位置上八回,下面是部分css代碼

.game-overlay

可以使用相同的技術(shù)創(chuàng)建闖關(guān)通過(guò)的界面酷愧,要做的僅僅是創(chuàng)建一個(gè)winning.game-overlay元素

Animation

原版2014讓人印象深刻的一個(gè)特點(diǎn)是:方塊魔法般的從一個(gè)格子滑動(dòng)到下一個(gè)格子,游戲勝利和結(jié)束畫(huà)面出現(xiàn)的是那么自然而不突兀缠诅,使用Angular溶浴,同樣也可以做到和原版近乎一致的效果。

實(shí)際上管引,我們希望我們的游戲可實(shí)現(xiàn)滑動(dòng)士败、展現(xiàn)等效果,動(dòng)畫(huà)是如此容易實(shí)現(xiàn),以至于我們根本不需要或者只需很少的javascript就可以實(shí)現(xiàn)谅将。

Animating the CSS positioning (aka adding sliding tiles)

我們的實(shí)現(xiàn)中漾狼,方塊的定位是通過(guò)為其添加class position-[x]-[y]來(lái)實(shí)現(xiàn)的,當(dāng)一個(gè)新的位置設(shè)置給了方塊后饥臂,其對(duì)應(yīng)的Dom元素就被添加上了新的定位class position-[newX]-[newY]逊躁,同時(shí)舊的position-[oldX]-[oldY]將會(huì)被移除,這種情況下隅熙,為.tile添加css的transition屬性就可以達(dá)到sliding的效果

SCSS代碼片段如下:

.tile
Animating the game over screen

如果想要從動(dòng)畫(huà)中獲得更多稽煤,可以使用ngAnimate模塊

首先是安裝ngAnimate

$ bower install --save angular-animate

其次是給index.html文件引入

script src="bower_components/angular-animate/angular-animate.js"></script>

最后在app/app.js文件中將ngAnimate模塊注入

angular.module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard', 'ngAnimate', 'ngCookies'])
ngAnimate

盡管深入的討論ngAnimate 不在本文的范圍內(nèi)(ng-book這本書(shū)中有關(guān)于其原理的深入探討),我們只是簡(jiǎn)單的看看她是如何工作的猛们,以便于能夠?yàn)槲覀兊挠螒蛱砑觿?dòng)畫(huà)效果念脯。

ngAnimate作為一個(gè)模塊級(jí)別的依賴被我們引入,任何時(shí)候弯淘,在Angular添加了一個(gè)新的對(duì)象后绿店,一組相關(guān)的指令可以用來(lái)為其添加css的class

1.png

當(dāng)一個(gè)元素被添加到ng-repeat的作用域后,新的元素將會(huì)被自動(dòng)的賦予ng-enter對(duì)應(yīng)的css class庐橙,接著當(dāng)其真正被添加到view中時(shí)假勿,ng-enter-active類(lèi)也會(huì)被自動(dòng)添加,這對(duì)我們?cè)趹?yīng)用中實(shí)現(xiàn)動(dòng)畫(huà)很重要态鳖,同樣ng-leave工作的模式和ng-enter是相同的

Animating the game over screen

在游戲獲勝和結(jié)束的畫(huà)面中转培,就可使用ng-enter來(lái)為其實(shí)現(xiàn)動(dòng)畫(huà)效果。記住一點(diǎn)浆竭,.game-overlay類(lèi)的隱藏和顯示使用ng-if指令控制浸须,當(dāng)ng-if的條件變化時(shí)(為真時(shí))ngAnimate將會(huì)為其添加.ng-enter和.ng-enter-active

相關(guān)的SCSS代碼如下:

.game-overlay類(lèi)

Demo demo

完整的demo在這里http://ng2048.github.io/

關(guān)于這個(gè)游戲所有的代碼可以在github上找到,地址在這里

To build the game locally, clone the source and run:

build 2048
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末邦泄,一起剝皮案震驚了整個(gè)濱河市删窒,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌顺囊,老刑警劉巖肌索,帶你破解...
    沈念sama閱讀 218,755評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異特碳,居然都是意外死亡诚亚,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)午乓,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)站宗,“玉大人,你說(shuō)我怎么就攤上這事益愈》萜梗” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,138評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)或辖。 經(jīng)常有香客問(wèn)我瘾英,道長(zhǎng),這世上最難降的妖魔是什么颂暇? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,791評(píng)論 1 295
  • 正文 為了忘掉前任缺谴,我火速辦了婚禮,結(jié)果婚禮上耳鸯,老公的妹妹穿的比我還像新娘湿蛔。我一直安慰自己,他們只是感情好县爬,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布阳啥。 她就那樣靜靜地躺著,像睡著了一般财喳。 火紅的嫁衣襯著肌膚如雪察迟。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,631評(píng)論 1 305
  • 那天耳高,我揣著相機(jī)與錄音扎瓶,去河邊找鬼。 笑死泌枪,一個(gè)胖子當(dāng)著我的面吹牛概荷,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播碌燕,決...
    沈念sama閱讀 40,362評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼误证,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了修壕?” 一聲冷哼從身側(cè)響起愈捅,我...
    開(kāi)封第一講書(shū)人閱讀 39,264評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎叠殷,沒(méi)想到半個(gè)月后改鲫,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體诈皿,經(jīng)...
    沈念sama閱讀 45,724評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡林束,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評(píng)論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了稽亏。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片壶冒。...
    茶點(diǎn)故事閱讀 40,040評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖截歉,靈堂內(nèi)的尸體忽然破棺而出胖腾,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評(píng)論 5 346
  • 正文 年R本政府宣布咸作,位于F島的核電站锨阿,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏记罚。R本人自食惡果不足惜墅诡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評(píng)論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望桐智。 院中可真熱鬧末早,春花似錦、人聲如沸说庭。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,944評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)刊驴。三九已至姿搜,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間缺脉,已是汗流浹背痪欲。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,060評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留攻礼,地道東北人业踢。 一個(gè)月前我還...
    沈念sama閱讀 48,247評(píng)論 3 371
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像礁扮,于是被迫代替她去往敵國(guó)和親知举。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容