《球球大作戰》源碼解析:服務器與客戶端架構

 

系列文章
《球球大作戰》源碼解析——(1)運行起來
《球球大作戰》源碼解析:服務器與客戶端架構
《球球大作戰》源碼解析:移動算法
《球球大作戰》源碼解析(6):碰撞處理
《球球大作戰》源碼解析(7):遊戲循環
《球球大作戰》源碼解析(8):消息廣播

鑑於agar.io類型遊戲的火爆場面,一些公司紛紛效仿,一時間出現各種《XX大作戰》類型的遊戲。出於學習的目的,亦是做些技術和方案儲備,接下來會有大概10篇文章,分析下面這款使用nodejs編寫的開源“球球大作戰”。由於該遊戲採用服務端運算、客戶端顯示的方式,服務端的邏輯處理是該源碼的重點,故而系列文章主要針對服務端。通過這套源碼,可以學習到“一種基於nodejs的簡單服務器實現方法”“一種簡單的服務端物理邏輯的實現方式”“一種基於redis pub/sub的跨服設計思想”“nodejs語法、框架及其使用方式”等內容。

系列文章將會分析huytd/agar.io-clone的源碼,這是一套簡約而不簡單的Agar.IO實現。該項目使用NodeJS開發,使用socket.IO作爲網絡通信,使用HTML5實現客戶端。


一、運行起來

下圖爲遊戲運行畫面,遊戲規則如下。

1、玩家可以移動鼠標控制小球

2、當小球吞食場景中的食物或其他玩家控制的小球時,玩家控制的小球會變大

3、小球越大,移動速度越慢

4、小球的質量代表它的大小,質量爲它吞食的食物或其他玩家的質量之和

5、遊戲目標是儘可能的吞食其他玩家,使小球變大

6、玩家剛出生時會有無敵屬性,直到它吞食食物

7、每當有玩家進入遊戲,場景中會生成3個食物

8、每當吞食食物時,場景中亦會生成一個新的食物
 


第一步便是要讓遊戲運行起來,只有運行起來了,才談得上後續的源碼分析。爲了“從零開始”,筆者購買Ubuntu系統的騰訊雲,新的系統幾乎沒有安裝額外軟件,一步一步安裝所需的軟件,然後將遊戲運行起來吧。筆者選用了最低一檔配置的服務器,花費近50大洋(此處是不是應該發個求贊助的鏈接?)配置如下圖所示。
 


1、安裝nodeJs

遊戲使用nodejs開發,那就必須要安裝nodejs,可以有兩種方法安裝。

方法1:輸入sudo apt install nodejs,這是最簡單的安裝方法了。不過使用該方式安裝的程序名叫爲nodejs,而不是普遍使用的node。可以使用sudo ln-s/usr/bin/nodejs/usr/bin/node建立名爲node的連接,以解決這個問題。

方法2:下載源碼、編譯、安裝。具體可以參考這篇文章在Ubuntu下安裝Node.JS的不同方式-技術◆學習|Linux.中國-開源社區(文章裏使用的node-v6.9.5要改爲最新版的)

完成後,可以使用node-v查看nodejs版本號,以驗證是否成功安裝。

2、上傳代碼文件

從github上下載源碼,然後上傳到linux服務器上。如下圖所示,筆者將源碼上傳到/home/ubuntu/agar.io-clone-master目錄下
 


3、安裝npm

npm(node package manager)是nodejs的包管理和分發工具,一般安裝nodejs後都需要安裝該軟件,可以使用以下命令安裝:sudo apt install npm

4、安裝gulp

項目使用到了gulp,需要安裝它。gulp是一個前端構建工具,開發者可以使用它在項目開發過程中自動執行常見任務,比如複製文件,比如替換文件中某些字符。進入源碼目錄,執行sudo npm install-g gulp即可安裝。

5、安裝項目所需的包文件

進入源碼目錄,執行npm install即可安裝項目所需包文件。npm install會檢查當前目錄下的package.json文件,文件包含了項目所需的模塊,npm根據該文件的描述下載這些文件並把模塊放到./node_modules目錄下。關於package.json的格式可以參考這篇文章package.json for NPM文件詳解

6、運行服務器

