iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 28
0
Software Development

從讀遊戲原始碼學做連線遊戲系列 第 28

Day. 28 - 實作練習 - Client 重構

在上一篇我們注意到客戶端的邏輯如果都放在同一個檔案(SimpleRPG_Map.js)裡面是相當混亂的,因此我們需要稍微區分不同的職責來處理。在前面提到的 Unlight 原始碼中我們已經可以大致上知道一個還算不錯的設計方向。

類型 用途
Server 用於處理與伺服器的連線
Controller 用於處理伺服器回傳的指令
其他 遊戲中的物件(Ex. 地圖、玩家)

我們先簡單這樣區分,現階段我們其實只有遊戲中的物件這類跟遊戲本身直接相關的物件(當然還可以再繼續細分)也因此將跟網路連線相關的物件混在裡面,就會變得難以擴充與測試。

大家可能都有讀過一些關於物件導向設計原則的文章或者 SOLID 之類的東西,像這個情境就很適合我們思考「這個物件的責任是否過大?」之類的問題,透過這樣的方式也能夠逐步加深我們對程式設計的理解。

連線管理

首先我們的 WebSocket 物件明顯需要抽離出來,因此第一階段就是將 WebSocket 的連線部分獨立成 SimpleRPG_Servers_Map.js 這個檔案,不過當初在設計的時候我們預期不同功能的伺服器是獨立運作的。因此我們需要先設計一個基本的伺服器物件出來用於繼承。

// js/plugins/SimpleRPG_Server.js

//=============================================================================
// SimpleRPG_Server.js
//=============================================================================

/*:
 * @plugindesc Abstract Server
 * @author Aotokitsuruya
 */

var SimpleRPG = SimpleRPG || {};

(function() {
  function Server() {
    this.initialize.apply(this, arguments);
  };

  Server.prototype = Object.create(Object.prototype);
  Server.prototype.constructor = Server;

  Server.prototype.initialize = function(server) {
    this.socket = new WebSocket(server);
    this.socket.onopen = this._connected.bind(this);
    this.socket.onmessage = this._received.bind(this);

    this.initMembers();
  };

  Server.prototype.initMembers = function() {
    this.controller = null;
    this._commands = [];
    this.connected = false;
  };

  Server.prototype._connected = function(ev) {
    this.connected = true;
    this.processCommand();
  };

  Server.prototype._received = function(ev) {
    if (this.controller && ev.data) {
      var data = JSON.parse(ev.data);

      this.controller.execute(data['command'], data['parameters']);
    }
  };

  Server.prototype.send = function(command, parameters) {
    this._commands.push({ command: command, parameters: parameters });
  };

  Server.prototype.processCommand = function() {
    if (this.connected && this._commands.length > 0) {
      var command = this._commands.shift();
      this.socket.send(JSON.stringify(command));
    }

    requestAnimationFrame(this.processCommand.bind(this));
  };

  SimpleRPG.Server = Server;
})();

我們在這邊將 WebSocket 重新封裝成一個抽象的 Server 物件做了這幾件事情。

  1. 將收到的指令轉給 Controller 物件執行
  2. 將玩家操作的指令放進一個 Queue 依序發送給伺服器

不管是哪一種功能的伺服器都會需要共用這個機制,因此我們在處理個別伺服器上的時候就能夠重複利用這些邏輯。

接下來要將關於地圖伺服器的行為實作出來。

// js/plugins/SimpleRPG_Servers_Map.js

//=============================================================================
// SimpleRPG_Servers_Map.js
//=============================================================================

/*:
 * @plugindesc Map Server
 * @author Aotokitsuruya
 */

var SimpleRPG = SimpleRPG || {};
SimpleRPG.Servers = SimpleRPG.Servers || {};

(function() {
  function Server() {
    this.initialize.apply(this, arguments);
  };

  Server.prototype = Object.create(SimpleRPG.Server.prototype);
  Server.prototype.constructor = Server;

  Server.prototype.initialize = function() {
    SimpleRPG.Server.prototype.initialize.call(this, 'ws://localhost:9292')
  };

  Server.prototype.setCurrentMap = function(map) {
    this.controller = new SimpleRPG.Controllers.Map(map);
  };

  var instance = null;
  Object.defineProperties(SimpleRPG.Servers, {
    Map: {
      get: function() {
        if (!instance) {
          instance = new Server();
        }

        return instance;
      }
    }
  });
})();

有了前面 Server 物件作為基礎,我們在實作 Map Server 相關的行為就單純很多,另外這邊故意利用 getter 的特性實作了 Singleton 來確保玩家不會多次的連接到 Map Server 上面。

其實在這樣設計的後期我開始思考,玩家的動作應該是實作在 Server 上,還是要另外設計一個 Command 物件來處理呢?

控制器

