Tìm hiểu game framework Phaser qua việc làm một game infinite scrolling đơn giản

Phaser là một framework làm game HTML5 khá thú vị và dễ sử dụng. Bài viết này mình sẽ giới thiệu cách làm một game infinite scrolling đơn giản sử dụng framework này.

Game của chúng ta bao gồm một nhân vật chạy xuyên suốt game và tránh các vật cản sinh ra ngẫu nhiên bằng cách nhảy qua chúng. Điều kiện kết thúc game duy nhất là nhân vật chạm vào vật cản. Nhân vật sẽ tăng tốc độ chạy theo thời gian, qua đó tăng độ khó cho trò chơi.

Bạn có thể tải mã nguồn tại đây.

Khởi tạo tài nguyên

Chúng ta cần khởi tạo một file html chính để render game và chứa link tham chiếu đến các tài nguyên cần thiết. Game cần sprite và code javascript, nên để tách bạch, cấu trúc thư mục của game sẽ như sau:

infinite-scrolling-demo
    |__assets/
    |    // chứa sprite ở đây
    |__src/
    |    // chứa code javascript ở đây
    |__index.html

File index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Infinite scrolling demo</title>
    <script type="text/javascript" src="http://cdn.jsdelivr.net/phaser/2.2.2/phaser.min.js"></script>
  </head>
  <body>
  </body>
</html>

Để sử dụng framework Phaser thì chúng ta có thể tham chiếu thông qua thẻ script và đường link như trên hoặc tải framework về và tham chiếu đến, tương tự các file javascript thông thường.

Bắt tay vào code

Khởi tạo đối tượng game

Chúng ta sẽ khởi tạo một đối tượng Game của Phaser, đối tượng này cung cấp các hàm nạp tài nguyên, quản lý các trạng thái của game, chạy game loop (vòng lặp chính của game) và cung cấp rất nhiều tiện ích khác. Để dễ quản lý, chúng ta sẽ thực hiện việc khởi tạo này trong file main.js và tham chiếu file này trong index.html

index.html

<body>
  <script type="text/javascript" src="src/main.js"></script>
</body>

main.js

var game = new Phaser.Game(600, 450, Phaser.AUTO, "");

600, 450 là chiều dài, rộng của vùng hiển thị game. Tham số thứ 3 là phương thức render, mặc định là Phaser.AUTO (tự động phát hiện), ngoài ra còn có các giá trị là Phaser.WEBGL (sử dụng webgl để render), Phaser.CANVAS (sử dụng canvas để render) và Phaser.HEADLESS (không render). Tham số thứ 4 là DOM element mà canvas hiển thị của game sẽ được chèn vào, mặc định là "", khi đó canvas sẽ được chèn vào body. Tất nhiên 2 tham số 3 và 4 có thể bỏ do sử dụng giá trị mặc định 😃). (Ngoài ra hàm khởi tạo này còn có một số tham số nữa ở cuối, tuy nhiên không cần thiết dùng ở đây, bạn có thể xem thêm tại tài liệu của hàm đó)

Khởi tạo đã xong, bạn có thể mở file index.html trên trình duyệt để xem kết quả:

Thêm state đầu tiên của game: Menu

Tiếp theo chúng ta cần thêm một state (trạng thái)) của game. Mỗi game sẽ có 1 hay nhiều state khác nhau (như boot, load, hiển thị main menu, vào game, kết thúc game, ..). Để thêm một state, chúng ta thêm vào file main.js như sau:

game.state.add("Menu", Menu);

Thêm tham chiếu trong file index.html

<body>
  <script type="text/javascript" src="src/menu.js"></script>
  <script type="text/javascript" src="src/main.js"></script>
</body>

"Menu" là định danh của state, có thể đặt theo ý thích nhưng định danh này phải duy nhất, không được trùng với định danh của các state khác. Biến Menu là một object định nghĩa các hàm:

  • preload: thực hiện nạp các tài nguyên (sprite, spritesheet, tilemap, ...)
  • create: khởi tạo các giá trị, đối tượng có trong state
  • update: cập nhật các giá trị, đối tượng có trong state

Menu của game hiện tại chỉ chứa một nút start, không thực hiện việc cập nhật trạng thái gì nên có thể bỏ hàm update trong định nghĩa. Chúng ta sẽ viết định nghĩa của Menu trong file src/menu.js

var Menu = {
  preload: function() {
    game.load.image("startBtn", "assets/start.png");
  },

  create: function() {
    game.stage.backgroundColor = "#71c5cf";
    startButton = game.add.button(game.width/2, game.height/2, "startBtn", this.startGame, this);
    startButton.anchor.setTo(0.5);
  },

  startGame: function() {
    game.state.start("Game");
  }
};

Hàm preload thực hiện việc nạp file assets/start.png và gán định danh startBtn cho nó (bạn có thể tải các assets tại đây).