在源碼目錄下執行gulp run,可以看到服務器啓動的提示信息。

7、運行客戶端

運行瀏覽器,輸入地址即可,筆者的騰訊雲ip爲139.199.179.39,由於默認配置了3000端口,所以要輸入http://139.199.179.39:3000/,即可看到如下的遊戲界面。
 


在筆者的試驗中,該頁面報錯,點擊按鈕沒有反應。原因是src/client中的index.html最後面有這麼一句,<script src="//code.jquery.com/jquery-2.2.0.min.js"></script>,該語句用於加載jquery的,而http://code.jquery.com/jquery-2.2.0.min.js無法訪問(或國內網絡訪問速度慢),導致報錯。只要換個文件地址即可,例如改成下面這樣:

<script src="http://libs.baidu.com/jquery/1.9.0/jquery.js"></script >

運行遊戲,服務端也會打印出相應的信息,如下圖所示。
 


把遊戲運行起來後,下一步就要分析下游戲的流程了。

二、程序流程

在解析源碼之前,需要先了解該項目的程序流程,瞭解客戶端和服務端是如何運行和通信的。本文是wiki文檔Game Architecture的翻譯,以幫助讀者從大方向上了解《球球大作戰》。

程序架構

遊戲程序使用NodeJs編寫,服務端通過http://Socket.IO創建WebSocket服務並默認監聽3000號端口。程序還使用ExpressJS建立一個簡單的HTTP服務器,它負責html頁面的顯示。index.html是遊戲主頁面,它通過Canvas渲染遊戲,通過Javascript腳本和服務端通信。
 


目錄結構該項目由3部分組成:

1、配置文件,如package.json,config.json等等

2、客戶端程序

3、服務端程序

配置文件package.json列出了項目所需的庫文件,讀者只需在項目目錄下執行“npm install”即可自動安裝這些文件。package.json的格式可以參考下面的文章:

npm package.json屬性詳解

遊戲客戶端

client文件夾裏包含了客戶端所需的代碼,它是一個簡單的HTML文件,該文件會通過canvas繪製遊戲場景、聊天框等元素。
 


js/app.js是客戶端的邏輯代碼,它實現了畫面渲染、網絡延遲檢測、觀戰模式、聊天等功能,處理了鼠標輸入、服務端通信等事項。遊戲採用服務端運算模式,客戶端只是負責將服務端發來的數據顯示到屏幕上,以及接收鼠標事件。

客戶端程序使用了requestAnimationFrame程序渲染循環,而不是使用setInterval,這讓遊戲有着更好的渲染性能。你可以試着修改代碼,調用setInterval方法,看看低效率的渲染是個啥樣子。
(function animloop(){
  requestAnimFrame(animloop);
  gameLoop();
})();

to

setInterval(gameLoop, 16);


遊戲服務端

server/server.js包含了服務端的配置和邏輯處理,配置了諸如食物質量、移動速度、無敵狀態的最大質量,處理了食物顏色計算、碰撞檢測、玩家移動處理等等事項。
 


所有的遊戲邏輯都在服務端處理,服務端和客戶端的通信有着下面幾個要點。

1、服務端使用list保存玩家列表,而不是使用array,使用list保存食物列表,而不是使用array。服務端保存着socket列表,用於記錄所有客戶端連接。

2、之前的版本設置了一個定時器,每隔幾秒鐘就產生一些食物,但這種方法的效率不高,會延遲服務端處理速度。所有在此版本中使用了一種新的方式來產生食物,當一個玩家進入遊戲時,程序會隨機產生3個食物(可以修改配置文件的newFoodPerPlayervariable改變該數值),當玩家喫掉一個食物時,程序會產生另外一個食物(可以修改配置文件的respawnFoodPerPlayer改變該數值)。如果場景中的食物數量大於50(配置文件的maxFoodCount),服務端會停止產生新食物。

客戶端服務端通信

客戶端與服務端通信可以分爲兩個部分,分別是登錄認證和遊戲內通信。

登陸認證
 


當一個玩家打開遊戲網頁,他先會看到一個輸入用戶名的對話框,點擊“Play”按鈕後,客戶端發起socket連接,服務端accept連接後發出welcome協議,並把該客戶端的UserID附帶在協議中。