有了 Server 物件後,我們還缺少控制器來幫助我們處理來自伺服器的指令。跟伺服器的情況相同,我們需要先製作一個控制器的基礎物件來封裝共通的行為。

// js/plugins/SimpleRPG_Controller.js

//=============================================================================
// SimpleRPG_Controller.js
//=============================================================================

/*:
 * @plugindesc Controller
 * @author Aotokitsuruya
 */

var SimpleRPG = SimpleRPG || {};

(function() {
  function Controller() {
    this.initialize.apply(this, arguments);
  };

  Controller.prototype = Object.create(Object.prototype);
  Controller.prototype.constructor = Controller;

  Controller.prototype.initialize = function() {};

  // Execute
  Controller.prototype.execute = function(command, parameters) {
    if (!this[command]) {
      return;
    }

    this[command].apply(this, parameters);
  };

  SimpleRPG.Controller = Controller;
})();

因為我們的指令系統是使用 JSON 所以幾乎不太需要處理,只需要檢查當下的 Controller 是否存在我們需要的方法然後執行即可。

不過實際上還需要做一個轉換,在 Ruby 中命名習慣是 move_to 但是 JavaScript 卻是 moveTo 勢必會再傳輸時選擇其中一種,此時就會有一方需要轉換成適合的規格。

接下來實作 Map Controller 出來就能夠對地圖做控制。

// js/plugins/SimpleRPG_Controllers_Map.js

//=============================================================================
// SimpleRPG_Controllers_Map.js
//=============================================================================

/*:
 * @plugindesc Map Controller
 * @author Aotokitsuruya
 */

var SimpleRPG = SimpleRPG || {};
SimpleRPG.Controllers = SimpleRPG.Controllers || {};

(function() {
  function MapController() {
    this.initialize.apply(this, arguments);
  };

  MapController.prototype = Object.create(SimpleRPG.Controller.prototype);
  MapController.prototype.constructor = MapController;

  MapController.prototype.initialize = function(map) {
    this.map = map;
  };

  MapController.prototype.join = function(id, avatarIdx) {
    var player = this.map.addPlayer(id);
    player.setImage('Actor2', 1);
    player.locate(8, 6);
  };

  MapController.prototype.move = function(id, x, y) {
    var player = this.map.findPlayer(id);
    if (player) {
      player.moveTo(x, y);
    }
  };

  SimpleRPG.Controllers.Map = MapController;
})();

透過前面的封裝,到了 Controller 這一步也會比上一篇的 data['parameters'] 寫起來乾淨很多,我們現在大多是使用框架去開發軟體可能會誤以為這樣的機制是理所當然的,不過仔細的自己實作之後大多是非常多的巧思集合而成才能有這樣乾淨的介面可以使用。

修改地圖

當我們完成重構後,就要再把原本地圖那些不必要的行為去除掉,換上我們重構後的版本。

// js/plugins/SimpleRPG_Map.js

//=============================================================================
// SimpleRPG_Map.js
//=============================================================================

/*:
 * @plugindesc Map Handler
 * @author Aotokitsuruya
 */

var SimpleRPG = SimpleRPG || {};

(function() {

  // WebSocket 部分移除
  
  // 略
  Scene_Map.prototype.onMapLoaded = function() {
    _Scene_Map_onMapLoaded.call(this);

    SimpleRPG.Servers.Map.setCurrentMap(this);
    SimpleRPG.Servers.Map.send('join', []);

    // Hide Default Player
    $gamePlayer.setOpacity(0);
  }
  
  // 略
 
  // Handling TouchInput
  Scene_Map.prototype.processMapTouch = function() {
    if (TouchInput.isTriggered() || this._touchCount > 0) {
      if (TouchInput.isPressed()) {
        if (this._touchCount === 0 || this._touchCount >= 15) {
          var x = $gameMap.canvasToMapX(TouchInput.x);
          var y = $gameMap.canvasToMapY(TouchInput.y);
          SimpleRPG.Servers.Map.send('move', [x, y]);
        }
        this._touchCount++;
      } else {
        this._touchCount = 0;
      }
    }
  };

我們先將原本 WebSocket 的部分移除,然後將原本直接對 socket 操作的行為改為 SimpleRPG.Servers.Map 這個 Singleton 物件,透過這個物件來發送指令。因為在 Singleton 物件產生時會自動連上伺服器,因此我們只需要關注如何操作這件事情身上即可。

到了這個階段,我們就可以安心的處理關於無法顯示先前玩家的問題,同時也開始會在玩家身上紀錄越來越多的東西,因此還要再加上考慮玩家最後一次連線的位置問題。

我的個人部落格是弦而時習之平常會把自己發現的一些新技巧紀錄在上面,也歡迎大家來逛逛。


上一篇
Day. 27 - 實作練習 - 顯示其他玩家
下一篇
Day 29 - 實作練習 - 玩家列表
系列文
從讀遊戲原始碼學做連線遊戲33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言