Hàm create đặt màu nền cho state, tạo một button ở giữa màn hình với sprite là startBtn đã được nạp từ trước với hàm callback startGame sẽ được gọi khi button được nhấn. Tuy nhiên khi hiển thị, thay vì điểm giữa của button, góc trên bên trái của button sẽ hiển thị ở giữa màn hình, do đó chúng ta cần đặt lại điểm neo (anchor) của button về giữa button bằng hàm setTo với giá trị 0.5.

Hàm startGame sẽ thực hiện việc chuyển state cho game sang state "Game".

Để game bắt đầu state "Menu", chúng ta thêm vào cuối file menu.js:

game.state.start("Menu");

Tải lại file index.html, chúng ta có kết quả:

Tuy nhiên khi nhấn nút START sẽ không có phản ứng do state "Game" chưa được định nghĩa. Chúng ta sẽ tiếp tục định nghĩa state "Game" chứa logic chính của game.

Thêm state thứ 2: Game

Tiếp tục thêm state vào đối tượng game:

...
game.state.add("Game", Game);
game.state.start("Menu");

Tạo file src/game.js, tham chiếu trong file index.html:

<body>
  <script type="text/javascript" src="src/menu.js"></script>
  <script type="text/javascript" src="src/game.js"></script>
  <script type="text/javascript" src="src/main.js"></script>
</body>

Thêm vào src/game.js:

var Game = {
  preload: function() {
    game.load.image("ground", "assets/ground.png");
    game.load.image("player", "assets/player.png");
    game.load.image("obstacle", "assets/obstacle.png");
  },

  create: function() {
    game.stage.backgroundColor = "#71c5cf";

    game.physics.startSystem(Phaser.Physics.ARCADE);
  },

  update: function() {
  }
}

Hàm create sẽ khởi tạo hệ thống vật lý trong game với hệ vật lý Arcade. Đây là một hệ vật lý đơn giản với biên xác định va chạm là hình chữ nhật. Do vậy, khi bạn khởi tạo một đối tượng có hình dạng phức tạp thì việc xác định va chạm có thể không đúng như mong muốn. Bạn có thể sử dụng hệ vật lý P2 hay Ninja, đã được tích hợp sẵn trong Phaser, thay cho Arcade. Đối với bài viết này, hệ vật lý Arcade đáp ứng được yêu cầu nên chúng ta sẽ sử dụng nó.

Bạn có thể xem hướng dẫn về hệ vật lý P2 tại đây và Ninja tại đây.

Thêm mặt đất

Chúng ta sẽ thêm mặt đất để nhân vật đứng trên đó:

var Game = {
  ground: null,
  ...

// hàm create
  ...
  this.ground = game.add.sprite(0, game.world.height - 100, "ground");
  game.physics.arcade.enable(this.ground);
  this.ground.body.immovable = true;
  ...

game.physics.arcade.enable(this.ground); sẽ tạo một physic body cho ground để có thể tương tác với các object khác. Body của ground sẽ được đặt immovable = true để không thể dịch chuyển khi object khác va chạm với nó (chẳng hạn khi nhận vật rơi xuống).

Thêm nhân vật

Tiếp tục thêm nhân vật:

var Game = {
  ground: null,
  player: null,
  ...

// hàm create
  ...
  this.player = game.add.sprite(100, game.world.height - 150, "player");
  game.physics.arcade.enable(this.player);
  this.player.body.gravity.y = 1000;
  ...

Trọng lực (gravity) theo chiều thẳng đứng (y) của nhân vật sẽ được đặt giá trị 1000 để nhân vật rơi từ trên xuống, giá trị âm sẽ làm nhân vật rơi theo chiều ngược lại. Bạn hãy tải lại file index.html trên trình duyệt để xem kết quả.

Có thể thấy nhân vật của chúng ta rơi xuyên qua mặt đất 😐. Chúng ta cần phải thêm phát hiện va chạm giữa nhân vật và mặt đất ở hàm update:

update: function() {
  game.physics.arcade.collide(this.player, this.ground);
}

Bạn hãy chỉnh lại vị trí của nhân vật để nhân vật xuất hiện cao hơn mặt đất và tải lại trang index.html để xem kết quả.

Cho phép nhân vật nhảy lên

Khi chúng ta nhấn một phím, chẳng hạn phím cách, nhân vật sẽ nhảy lên. Để thực hiện điều này, chúng ta sẽ định nghĩa input cho game và liên kết input này với một hàm callback:

// hàm create
  ...
  spaceKey = game.input.keyboard.addKey(Phaser.Keyboard.SPACEBAR);
  spaceKey.onDown.add(this.jump, this);
  ...

Định nghĩa thêm hàm callback cho state Game:

var Game = {
  ...
  jump: function() {
    this.player.body.velocity.y = -500;
  }
}

Chúng ta đặt giá trị âm cho vận tốc theo chiều thẳng đứng của nhân vật để nhân vật dịch chuyển từ dưới lên. Tuy nhiên khi nhấn phím cách nhiều lần, nhân vật có thể "bay" lên ngay cả khi không chạm đất 😮. Vậy nên chúng ta cần kiểm tra xem nhân vật có đang chạm ở phía dưới không, nếu có thì mới đặt vận tốc cho nhân vật:

jump: function() {
  if (this.player.body.touching.down) {
    this.player.body.velocity.y = -500;
  }
}

Sinh các vật cản

Các vật cản sẽ được chứa vào một group để tiện cho việc kiểm tra va chạm:

var Game = {
  player: null,
  ground: null,
  obstacles: null,
  ...

// hàm create
this.obstacles = game.add.group();
this.obstacles.enableBody = true;

Thiết lập enableBody = true để các obstacle tạo ra bằng obstacles.create sẽ được tạo luôn physic body.

Các vật cản sẽ được tạo ra vào thời điểm ngẫu nhiên. Để làm được điều này chúng ta cần lưu mốc mà một vật cản được sinh ra, sinh ngẫu nhiên khoảng thời gian tiếp theo (X) mà vật cản tiếp theo được tạo. Mỗi lần hàm update được chạy, nếu thời gian hiện tại lớn hơn mốc sinh vật cản cũ cộng với khoảng thời gian X thì ta sẽ cập nhật lại mốc thời gian, sinh lại X và tạo vật cản:

var Game = {
  player: null,
  ground: null,
  obstacles: null,
  time_until_spawn: null,
  last_spawn_time: null,
  ...

// hàm create
// khởi tạo các giá trị ban đầu của time_until_spawn và last_spawn_time
this.time_until_spawn = Math.random() * 1000 + 1000;
// game.time.time giúp lấy thời gian hiện tại
this.last_spawn_time = game.time.time;

// hàm update
...
// thêm kiểm tra và chạm giữa player và obstacles
game.physics.arcade.overlap(this.player, this.obstacles, this.endGame, null, this);

var current_time = game.time.time;
if (current_time - this.last_spawn_time > this.time_until_spawn) {
  this.time_until_spawn = Math.random() * 1000 + 1000;
  this.last_spawn_time = current_time;
  this.spawnObstacle();
}

Định nghĩa tiếp hàm spawnObstacle để sinh vật cản:

spawnObstacle: function() {
  var obstacle = this.obstacles.create(game.world.width, game.world.height - 150, "obstacle");

  // cho các vật cản dịch chuyển từ phải sang trái
  obstacle.body.velocity.x = -200;

  obstacle.checkWorldBounds = true;
  obstacle.outOfBoundsKill = true;
}

obstacle.checkWorldBounds = trueobstacle.outOfBoundsKill = true giúp xóa vật cản khi vật cản dịch chuyển khỏi biên của game. Điều này giúp loại bỏ các đối tượng không cần thiết để giảm bộ nhớ được sử dụng khi chương trình chạy lâu.

Tiếp tục định nghĩa hàm callback endGame được chỉ định ở hàm kiểm tra va chạm giữa nhân vật và các vật cản ở phía trên. Chúng ta sẽ khởi động lại state "Menu" của game:

endGame: function() {
  game.state.start("Menu");
}

Đến lúc này bạn có thể chơi thử game được rồi 😃

Tăng tốc độ của vật cản theo thời gian

Chúng ta sẽ lưu tốc độ hiện tại vào một biến, tăng giá trị của biến này qua mỗi lần update và đặt tốc độ của vật cản mới được tạo ra theo tốc độ hiện tại này:

var Game = {
  ...
  current_speed: 200,
  speed_inc_amount: 0.05,
  ...

// hàm update
update: function() {
  this.increaseSpeed();
  ...
}

// viết thêm hàm increaseSpeed
increaseSpeed: function() {
  // giới hạn tốc độ tối đa
  if (this.current_speed < 500) {
    this.current_speed += this.speed_inc_amount;
  }
}

// sửa lại tốc độ của vật cản mới được sinh ra
spawnObstacle: function() {
  ...
  obstacle.body.velocity.x = -this.current_speed;
  ...
}

Tuy nhiên khi tốc độ tăng nhanh lên thì các vật cản được sinh ra có khoảng cách khá lớn do phải mất ít nhất 1 giây thì vật cản mới mới được tạo, do vậy chúng ta cần sửa lại khoảng thời gian kế tiếp để sinh vật cản mới theo tốc độ hiện tại:

// hàm update
if (current_time - this.last_spawn_time > this.time_until_spawn) {
  this.time_until_spawn = Math.random() * 1000 + (1000 - this.current_speed);
  ...
}

Kết luận

Trên đây mình đã hướng dẫn các bạn làm một game infinite scrolling đơn giản (và khá là cùi 😃) ) bằng framework Phaser, hy vọng các bạn đã có một cái nhìn sơ bộ về framework này. Cảm ơn các bạn đã theo dõi bài viết!

Tham khảo