當客戶端收到welcome協議,它會返回附帶用戶名的gotit協議。

當服務端收到gotit協議,它會其它的已連接玩家廣播playerJoin協議,告訴他們有新的玩家加入。其它玩家收到該協議後,會在屏幕上繪製這個新加入的角色。

此時,對於新加入的玩家來說,遊戲剛剛開始。

遊戲內通信

遊戲內通信分爲3個部分,分別是遊戲邏輯、聊天和Ping(測試網絡延遲)。

遊戲邏輯

玩家在遊戲中會有移動、吞食食物、吞食其他玩家三種行爲,這些邏輯全部由服務端運算,客戶端只是根據運算結果將圖像顯示在對應的位置上。

移動
 


當玩家移動鼠標,小球會朝着鼠標的位置移動。客戶端會發送附帶了目的地座標的playerSendTarget協議。服務端收到協議後會更新小球的運動狀態,然後向該客戶端回覆serverTellPlayerMove協議,然後發送serverUpdateAllPlayers給其他客戶端,讓全部客戶端更新所有玩家的座標。

小球移動期間,服務端還會檢測小球是否吞食了食物,或者吞食了其他玩家。

吞食食物
 


服務端維持了users列表和food列表來保存所有的小球和食物的信息,如果小球碰到食物,服務端會執行相應的邏輯,增加小球質量、刪除列表裏的食物、產生新的食物。然後服務端廣播serverUpdateAllPlayers和serverUpdateAllFoods協議,讓客戶的更新玩家和食物。

吞食其他玩家
 


如果小球吞食了其他玩家的小球,服務端會比較兩者的質量和距離,質量小的被吞食。服務端會發送RIP協議告訴質量下的玩家他死掉了,然後斷開與該玩家的連接,同時在users列表裏刪除他。還會廣播serverUpdateAllPlayers協議通知客戶端。

聊天

聊天的流程如下圖所示

當玩家在聊天框中輸入信息並按下回車鍵時,客戶端向服務端發送playerChat協議,服務端收到協議後廣播serverSendPlayerChat協議。
 


當客戶端收到serverSendPlayerChat協議時,它會解析該協議,將聊天內容顯示到屏幕上。

Ping(延遲檢測)

網絡遊戲都會實現ping機制來檢測客戶端和服務端之間的延遲,而它的實現也很簡單。
 


檢測開始時,客戶端會保存當前的開始時間,然後發送ping協議給服務端,服務端收到後,會返回pong協議。客戶端收到pong協議會計算時間差,如果時間差很大,說明網絡延遲很嚴重。

願這份文檔能夠協助讀者理解agar.io-clone這個項目,你還可以繼續完善這款遊戲,將它做得更好。也希望各位能夠在項目wiki中分享心得。

三、gulp工具

運行遊戲使用的命令是gulp run,agar.io-clones使用了nodejs開發,gulp是基於nodejs的一個工具,它能夠批量的做一些文件操作。gulp run意思是執行目錄下gulpfile.js下的run任務,那麼源碼中使用了gulp的哪些功能呢?這篇文章將會做個簡單介紹。

gulp能自動化地完成javascript/coffee/sass/less/html/image/css等文件的的測試、檢查、合併、壓縮、格式化、瀏覽器自動刷新、部署文件生成,並檢測文件變化。在實現上,gulp鑑了Unix操作系統的管道(pipe)思想,前一級的輸出,直接變成後一級的輸入。

關於gulp入門,可以參考下面的文章:

一點|gulp詳細入門教程

入門指南-gulp.js中文文檔
一個最簡單的示例

要使用gulp根據,當然得先安裝它,有兩種方式安裝,對應於不同的命令參數。

全局安裝gulp:npm install--global gulp

作爲項目的開發依賴(devDependencies)安裝:npm install--save-dev gulp

現在新建一個目錄並創建一個名爲gulpfile.js的文件,在裏面編寫如下代碼

var gulp = require('gulp');gulp.task('default', function() {  // 將你的默認的任務代碼放在這});
在目錄下執行gulp,此時程序會搜尋目錄下gulpfile.js文件中的默認(default)任務,也就是上面代碼中“//將你的默認的任務代碼放在這”處的代碼去執行。“gulp run”即表示執行名爲run的任務,相關代碼可以在項目文件夾下的gulpfile.js中看到。相關代碼如下

gulp.task('run', ['build'], function () {
    nodemon({
        delay: 10,
        script: './server/server.js',
        cwd: "./bin/",
        args: ["config.json"],
        ext: 'html js css'
    })
    .on('restart', function () {
        util.log('server restarted!');
    });
});

代碼解析

要看懂上面的代碼,必須要瞭解gulp的一些API,知道“nodemon”等單詞到底是什麼意思,實現什麼功能,gulp的api可以參考下面的文章:

一點|gulp教程之gulp中文API

依賴

上面代碼中的“gulp.task('run',['build'],function(){}”意爲run依賴於build,當執行gulp run時,程序會先執行build任務,再執行run任務。

nodemon

先看看nodemon,詳細的解釋可以參考gulp-nodemon

nodemon是一個工具,用於項目代碼發生變化時可以自動重啓,nodemon本意時檢測項目變化的,對項目做監控的。重啓只是它的一個功能。在上面的代碼中,相當於執行./server/server.js這個文件。而這個文件其實是build任務中生成的。

build任務

接下來看看build任務是什麼樣子的,會發現build任務依賴於build-client、build-server、test、todo這4個任務,也就是說,需要按順序先執行這4個任務,纔會執行build。此時我們會發現,代碼的執行流程是build-client、build-server、test、todo、run

gulp.task('build',['build-client','build-server','test','todo']);

build-client任務

build-client處理了客戶端代碼的創建,它用到了uglify、webpack和babel。

其中uglify表示壓縮javascript文件,減小文件大小(參見一點|gulp教程之gulp-uglify)

webpack表示模塊打包,它能幫我們把本來需要在服務端運行的JS代碼,通過模塊的引用和依賴打包成前端可用的靜態文件(參考《nodejs+gulp+webpack基礎實戰篇》課程筆記(三)--webpack篇-亡命小卒-博客園)

babel是一個JavaScript轉換編譯器,它可以將ES6(下一代JavaScript規範,添加了一些新的特性和語法)轉換成ES5(可以在瀏覽器中運行的代碼)。這就意味你可以在一些暫時還不支持某些ES6特性的瀏覽器引擎中,使用ES6的這些特性。比如說,class和箭頭方法。

pipe表示管道,下面的代碼是指將源文件(.src)“src/client/js/app.js”通過uglify方法壓縮,然後將壓縮後的結果通過webpack打包,然後通過babel做兼容性,最後通過將文件存入dest指定的目錄下“bin/client/js/”

gulp.task('build-client', ['lint', 'move-client'], function () {  return gulp.src(['src/client/js/app.js'])    .pipe(uglify())    .pipe(webpack(require('./webpack.config.js')))    .pipe(babel({      presets: [        ['es2015', { 'modules': false }]      ]    }))    .pipe(gulp.dest('bin/client/js/'));});
webpack()方法的參數是“require('./webpack.config.js') ”“ ./webpack.config.js”,該文件的內容如下,它是打包的配置文件。

module.exports = {
    entry: "./src/client/js/app.js",
    output: {
        path: require("path").resolve("./src/bin/client/js"),
        library: "app",
        filename: "app.js"
    },
    module: {
        loaders: [
            {
                test: /\.jsx?$/,
                exclude: /(node_modules|bower_components)/,
                loader: 'babel'
            }
        ]
    }
};

“build-client”依賴於“lint”和“move-client”,先要完成這兩個任務,程序纔會執行“build-client”任務。

lint任務

“lint”任務如下所示,它使用了jshint方法。jshint是用來檢測javascript的語法錯誤的。如果有錯誤,就報告fail。

gulp.task('lint', function () {
  return gulp.src(['**/*.js', '!node_modules/**/*.js', '!bin/**/*.js'])
    .pipe(jshint({
          esnext: true
      }))
    .pipe(jshint.reporter('default', { verbose: true}))
    .pipe(jshint.reporter('fail'));
});

move-client任務

“build-client”還依賴於“move-client”代碼如下,它只是移動一些文件

gulp.task('move-client', function () {
  return gulp.src(['src/client/**/*.*', '!client/js/*.js'])
    .pipe(gulp.dest('./bin/client/'));
});

build-server任務

build-server任務比較簡單,它也是複製下文件

gulp.task('build-server', ['lint'], function () {
  return gulp.src(['src/server/**/*.*', 'src/server/**/*.js'])
    .pipe(babel())
    .pipe(gulp.dest('bin/server/'));
});

test任務

build任務依賴於build-client、build-server、test和todo任務,在建了客戶端和服務端文件後,自然需要對它測試一下,test任務調用了mocha方法,它是一個測試方法。

gulp.task('test', ['lint'], function () {
    gulp.src(['test/**/*.js'])
        .pipe(mocha());
});

todo任務

todo任務調用了todo方法,該方法會收集符合“src/**/*js”匹配符的文件信息,生成一個名爲TODO.md的文件。

gulp.task('todo', ['lint'], function() {  gulp.src('src/**/*.js')      .pipe(todo())      .pipe(gulp.dest('./'));});
生成的TODO.md如下圖所示。
 


由於實際運行的文件在是bin/目錄下,如果修改了源文件,需要重新執行gulp run才能生效。

四、Websocket

運行服務端後,玩家只要打開瀏覽器,輸入地址和端口,就可以看到遊戲畫面。這就意味着,遊戲服務端開了個http服務器。Node.js標準庫提供了http模塊,其中封裝了一個高效的HTTP服務器和一個簡易的HTTP客戶端。http.Server是一個基於事件的HTTP服務器,它的核心由Node.js下層C++部分實現,而接口由JavaScript封裝,兼顧了高性能與簡易性。http.request則是一個HTTP客戶端工具,用於向HTTP服務器發起請求。關於http服務端的入門,可以參考下面教程。

Node.js學習(11)----HTTP服務器與客戶端-推酷

安裝http包

使用http模塊,必須先安裝它,執行npm install http命令安裝即可。

顯示Html文本

新建一個js文件,然後輸入如下的代碼。通過require('http').Server創建一個http服務器,“http.listen”表示開啓監聽,如下代碼是監聽3001端口,監聽成功後會在屏幕中打印出“[DEBUG]Listening”。http.on('request'function(){……})表示當服務端收到客戶端的請求時做出怎樣的處理,這裏向客戶端返回html信息。

var http = require('http').Server()

http.on('request',function(req,res){
        console.log('[DEBUG] on request ' );
        res.writeHead(200,{'Content-Type':'text/html'});
        res.write('<h1>Node.js</h1>');
        res.end('<p>HelloWorld</p>');
});

http.listen(3001, function() {
    console.log('[DEBUG] Listening ' );
});

運行腳本,然後用瀏覽器打開3001端口,即可看到html文本。
 


用express顯示Html文件

Express是一個基於Node.js平臺的web應用開發框架,可以使用它指定要顯示的網頁文件。在使用之前需要使用npm install express命令安裝express。

新建js文件填入下面的代碼,除了創建http服務器外,還使用express指定了網頁目錄“__dirname+'/'”,即代碼文件的同一目錄下。

var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));

console.log("hehe");


http.listen(3001, function() {
    console.log('[DEBUG] Listening ' );
});

在同一目錄下新建index.html,輸入下面的文本。

<html>
<head>
    <title>Ssocket</title>
</head>

<body>
        <P>測試</P>

</body>
</html>

運行服務端,用瀏覽器打開頁面,將會看到如下網頁。
 


WebSocket介紹

談到Web實時推送,就不得不說WebSocket。在WebSocket出現之前,很多網站爲了實現實時推送技術,通常採用的方案是輪詢(Polling)和Comet技術,Comet又可細分爲兩種實現方式,一種是長輪詢機制,一種稱爲流技術,這兩種方式實際上是對輪詢技術的改進,這些方案帶來很明顯的缺點,需要由瀏覽器對服務器發出HTTP request,大量消耗服務器帶寬和資源。面對這種狀況,HTML5定義了WebSocket協議,能更好的節省服務器資源和帶寬並實現真正意義上的實時推送。

WebSocket協議本質上是一個基於TCP的協議,它由通信協議和編程API組成,WebSocket能夠在瀏覽器和服務器之間建立雙向連接,以基於事件的方式,賦予瀏覽器實時通信能力。既然是雙向通信,就意味着服務器端和客戶端可以同時發送並響應請求,而不再像HTTP的請求和響應。

具體可以參考下面的文章

使用Node.js+Socket.IO搭建WebSocket實時應用-OPEN開發經驗庫

WebSocket簡單實例

下面通過一個簡單的例子介紹WebSocket的使用方法,在安裝WebSocket後編寫如下的代碼和html文件。當客戶端發起連接(connection)後,它會打印出“A user connected!”

var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));

console.log("hehe");

io.on('connection', function (socket) {
    console.log('A user connected!', socket.handshake.query.type);
})

http.listen(3001, function() {
    console.log('[DEBUG] Listening ' );
});

html代碼如下所示,頁面中會有一個按鈕,當點擊按鈕時,會通過io.connect連接服務端

<html>
<head>
    <title>Ssocket</title>
        http://139.199.179.39:3001/socket.io/socket.io.js</a>">   
</head>

<body>
        <P>測試</P>
        <input type="button" id="btn" value="click" />
        <script type="text/javascript">
                var oBtn = document.getElementById('btn');
                        oBtn.onclick = function(){
                        var socket = io.connect('http://139.199.179.39:3001/');
                        alert("send");
                };
        </script>

</body>
</html>

運行程序,點擊客戶端上的按鈕,服務端會顯示“A user connected!”

收發信息

客戶端和服務端可要相互通信,在下面的例子中,網頁上有connect和send兩個按鈕,點擊send按鈕後,會發送login協議,服務端收到login協議後,會打印客戶端傳來的信息。

var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));

console.log("hehe");

io.on('connection', function (socket) {
    console.log('A user connected!', socket.handshake.query.type);
        
        socket.on('login', function (data) {
      console.log(data);
    });
})

http.listen(3001, function() {
    console.log('[DEBUG] Listening ' );
});
<html>
<head>
    <title>Socket</title>
        http://139.199.179.39:3001/socket.io/socket.io.js</a>">   
</head>

<body>
        <P>測試</P>
        <input type="button" id="btn1" value="connect" />
        <input type="button" id="btn2" value="send" />
        <script type="text/javascript">
                var oBtn1 = document.getElementById('btn1');
                        oBtn1.onclick = function(){
                        socket = io.connect('http://139.199.179.39:3001/');
                        alert("connect");
                };
               
                var oBtn2 = document.getElementById('btn2');
                        oBtn2.onclick = function(){
                        socket.emit('login', { name: 'LPY' });
                        alert("send");
                };
        </script>

</body>
</html>

運行程序,點擊按鈕,服務端將會顯示客戶端login協議傳入的用戶名“LPY”,如下圖所示。

客戶端
 


服務端
 


客戶端回顯

在下面的代碼中,服務端收到客戶端的login協議後會恢復客戶端loginBack協議,客戶端收到loginBack協議後會彈出對話框。

var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));

console.log("hehe");

io.on('connection', function (socket) {
    console.log('A user connected!', socket.handshake.query.type);
        
        socket.on('login', function (data) {
                console.log(data);
                socket.emit('loginBack', { result: 'success' });
    });
})

http.listen(3001, function() {
    console.log('[DEBUG] Listening ' );
});

<html>
<head>
    <title>Socket</title>
        http://139.199.179.39:3001/socket.io/socket.io.js</a>">   
</head>

<body>
        <P>測試</P>
        <input type="button" id="btn1" value="connect" />
        <input type="button" id="btn2" value="send" />
        <script type="text/javascript">
                var oBtn1 = document.getElementById('btn1');
                        oBtn1.onclick = function(){
                                socket = io.connect('http://139.199.179.39:3001/');
                                alert("connect");
                        
                                socket.on('loginBack', function (data) {   
                                        alert(data.result);
                                });
                };
               
                var oBtn2 = document.getElementById('btn2');
                        oBtn2.onclick = function(){
                                socket.emit('login', { name: 'LPY' });
                                alert("send");
                        };
        </script>

</body></html>

運行程序,結果如下圖所示。
 

 

轉自 https://www.gameres.com/837208.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章