paiza開発日誌

IT/Webエンジニア向け総合求人・学習サービス「paiza」(https://paiza.jp ギノ株式会社)の開発者が開発の事、プログラミングネタ、ITエンジニアの転職などについて書いています。

使うのはJavaScriptとNode.jsだけ!30分で3Dオンライン対戦ゲームを作って公開してみた

f:id:paiza:20180621152554g:plain

プレイしてみる

(English article is here)

f:id:paiza:20151217152725j:plainこんにちは、吉岡(@)です。

皆さんは、オンライン対戦ゲームをプレイしたことはありますか?

オンラインゲームは友人と遊ぶだけでなく、世界中の知らない人たちと一緒にプレイするのも楽しいですよね。最近は、PUBGやフォートナイトのような3Dの対戦ゲームも盛り上がっています。

このような3Dオンライン対戦ゲームを自分で作ってみませんか?

かつては、このようなゲームを自分で作ろうと思ったら、サーバとクライアントのプログラムを用意した上で、複雑なネットワーク通信や3Dプログラミングに挑戦しなければなりませんでした。

しかし、最近ではサーバ・クライアント・ネットワーク・3Dの全てをJavaScriptだけで取り扱うことができるようになっており、以前よりかなり簡単に3Dオンライン対戦ゲームが作れるようになっています。

そこでこの記事では、こんな感じでJavaScriptだけでブラウザ上で動作する3Dオンライン対戦ゲームを作ってみます!(ブラウザゲームなので、パソコンからでもスマホからでも遊べます)

f:id:paiza:20180621141501g:plain

プレイしてみる

開発環境について

開発には、JavaScriptでサーバを動かすNode.js、WebGLを使った3Dプログラミングが簡単にできるThree.js、ネットワーク通信にSocket.IOを使います。

開発が簡単になったとは言え、いざ作業を始めようとすると、Node.jsのインストールや、作ったプログラムのデプロイなどが必要となります。これが意外と厄介で、手順通りにインストールしたつもりでも、OSやバージョン、他のソフトウェアなど、さまざまな原因でエラーが出たりして失敗することもあります。

そこで今回は、ブラウザだけでNode.jsを使ったWeb開発ができるPaizaCloud Cloud IDEを使ってみます。

PaizaCloudは自由度が高く、Node.jsなどのさまざまなフレームワークや言語を使ったWeb開発が、初心者でも簡単にできます。PaizaCloudを使えば、最短でNode.jsを使ったWeb開発が始められます。

開発環境がクラウド上で動作しているので、自分でサーバなどを用意しなくても、作ったウェブサービスはその場ですぐに公開し、みんなで遊ぶことができます!遊びながらゲームの調整をすぐにできるので、効率よく改善することができます。

まずは簡単なプログラムからはじめて、2Dのオンライン対戦ゲームを作り、そのあと3Dオンライン対戦ゲームを作ってみましょう。手順に沿って進めれば、30分程度で作れるかと思いますのでぜひ挑戦してみてください。

なお、今回作成するゲームは、こちらで体験プレイすることができますので、ぜひ遊んでみてください。

上記ゲームのソースコードはこちらでも公開しています。

PaizaCloud Cloud IDEを使う

それでは、始めていきましょう。

PaizaCloud Cloud IDEのサイトはこちらです。

https://paiza.cloud/

メールアドレスなどを入力して登録すると、登録確認メールが送られてきます。GitHubやGoogle(Gmail)ログインを利用すると、ボタン一つで登録することもできます。

サーバを作る

開発環境となるサーバを作りましょう。

f:id:paiza:20171213234155p:plain

「新規サーバ作成」ボタンを押して、サーバ作成画面を開きます。 「サーバ作成」ボタンを押します。

f:id:paiza:20180607135933p:plain

3秒程度で、Node.jsを使える開発環境がブラウザ上にできあがります。

プロジェクトの作成

それでは、Node.jsを使ってオンライン対戦ゲームを作成してみましょう。

最初に、Node.jsのアプリケーション作成するため、"npm init"というコマンドを使います。

PaizaCloudでは、ブラウザ上で、コマンドを入力するための「ターミナル」を使うことができます。

画面左側の、「ターミナル」のボタンをクリックします。

f:id:paiza:20171213234317p:plain

ターミナルが起動しますので、"npm init -y アプリケーション名"のようにコマンドを入れます。

"アプリケーション名"というのは作るアプリケーションの名前なので、"music-app"、"game-app" みたいな感じで、好きな名前にするといいですね。

ここでは、アプリケーション名は"myapp"としておきます。"npm init -y myapp"とコマンドを入れて、エンターキーを押します。

$ npm init -y myapp

f:id:paiza:20180607142828p:plain

画面左側のファイルファインダを見ると、package.jsonというファイルが作られています。

package.jsonはアプリケーションが利用するライブラリを管理するNode.jsのアプリケーションに必要なファイルです。

f:id:paiza:20180607140318p:plain

Node.jsでWebサービスを作成する場合、通常ExpressというWebアプリケーションフレームワークを利用しますので、Expressパッケージをインストールしておきましょう。パッケージをインストールするには、"npm install パッケージ名 --save"というコマンドを使います。

$ npm install express --save

インストールすると、node_modulesというディレクトリが作成されます。この中に、アプリケーションが利用するパッケージがインストールされます。

次に、Webアプリケーションのプログラムを作成します。まずは、"Hello Node.js!"と表示するアプリケーションを作成してみましょう。

ファイルを作成するには、PaizaCloudの画面左側の"新規ファイル"ボタンをクリックするか、ファイル管理ビューの右クリックメニューから"新規ファイル"を選択します。

f:id:paiza:20180607140405p:plain f:id:paiza:20180607140439p:plain

ファイル作成ダイアログが表示されますので、ファイル名に"server.js"と入れて、作成ボタンを押します。

f:id:paiza:20180607140526p:plain

ファイル"server.js"が作成されますので、以下のようにコードを書いていきます。

server.js:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello Node.js!');
});

app.listen(3000, () => {
    console.log("Starting server on port 3000!");
});

f:id:paiza:20180607140657p:plain

編集したら、「保存」ボタンを押すか、「Command-S」または「Ctrl-S」で保存します。

このコードを見ていきましょう。1,2行目では、利用するExpressライブラリを読み込んでいます。

4行目の"app.get('/',..."では、トップページ、つまりURLのパス名が"/"の時の動作を関数で書いていきます。ここでは、"res.send()"関数を使って'Hello Node.js'という内容を表示しています。

8行目のapp.listen(3000, ...)でサーバを3000番ポートで起動します。

アプリケーションの起動

それでは作成したアプリケーションを実際に動かしてみましょう!

"node server.js"コマンドで、作成したプログラムを起動します。

$ node server.js

f:id:paiza:20180607141032p:plain

画面の左側に、"3000"と書かれたボタンが追加されました。

f:id:paiza:20180607141059p:plain

作成したアプリケーションは3000番ポートでサーバが起動します。PaizaCloudでは、この3000番ポートに対応したブラウザ起動ボタンを自動で追加しています。

ボタンをクリックすると、ブラウザ(PaizaCloudの中で動くブラウザ)が起動して、メッセージが表示されました!

f:id:paiza:20180607141152p:plain

なお、このサーバはHTTPで動作していますが、PaizaCloudではこれをHTTPSに変換しています。また、サーバはlocalhostで動作していますが、PaizaCloudでは"https://localhost-サーバ名.paiza-user.cloud:ポート番号"というURLでlocalhostに接続できるようになっています。

サーバはこのようにnodeコマンドで起動してもいいのですが、コード変更後の再起動が必要になります。以下のようにnodemonを利用すれば自動で再起動するようになりますので、今後はnodemonコマンドを使いましょう。

$ npm install nodemon -g --save
$ nodemon server.js

簡易版ゲーム作成(サーバ)

それでは、まずはプレイヤーのみが動く簡易版ゲームを作成してみます。

まずはサーバを作成していきましょう。

以下のように、server.jsを書き換えます。

server.js:

'use strict';

const express = require('express');
const http = require('http');
const path = require('path');
const socketIO = require('socket.io');
const app = express();
const server = http.Server(app);
const io = socketIO(server);

const FIELD_WIDTH = 1000, FIELD_HEIGHT = 1000;
class Player{
    constructor(obj={}){
        this.id = Math.floor(Math.random()*1000000000);
        this.width = 80;
        this.height = 80;
        this.x = Math.random() * (FIELD_WIDTH - this.width);
        this.y = Math.random() * (FIELD_HEIGHT - this.height);
        this.angle = 0;
        this.movement = {};
    }
    move(distance){
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
    }
};

let players = {};

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player({
            socketId: socket.id,
        });
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player){return;}
        player.movement = movement;
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

setInterval(function() {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    io.sockets.emit('state', players);
}, 1000/30);

app.use('/static', express.static(__dirname + '/static'));

app.get('/', (request, response) => {
  response.sendFile(path.join(__dirname, '/static/index.html'));
});

server.listen(3000, function() {
  console.log('Starting server on port 3000');
});

編集できたら、保存ボタンを押すか、Ctrl-S又はCommand-Sでファイルを保存します。

それではコードを見ていきましょう。

'use strict';

まず決まり文句ですが、'use strict'を一行目に書いて、最近のJavaScriptのコードということを示しておきます。

const express = require('express');
const http = require('http');
const path = require('path');
const socketIO = require('socket.io');
const app = express();
const server = http.Server(app);
const io = socketIO(server);

続いて、このサーバで利用するパッケージを読み込み、必要なオブジェクトを作成します。ここでは、express, path, socket.ioを利用しています。このあたりも決まり文句になります。

const FIELD_WIDTH = 1000, FIELD_HEIGHT = 1000;

ゲーム画面の範囲を定数で指定しておきます。ここでは、縦横1000にしました。

class Player{
    constructor(){
        this.id = Math.floor(Math.random()*1000000000);
        this.width = 80;
        this.height = 80;
        this.x = Math.random() * (FIELD_WIDTH - this.width);
        this.y = Math.random() * (FIELD_HEIGHT - this.height);
        this.angle = 0;
        this.movement = {};
    }
    move(distance){
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
    }
};

次にプレイヤーを管理するクラスを作りましょう。それぞれのプレイヤー、プレイヤーを区別するid、プレイヤーの幅と高さを表すwidth, height、プレイヤーの位置を表すx, y、プレイヤーの向きを表すangle、プレイヤーの移動方向を表すmovement変数を持っています。

Playerオブジェクト作成時に呼ばれるconstructor関数を見てみましょう。idを乱数で設定し、大きさは縦横(width, height)80です。プレイヤーの位置(x, y)は乱数で設定しています。向きは0(右)に、動きは空にしておきます。

move()関数ではプレイヤーを引数で指定した距離だけ動かします。Math.cos()で角度からx軸の距離を、Math.sin()で角度からy軸の距離を取得し、その分だけx, y変数を変化させています。

let players = {};

プレイヤーの一覧をplayer変数で管理します。

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player();
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player){return;}
        player.movement = movement;
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

サーバとブラウザ(クライアント)は、Socket.IOを使って通信を行います。 io.on('connection',...)は、接続が完了した時に指定した関数が呼ばれますので、ここでネットワーク処理を記述します。

socket.on('game-start',...)では、ゲーム開始時の処理を行います。ここでは、Playerオブジェクトを作成し、プレイヤー一覧を管理するplayers変数に追加しています。

socket.on('movement',...)では、プレイヤーの移動コマンドを処理します。ここではシンプルにPlayerのmovement変数に設定しています。

socket.on('disconnect',...)は、通信が終了したときに呼ばれます。ブラウザを閉じたり、リロードしたり、ページを移動したりした時に呼ばれます。ここではplayers変数から、この通信を行っているプレイヤーを削除します。

setInterval(() => {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    io.sockets.emit('state', players);
}, 1000/30);

setIntervalを使って、1/30ごとにプレイヤーを動かしていきます。プレイヤー一覧(players)のプレイヤーごとに、現在のmovementプロパティの状態にしたがって、前後左右に動くようにmove()関数を呼び出したり、angleプロパティを変更します。

app.use('/static', express.static(__dirname + '/static'));

app.use()関数では、ミドルウェアを設定します。ここではexpress.staticを使って、パスが'/static'ではじまるURLは、'static'ディレクトリ以下の静的ファイルを返すように設定しています。

app.get('/', (request, response) => {
  response.sendFile(path.join(__dirname, '/static/index.html'));
});

トップページは、static/index.htmlを返すように指定します。

server.listen(3000, function() {
  console.log('Starting server on port 3000');
});

最後に、3000番ポートでサーバを起動します。

簡易版ゲーム作成(HTML)

次にブラウザ上で表示するために、トップページのHTMLファイル"static/index.html"を編集します。

PaizaCloudのファイル管理ビューで、ホームディレクトリ(/home/ubuntu)を右クリックし、メニューから「新規ディレクトリ」を選び、"static"という名前のディレクトリを作成します。作成した"static"ディレクトリを右クリックし、「新規ファイル」を選んでファイル作成ダイアログを表示し、"index.html"という名前でファイルを作成します。

作成した"static/index.html"ファイルは以下のように編集します。

static/index.html:

<html>
  <head>
    <title>Paiza Battle Ground</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  </head>
  <body style="width:100%;height:100%;margin:0;">
    <canvas id="canvas-2d" width="1000" height="1000" style="width:100%;height:100%;object-fit:contain;"></canvas>
    <img id="player-image" src="https://paiza.jp//images/bg_ttl_01.gif" style="display: none;">
    <script src="/static/game.js"></script>
  </body>
</html>

このファイルを見てみましょう。

    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

headタグ内のscriptタグで、利用するsocket.IO、jQueryライブラリを読み込んでいます。

    <canvas id="canvas-2d" width="1000" height="1000" style="width:100%;height:100%;object-fit:contain;"></canvas>

canvasタグで、ゲーム画面となるキャンバスを用意します。キャンバスは2次元の図形などを表示することができますので、プレイヤーなどはこのcanvas上に表示します。canvasの座標で利用する幅と高さは1000に設定しています。画面上で表示するサイズは画面全体(100%)に指定しています。

    <img id="player-image" src="/static/player.gif" style="display: none;">

プレイヤーの画像ファイルを設定します。ここではpaizaのキャラクター(パイザくん)を読み込んでいます。canvas内で利用するので、ここではスタイルを"display: none;"として、表示しないように設定しています。

    <script src="/static/game.js"></script>

最後に、ブラウザ(クライアント)で動作するJavaScriptプログラムとなる"static/game.js"ファイルを読み込んでいます。HTMLファイルの最後に記述することで、HTMLファイルが全て読み込まれた状態でプログラムが実行されるようになります。

簡易版ゲーム作成(プレイヤー画像)

HTMLファイルで指定した画像ファイル(player.gif)を用意します。ここでは以下のpaizaのロゴファイルを使ってみます。

f:id:paiza:20180620183108g:plain

PaizaCloudでは、ファイルのアップロードはドラッグ&ドロップで行います。

デスクトップなどに保存した"player.gif"という名前の画像ファイルをドラッグ&ドロップでstaticフォルダにアップロードします。

f:id:paiza:20180620183145p:plain

簡易版ゲーム作成(クライアント)

次に、ブラウザ(クライアント)で動作させるJavaScriptプログラムを記述します。

PaizaCloudで、"static"ディレクトリを右クリックして「新規ファイル」を選択し、"game.js"という名前でファイルを作成します。

作成したファイルを以下のように編集します。

'use strict';

const socket = io();
const canvas = $('#canvas-2d')[0];
const context = canvas.getContext('2d');
const playerImage = $('#player-image')[0];
let movement = {};


function gameStart(){
    socket.emit('game-start');
}

$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
});

socket.on('state', (players, bullets, walls) => {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.lineWidth = 10;
    context.beginPath();
    context.rect(0, 0, canvas.width, canvas.height);
    context.stroke();

    Object.values(players).forEach((player) => {
        context.drawImage(playerImage, player.x, player.y);
        context.font = '30px Bold Arial';
        context.fillText('Player', player.x, player.y - 20);
    });
});

socket.on('connect', gameStart);

それではコードを見ていきましょう。

const socket = io();

Socket.IOを利用してサーバに接続しています。サーバとの通信には、この"socket"変数を使います。

const canvas = $('#canvas-2d')[0];
const context = canvas.getContext('2d');

ゲーム画面となるcanvasオブジェクトをHTML要素から取得します。

canvasへの描画は描画コンテキストを通じて行いますので、"getContext('2d')"で描画コンテキストを取得します。

const playerImage = $('#player-image')[0];

IMGタグで指定した、プレイヤーの画像のHTML要素を取得します。

let movement = {};

movementオブジェクトには、プレイヤーの動きを保存します。forward, back, left, rightプロパティを持ち、前後左右の動きをあらわします。

function gameStart(){
    socket.emit('game-start');
}

gameStart()関数では、ゲーム開始時の処理を記述します。ここでは、サーバにゲーム開始を表す"game-start"メッセージを送っています。

$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
});

プレイヤーの動きをキーボードで操作できるように、キーボードが押された('keydown')、離れた('keyup')イベントを処理します。押されたキーは"event.key"に保存されます。上下左右のキーは"ArrowUp","ArrowDown","ArrowLeft","ArrowRifht"になりますので、これらのキーをmovementのプロパティの"forward","back","left","right"に、KeyToCommandハッシュを使って変換します。

movementオブジェクトのプロパティ(movement[command])には、キーが押された時はtrue, 離された時はfalseを設定して、動きの状態を保持します。 設定したmovementオブエジェクトはsocket.emit()関数を使ってサーバに送ります。

socket.on('state', (players) => {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.lineWidth = 10;
    context.beginPath();
    context.rect(0, 0, canvas.width, canvas.height);
    context.stroke();

    Object.values(players).forEach((player) => {
        context.drawImage(playerImage, player.x, player.y);
        context.font = '30px Bold Arial';
        context.fillText('Player', player.x, player.y - 20);
    });
});

state.on('state',...)では、サーバからプレイヤーの状態が送られたときに、指定した関数が実行されますので、プレイヤーの描画を行います。

まず、clearRectでキャンバスの内容をクリアし、キャンバスの端がわかるように、context.rect()で四角を描きます。rect()のようなパスを使う描画は、描画前にcontext.beginPath(),描画後に context.stroke()を呼び出します。 

また、各プレイヤーについてdrawImage()で画像を表示します。fillText()で'Player'というテキストも表示してみます。

簡易版ゲームを動かす

それでは作ったプログラムを動かして見ましょう。念のため、サーバプログラムを再起動しておきます。

ターミナルで動作している"node server"又は"nodemon server"コマンドがあれば、Ctrl-Cキーを押して終了します。

"nodemon server.js"コマンドでサーバを起動します。

$ nodemon server.js

PaizaCloudの左側に"3000"と書かれたブラウザアイコンが表示されますので、クリックしてブラウザを起動しましょう。

f:id:paiza:20180620183217p:plain

プレイヤーが表示されました!上下左右のキーで動くことを確認してみましょう。

また、ブラウザを複数立ち上げてみると、複数プレイヤーが表示されます。ネットワーク経由で複数プレイヤーが同時にプレイできていますね!

もし動かない場合はブラウザを右クリックして"検証"メニューなどをクリックして、エラーメッセージなどを確認してみましょう。画像ファイルが"static/player.gif"という名前で保存されているかも確認しましょう。

2D版ゲーム(サーバ)

簡易版ゲームでは、複数プレイヤーで遊べるものの、プレイヤーはただ動けるだけでした。今度は、弾を打って戦えるようにしてみましょう!

また、壁をいくつか用意して、壁や画面の端に行ったらそれ以上進めないようにしてみましょう。

まずはサーバプログラムを作ります。

server.jsファイルを開いて以下のように編集します。

server.js:

'use strict';

const express = require('express');
const http = require('http');
const path = require('path');
const socketIO = require('socket.io');
const app = express();
const server = http.Server(app);
const io = socketIO(server);


const FIELD_WIDTH = 1000, FIELD_HEIGHT = 1000;
class GameObject{
    constructor(obj={}){
        this.id = Math.floor(Math.random()*1000000000);
        this.x = obj.x;
        this.y = obj.y;
        this.width = obj.width;
        this.height = obj.height;
        this.angle = obj.angle;
    }
    move(distance){
        const oldX = this.x, oldY = this.y;
        
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
        
        let collision = false;
        if(this.x < 0 || this.x + this.width >= FIELD_WIDTH || this.y < 0 || this.y + this.height >= FIELD_HEIGHT){
            collision = true;
        }
        if(this.intersectWalls()){
            collision = true;
        }
        if(collision){
            this.x = oldX; this.y = oldY;
        }
        return !collision;
    }
    intersect(obj){
        return (this.x <= obj.x + obj.width) &&
            (this.x + this.width >= obj.x) &&
            (this.y <= obj.y + obj.height) &&
            (this.y + this.height >= obj.y);
    }
    intersectWalls(){
        return Object.values(walls).some((wall) => {
            if(this.intersect(wall)){
                return true;
            }
        });
    }
    toJSON(){
        return {id: this.id, x: this.x, y: this.y, width: this.width, height: this.height, angle: this.angle};
    }
};

class Player extends GameObject{
    constructor(obj={}){
        super(obj);
        this.socketId = obj.socketId;
        this.nickname = obj.nickname;
        this.width = 80;
        this.height = 80;
        this.health = this.maxHealth = 10;
        this.bullets = {};
        this.point = 0;
        this.movement = {};

        do{
            this.x = Math.random() * (FIELD_WIDTH - this.width);
            this.y = Math.random() * (FIELD_HEIGHT - this.height);
            this.angle = Math.random() * 2 * Math.PI;
        }while(this.intersectWalls());
    }
    shoot(){
        if(Object.keys(this.bullets).length >= 3){
            return;
        }
        const bullet = new Bullet({
            x: this.x + this.width/2,
            y: this.y + this.height/2,
            angle: this.angle,
            player: this,
        });
        bullet.move(this.width/2);
        this.bullets[bullet.id] = bullet;
        bullets[bullet.id] = bullet;
    }
    damage(){
        this.health --;
        if(this.health === 0){
            this.remove();
        }
    }
    remove(){
        delete players[this.id];
        io.to(this.socketId).emit('dead');
    }
    toJSON(){
        return Object.assign(super.toJSON(), {health: this.health, maxHealth: this.maxHealth, socketId: this.socketId, point: this.point, nickname: this.nickname});
    }
};
class Bullet extends GameObject{
    constructor(obj){
        super(obj);
        this.width = 15;
        this.height = 15;
        this.player = obj.player;
    }
    remove(){
        delete this.player.bullets[this.id];
        delete bullets[this.id];
    }
};
class BotPlayer extends Player{
    constructor(obj){
        super(obj);
        this.timer = setInterval(() => {
            if(! this.move(4)){
                this.angle = Math.random() * Math.PI * 2;
            }
            if(Math.random()<0.03){
                this.shoot();
            }
        }, 1000/30);
    }
    remove(){
        super.remove();
        clearInterval(this.timer);
        setTimeout(() => {
            const bot = new BotPlayer({nickname: this.nickname});
            players[bot.id] = bot;
        }, 3000);
    }
};
class Wall extends GameObject{
};

let players = {};
let bullets = {};
let walls = {};

for(let i=0; i<3; i++){
    const wall = new Wall({
            x: Math.random() * FIELD_WIDTH,
            y: Math.random() * FIELD_HEIGHT,
            width: 200,
            height: 50,
    });
    walls[wall.id] = wall;
}

const bot = new BotPlayer({nickname: 'bot'});
players[bot.id] = bot;

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player({
            socketId: socket.id,
            nickname: config.nickname,
        });
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player || player.health===0){return;}
        player.movement = movement;
    });
    socket.on('shoot', function(){
        console.log('shoot');
        if(!player || player.health===0){return;}
        player.shoot();
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

setInterval(() => {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    Object.values(bullets).forEach((bullet) =>{
        if(! bullet.move(10)){
            bullet.remove();
            return;
        }
        Object.values(players).forEach((player) => {
           if(bullet.intersect(player)){
               if(player !== bullet.player){
                   player.damage();
                   bullet.remove();
                   bullet.player.point += 1;
               }
           } 
        });
        Object.values(walls).forEach((wall) => {
           if(bullet.intersect(wall)){
               bullet.remove();
           }
        });
    });
    io.sockets.emit('state', players, bullets, walls);
}, 1000/30);


app.use('/static', express.static(__dirname + '/static'));

app.get('/', (request, response) => {
  response.sendFile(path.join(__dirname, '/static/index.html'));
});

server.listen(3000, function() {
  console.log('Starting server on port 3000');
});

簡易版ゲームとの変更点を見ていきましょう。

まず、簡易版ゲームではPlayerクラスだけでしたが、ここでは壁を表すWallクラス、弾を表すBulletクラスを作ります。これらのゲーム上のクラスは、ゲーム画面上のある場所に存在するものという共通点がありますので、共通部分をGameObjectクラスとして作成して、Player, Wall, BulletクラスはGameObjectクラスから派生するようにします。

また、マルチプレイヤーゲームですが、対戦相手がいない時やテストプレイ時に便利なように、自動で動くbotプレイヤーも作成します。ボットプレイヤーはPlayerクラスから派生したBotPlayerクラスとしておきます。

class GameObject{
    constructor(obj={}){
        this.id = Math.floor(Math.random()*1000000000);
        this.x = obj.x;
        this.y = obj.y;
        this.width = obj.width;
        this.height = obj.height;
        this.angle = obj.angle;
    }
    ...

ゲーム上に存在するものをあらわすGameObjectクラスのコンストラクタ関数を見ていきましょう。

オブジェクトを区別するためのidは乱数で作成します。位置は、x,yプロパティで、大きさはwidth,heightプロパティで、向きはangleプロパティで保持します。それぞれ、オブジェクト作成時に引数で渡すことができるようにします。

    move(distance){
        const oldX = this.x, oldY = this.y;
        
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
        
        let collision = false;
        if(this.x < 0 || this.x + this.width >= FIELD_WIDTH || this.y < 0 || this.y + this.height >= FIELD_HEIGHT){
            collision = true;
        }
        if(this.intersectWalls()){
            collision = true;
        }
        if(collision){
            this.x = oldX; this.y = oldY;
        }
        return !collision;
    }

GameObjectクラスのmove関数では、引数で指定した距離だけ移動します。Math.cos()でx軸の移動距離を、Math.sin()でy軸の移動距離を取得します。移動した後は、画面の端にいっていないかをx, yが0〜FIELD_WIDTH - this.width, 0〜FIELD_HEIGHT-this.heightの範囲にあるかで確認します。

また、後ほど作成するintersectWalls()関数で壁と衝突していないかを確認します。

移動に成功したらtrueを返します。

衝突していた場合は、移動前の座標(oldX, oldY)に戻して移動をキャンセルし、falseを返します。

    intersect(obj){
        return (this.x <= obj.x + obj.width) &&
            (this.x + this.width >= obj.x) &&
            (this.y <= obj.y + obj.height) &&
            (this.y + this.height >= obj.y);
    }

GameObjectのintersect()関数では、指定したオブジェクトと衝突していないかを確認します。GameObjectは四角いオブジェクトですので、x軸、y軸それぞれについて、交わるところがないかを確認して判定します。

    intersectWalls(){
        return Object.values(walls).some((wall) => this.intersect(wall));
    }

GameObjectのintersectWalls()関数では、全ての壁との衝突を判定します。いずれかの壁と衝突しているかをtrue, falseで返します。

    toJSON(){
        return {id: this.id, x: this.x, y: this.y, width: this.width, height: this.height, angle: this.angle};
    }

GameObjectのtoJSON()関数では、ブラウザ(クライアント)にオブジェクトを送信する場合に利用するJSONへの変換方法を記述します。ここでは、id, x, y, width, height, angleプロパティを利用します。

JSON.serialize()でJSONに変換する時は、このtoJSON()関数で生成したオブジェクトからJSON文字列が生成されます。

次に、Playerクラスを見ていきましょう。

class Player extends GameObject{
    constructor(obj={}){
        super(obj);
        this.socketId = obj.socketId;
        this.nickname = obj.nickname;
        this.width = 80;
        this.height = 80;
        this.health = this.maxHealth = 10;
        this.bullets = {};
        this.point = 0;
        this.movement = {};

        do{
            this.x = Math.random() * (FIELD_WIDTH - this.width);
            this.y = Math.random() * (FIELD_HEIGHT - this.height);
            this.angle = Math.random() * 2 * Math.PI;
        }while(this.intersectWalls());
    }

PlayerクラスはGameObjectクラスから派生していて、GameObjectのプロパティに加えて、プレイヤーの通信ソケットIDを保持するsocketId、プレイヤーのニックネームを保持するnicknamee, プレイヤーの体力を保持するhealth、プレイヤーが発射した弾一覧を保持するbullets、得点を保持するpoint変数を保持します。体力(health)の初期値は10にしておき、初期値をmaxHealthプロパティに保持しておきます。

プレイヤーの位置と向きはランダムに決めますが、壁の中にならないように、壁と衝突していたら他の場所にします。

    shoot(){
        if(Object.keys(this.bullets).length >= 3){
            return;
        }
        const bullet = new Bullet({
            x: this.x + this.width/2,
            y: this.y + this.height/2,
            angle: this.angle,
            player: this,
        });
        bullet.move(this.width/2);
        this.bullets[bullet.id] = bullet;
        bullets[bullet.id] = bullet;
    }

Playerクラスのshoot()関数では弾を発射します。まず、同時に発射できる弾を三個にしてみましょう。bulletプロパティが3つ以上弾オブジェクトが存在したら、発射しないようにします。

"new Bullet()"で弾オブジェクトを作成します。球の位置はプレイヤーの真ん中にしておきます。発射後"player.move(this.width/2)"で球をプレイヤーの端ぐらいまで移動しておきます。

作成した弾はbulletsオブジェクトに保持しておきます。

    damage(){
        this.health --;
        if(this.health === 0){
            this.remove();
        }
    }

Playerのdamage()関数では、弾に当たってダメージを受けた時の処理を記述します。体力(health)を1減らし、0になっていたらremove()で削除します。

    remove(){
        delete players[this.id];
        io.to(this.socketId).emit('dead');
    }

Playerのremove()関数では、playersからプレイヤーを削除し、削除したことをプレイヤーのソケットでブラウザ(クライアント)に伝えます。

    toJSON(){
        return Object.assign(super.toJSON(), {health: this.health, maxHealth: this.maxHealth, socketId: this.socketId, point: this.point, nickname: this.nickname});
    }

PlayerのtoJSON()関数は、プレイヤー情報をブラウザ(クライアント)に送るときに利用するプロパティを指定します。ここでは、GameObjectのtoJSON()で設定したプロパティに加えて、Playerクラスで追加したhealth, maxHealth, socketId, point, nicknameプロパティにします。

続いて、弾を表すBulletクラスを見ていきます。

class Bullet extends GameObject{
    constructor(obj){
        super(obj);
        this.width = 15;
        this.height = 15;
        this.player = obj.player;
    }
    remove(){
        delete this.player.bullets[this.id];
        delete bullets[this.id];
    }
};

弾はゲーム画面上に存在するものなので、BulletクラスはGameObjectクラスを派生しています。コンストラクタでは、弾を発射したプレイヤーをplayerプロパティで保持しておきます。

remove()関数は弾を削除する関数で、全部の弾を管理するbulletsと、その弾を発射したプレイヤーの弾一覧を管理するthis.player.bulletsから、その弾を削除します。

次に、自動で動くボットプレイヤーを表すBotPlayerクラスを見ていきましょう。

class BotPlayer extends Player{
    constructor(obj){
        super(obj);
        this.timer = setInterval(() => {
            if(! this.move(4)){
                this.angle = Math.random() * Math.PI * 2;
            }
            if(Math.random()<0.03){
                this.shoot();
            }
        }, 1000/30);
    }
    remove(){
        super.remove();
        clearInterval(this.timer);
        setTimeout(() => {
            const bot = new BotPlayer({nickname: this.nickname});
            players[bot.id] = bot;
        }, 3000);
    }
};

ボットプレイヤーもプレイヤーなので、BotPlayerクラスはPlayerクラスから派生させます。

コンストラクタでは、タイマーでプレイヤーを1/30ごとに移動させています。移動に失敗したら、乱数でプレイヤーの向きを変えています。また、ランダムに弾も発射しています。 remove()関数はボットが死亡したときに呼ばれますので、3秒後に新しいボットを作成しています。

次に、壁をあらわすWallクラスですが、これはGameObjectを単純に派生させるだけにしておきます。

class Wall extends GameObject{
};

クラスが一通り用意できたので、オブジェクトを作成していきます。

for(let i=0; i<3; i++){
    const wall = new Wall({
            x: Math.random() * FIELD_WIDTH,
            y: Math.random() * FIELD_HEIGHT,
            width: 200,
            height: 50,
    });
    walls[wall.id] = wall;
}

まず、壁を3個作ります。壁の位置はランダムに決めています。作った壁はwallsとして保存しておきます。

const bot = new BotPlayer({nickname: 'bot'});
players[bot.id] = bot;

ボットプレイヤーを一個作ります。

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player({
            socketId: socket.id,
            nickname: config.nickname,
        });
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player || player.health===0){return;}
        player.movement = movement;
    });
    socket.on('shoot', function(){
        console.log('shoot');
        if(!player || player.health===0){return;}
        player.shoot();
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

ブラウザ(クライアント)側プログラムから受け取ったメッセージの処理を、記述していきます。

'game-start'イベントは、ゲーム開始メッセージですので。プレイヤーを、指定されたニックネーム、ソケットIDで作成しておきます。

'movement'メッセージは、プレイヤーの動きを表しますのでプレイヤーのmessageプロパティに設定します。

'shoot'メッセージは、弾の発射を表しますので、shoot()関数を呼び出します。

'disconnect'イベントは、ソケット通信が切断されたときに呼ばれますので、プレイヤー一覧からそのプレイヤーを削除します。

setInterval(() => {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    Object.values(bullets).forEach((bullet) =>{
        if(! bullet.move(10)){
            bullet.remove();
            return;
        }
        Object.values(players).forEach((player) => {
           if(bullet.intersect(player)){
               if(player !== bullet.player){
                   player.damage();
                   bullet.remove();
                   bullet.player.point += 1;
               }
           } 
        });
    });
    io.sockets.emit('state', players, bullets, walls);
}, 1000/30);

続いて、1/30ごとにプレイヤーと弾を移動させていきます。全てのplayerについて、movementプロパティに基づいてプレイヤーを移動させたり、向きを変えたります。

また、すべての弾丸についてもmove()で動かします。もし弾が画面の端に到達したり壁にぶつかって動けない場合、その弾を削除します。また、いずれかのプレイヤーと衝突したら、プレイヤーの体力を減らすためにdamage()関数を呼び出し、弾を削除し、プレイヤーの得点を1増やします。

移動ができたら、最後に、プレイヤー、弾、壁の位置をブラウザ(クライアント)に'state'メッセージとして送信します。

2D版ゲーム(HTML)

続いて、HTMLファイルを作成します。

static/index.htmlを開いて以下のように編集します。

static/index.html:

<html>
  <head>
    <title>Paiza Battle Ground</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  </head>
  <body style="display: flex; flex-direction: column; width: 100%; height: 100%; margin: 0;">
    <div>Paiza Battle Field</div>
    <div style="flex: 1 1; position: relative; overflow:hidden;">
        <div id="start-screen" style="width:100%; height:100%; display: flex; align-items: center; position:absolute; z-index:10;background-color:rgba(128,128,128,0.5);">
            <div style="text-align: center; width: 100%; font-size: xx-large;">
                <input type="text" name="nickname" id="nickname" placeholder="Your nickname" autofocus><br/><br/>
                <button style="font-size: xx-large;" id="start-button">Start</button>
            </div>
        </div>
        <canvas id="canvas-2d" width="1000" height="1000" style="position:absolute;width:100%;height:100%;object-fit:contain;"></canvas>
    </div>
    <img id="player-image" src="/static/player.gif" style="display: none;">
    <script src="/static/game.js"></script>
  </body>
</html>

簡易版ゲームとほぼ同じですが、"start-screen"というIDでゲーム開始画面を追加し、プレイヤーに名前をつけられるようにしています。

2D版ゲーム(クライアント)

次に、ブラウザ(クライアント)側のJavaScriptプログラムを変更していきます。

static/game.jsを開いて、以下のように変更します。

static/game.js:

'use strict';

const socket = io();
const canvas = $('#canvas-2d')[0];
const context = canvas.getContext('2d');
const playerImage = $('#player-image')[0];

function gameStart(){
    socket.emit('game-start', {nickname: $("#nickname").val() });
    $("#start-screen").hide();
}
$("#start-button").on('click', gameStart);

let movement = {};

$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
    if(event.key === ' ' && event.type === 'keydown'){
        socket.emit('shoot');
    }
});

socket.on('state', function(players, bullets, walls) {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.lineWidth = 10;
    context.beginPath();
    context.rect(0, 0, canvas.width, canvas.height);
    context.stroke();

    Object.values(players).forEach((player) => {
        context.save();
        context.font = '20px Bold Arial';
        context.fillText(player.nickname, player.x, player.y + player.height + 25);
        context.font = '10px Bold Arial';
        context.fillStyle = "gray";
        context.fillText('♥'.repeat(player.maxHealth), player.x, player.y + player.height + 10);
        context.fillStyle = "red";
        context.fillText('♥'.repeat(player.health), player.x, player.y + player.height + 10);
        context.translate(player.x + player.width/2, player.y + player.height/2);
        context.rotate(player.angle);
        context.drawImage(playerImage, 0, 0, playerImage.width, playerImage.height, -player.width/2, -player.height/2, player.width, player.height);
        context.restore();
        
        if(player.socketId === socket.id){
            context.save();
            context.font = '30px Bold Arial';
            context.fillText('You', player.x, player.y - 20);
            context.fillText(player.point + ' point', 20, 40);
            context.restore();
        }
    });
    Object.values(bullets).forEach((bullet) => {
        context.beginPath();
        context.arc(bullet.x, bullet.y, bullet.width/2, 0, 2 * Math.PI);
        context.stroke();
    });
    Object.values(walls).forEach((wall) => {
        context.fillStyle = 'black';
        context.fillRect(wall.x, wall.y, wall.width, wall.height);
    });
});

socket.on('dead', () => {
    $("#start-screen").show();
});

簡易版ゲームからの変更点を中心にコードを見ていきましょう。

function gameStart(){
    socket.emit('game-start', {nickname: $("#nickname").val() });
    $("#start-screen").hide();
}

gameStart()はゲーム開始時の処理になります。ここでは、開始画面で設定されたニックネームを取得し、'game-start'メッセージとしてサーバに送っています。また、ゲーム開始画面を消しています。

$("#start-button").on('click', gameStart);

ゲーム開始の"Start"ボタンが押されたら、gameStart()関数を呼び出してゲームを開始します。

$(document).on('keydown keyup', (event) => {
    ...
    if(event.key === ' ' && event.type === 'keydown'){
        socket.emit('shoot');
    }
});

キーボードイベントの処理は簡易版とほぼ同じですが、スペースキーが押されたら、弾を発射するために"shoot"イベントをサーバに送っています。

socket.on('state', function(players, bullets, walls) {
...
    Object.values(players).forEach((player) => {
        context.fillText(player.nickname, player.x, player.y + player.height + 25);
        ...
        context.fillText('♥'.repeat(player.health), player.x, player.y + player.height + 10);
        context.translate(player.x + player.width/2, player.y + player.height/2);
        context.rotate(player.angle);
        context.drawImage(playerImage, 0, 0, playerImage.width, playerImage.height, -player.width/2, -player.height/2, player.width, player.height);
        ...
        if(player.socketId === socket.id){
            ...
            context.fillText('You', player.x, player.y - 20);
            context.fillText(player.point + ' point', 20, 40);
            ...
        }
    });
    Object.values(bullets).forEach((bullet) => {
        context.beginPath();
        context.arc(bullet.x, bullet.y, bullet.width/2, 0, 2 * Math.PI);
        context.stroke();
    });
    Object.values(walls).forEach((wall) => {
        context.fillStyle = 'black';
        context.fillRect(wall.x, wall.y, wall.width, wall.height);
    });
});

サーバから'state'メッセージを受け取ったら、オブジェクトの描画を行います。

playersからプレイヤー一覧を取得し、ニックネーム(nickname)、体力(health)をcontext.fillTextで描画します。プレイヤー画像(playerImage)はcontext.drawImage()で描画しますが、context.translate()/context.rotate()を使って設定された向き(angle)に回転しています。

player.socketIdがsocket.idと同じ場合は、自分のプレイヤーになりますので、'You'と表示し、得点も表示します。

bulletsで弾の一覧を取得し、context.arc()で円を描きます。

wallsで壁の一覧を取得し、指定したサイズの長方形をcontext.fillRect()で描きます。

socket.on('dead', () => {
    $("#start-screen").show();
});

サーバから、プレイヤーが死亡したことを表す'dead'メッセージを受けとったら、開始画面を表示します。

2D版ゲーム(クライアント、モバイル対応)

キーボード操作はできましたが、このままではスマホなどから操作できませんので、タッチイベントを処理して、スマホからも操作できるようにしてみましょう。簡単な操作にするため、タップで弾の発射、タップ中は前進、左右へのスライドでプレイヤーの向きを変更…というふうにします。

以下のコードを、ブラウザ(クライアント)側JavaScriptプログラムのstatic/game.jsを追加します。

static/game.js

...
const touches = {};
$('#canvas-2d').on('touchstart', (event)=>{
    // console.log('touchstart', event, event.touches); 
    socket.emit('shoot');
    movement.forward = true;
    Array.from(event.changedTouches).forEach((touch) => {
        touches[touch.identifier] = {pageX: touch.pageX, pageY: touch.pageY};
    });
    event.preventDefault();
    console.log('touches', touches, event.touches);
});
$('#canvas-2d').on('touchmove', (event)=>{
    movement.right = false;
    movement.left = false;
    Array.from(event.touches).forEach((touch) => {
        const startTouch = touches[touch.identifier];
        movement.right |= touch.pageX - startTouch.pageX > 30;
        movement.left |= touch.pageX - startTouch.pageX < -30;
    });
    socket.emit('movement', movement);
    event.preventDefault();
});
$('#canvas-2d').on('touchend', (event)=>{
    Array.from(event.changedTouches).forEach((touch) => {
        delete touches[touch.identifier];
    });
    if(Object.keys(touches).length === 0){
        movement = {};
        socket.emit('movement', movement);
    }
    event.preventDefault();
});

コードを見ていきましょう。

"touchstart"イベントでは弾を発射するため'shoot'メッセージをサーバに送信し、また前に進むためにmovement.forwardをtrueに設定します。タッチ開始場所はtouches変数に保存しておきます。

"touchmove"イベントでは、タッチが移動した場合によばれます。30以上左右に動いていたら、左右へ移動するようにします。

"touchend"イベントはタッチ操作が終了したときに呼ばれます。touchesから削除し、すべてのタッチ操作が終了したら移動も終了します。

2D版ゲームを動かす

それでは、2D版ゲームを動かしてみましょう。

サーバが起動していなければ、"nodemon server.js"コマンドでサーバを起動します。

$ nodemon server.js

PaizaCloudの左側に"3000"と書かれたブラウザアイコンが表示されますので、クリックしてブラウザを起動しましょう。

ゲーム開始画面が表示されました!名前を入力して、"Start"ボタンを推し、ゲームを開始しましょう。

f:id:paiza:20180620183338p:plain

プレイヤーと、ボットプレイヤーが表示されました。上下左右キーで動作し、スペースキーで弾が発射します!体力がゼロになったらゲーム終了です。

マルチプレイヤーゲームですので、友達などと遊ぶこともできます。スマホからはタッチで簡単に操作できますね。

3D版ゲーム(HTML)

2D版ゲームでも十分楽しいですが、3Dゲームにするとより迫力が増しますので、今度は3D版ゲームを作成してみましょう。ここでは、Three.jsというライブラリを使ってWebGLによる3D表示を行います。

まず、three.jsライブラリを以下からパソコンのデスクトップなどにダウンロードします。

http://threejs.org/build/three.js

ドラッグ&ドロップで、PaizaCloudのstaticディレクトリにコピーします。

次に、HTMLファイルを変更してみましょう。HEAD要素にSCRIPTタグを追加し、追加したthree.jsを読み込みます。

    <script src="/static/three.js"></script>

IDが"canvas-2d"のcanvasタグの下に、もう一個3D用のcanvasを追加します。styleのz-indexで、2Dキャンバスが3Dキャンバスの上に来るようにしておきます。

        <canvas id="canvas-2d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:2;object-fit: contain;"></canvas>
        <canvas id="canvas-3d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:1;object-fit: contain;"></canvas>

これらの変更後のHTMLファイルは以下のようになっています。

static/index.html:

<html>
  <head>
    <title>A Multiplayer Game</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="/static/three.js"></script>
  </head>
  <body style="display: flex; flex-direction: column; width: 100%; height: 100%; margin: 0;">
    <div>Paiza Battle Ground</div>
    <div style="flex: 1 1; position: relative; overflow:hidden;">
        <div id="start-screen" style="width:100%; height:100%; display: flex; align-items: center; position:absolute; z-index:10;">
            <div style="text-align: center; width: 100%; font-size: xx-large;">
                <input type="text" name="nickname" id="nickname" placeholder="Your nickname" autofocus><br/><br/>
                <button style="font-size: xx-large;" id="start-button">Start</button>
            </div>
        </div>
        <canvas id="canvas-2d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:2;object-fit: contain;"></canvas>
        <canvas id="canvas-3d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:1;object-fit: contain;"></canvas>
    </div>
    <img id="player-image" src="/static/player.gif" style="display: none;">
  </body>
  <script src="/static/game-3d.js"></script>
</html>

3D版ゲーム(クライアント)

続いて、ブラウザ(クライアント)側のJavaScriptプログラムを変更しましょう。

WebGLでテキストを描画するにはフォントファイルが必要ですので、以下からデスクトップなどにダウンロードし、PaizaCloudのstaticディレクトリにドラッグ&ドロップでアップロードしておきます。

https://raw.githubusercontent.com/mrdoob/three.js/master/examples/fonts/helvetiker_bold.typeface.json

そして、以下のようにstatic/game.htmlを変更します。

static/game.js:

const socket = io();
const canvas2d = $('#canvas-2d')[0];
const context = canvas2d.getContext('2d');
const canvas3d = $('#canvas-3d')[0];
const playerImage = $("#player-image")[0];

const renderer = new THREE.WebGLRenderer({canvas: canvas3d});
renderer.setClearColor('skyblue');
renderer.shadowMap.enabled = true;

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 100, 1, 0.1, 2000 );

// Floor
const floorGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1);
const floorMaterial = new THREE.MeshLambertMaterial({color : 'lawngreen'});
const floorMesh = new THREE.Mesh(floorGeometry, floorMaterial);
floorMesh.position.set(500, 0, 500);
floorMesh.receiveShadow = true;
floorMesh.rotation.x = - Math.PI / 2; 
scene.add(floorMesh);

camera.position.set(1000, 300, 1000);
camera.lookAt(floorMesh.position);

// Materials
const bulletMaterial = new THREE.MeshLambertMaterial( { color: 0x808080 } );
const wallMaterial = new THREE.MeshLambertMaterial( { color: 'firebrick' } );
const playerTexture = new THREE.Texture(playerImage);
playerTexture.needsUpdate = true;
const playerMaterial = new THREE.MeshLambertMaterial({map: playerTexture});
const textMaterial = new THREE.MeshBasicMaterial({ color: 0xf39800, side: THREE.DoubleSide });
const nicknameMaterial = new THREE.MeshBasicMaterial({ color: 'black', side: THREE.DoubleSide });

// Light
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(-100, 300, -100);
light.castShadow = true;
light.shadow.camera.left = -2000;
light.shadow.camera.right = 2000;
light.shadow.camera.top = 2000;
light.shadow.camera.bottom = -2000;
light.shadow.camera.far = 2000;
light.shadow.mapSize.width = 2048;
light.shadow.mapSize.height = 2048;
scene.add(light);
const ambient = new THREE.AmbientLight(0x808080);
scene.add(ambient);

const loader = new THREE.FontLoader();
let font;
loader.load('/static/helvetiker_bold.typeface.json', function(font_) {
    font = font_;
});
        

// Helpers
// scene.add(new THREE.CameraHelper(light.shadow.camera));
// scene.add(new THREE.GridHelper(200, 50));
// scene.add(new THREE.AxisHelper(2000));
// scene.add(new THREE.DirectionalLightHelper(light, 20));

function animate() {
    requestAnimationFrame( animate );
    renderer.render( scene, camera );
}
animate();

function gameStart(){
    const nickname = $("#nickname").val();
    socket.emit('game-start', {nickname: nickname});
    $("#start-screen").hide();
}
$("#start-button").on('click', gameStart);

let movement = {};
$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
    if(event.key === ' ' && event.type === 'keydown'){
        socket.emit('shoot');
    }
});

const touches = {};
$('#canvas-2d').on('touchstart', (event)=>{
    socket.emit('shoot');
    movement.forward = true;
    socket.emit('movement', movement);
    Array.from(event.changedTouches).forEach((touch) => {
        touches[touch.identifier] = {pageX: touch.pageX, pageY: touch.pageY};
    });
    event.preventDefault();
});
$('#canvas-2d').on('touchmove', (event)=>{
    movement.right = false;
    movement.left = false;
    Array.from(event.touches).forEach((touch) => {
        const startTouch = touches[touch.identifier];
        movement.right |= touch.pageX - startTouch.pageX > 30;
        movement.left |= touch.pageX - startTouch.pageX < -30;
    });
    socket.emit('movement', movement);
    event.preventDefault();
});
$('#canvas-2d').on('touchend', (event)=>{
    Array.from(event.changedTouches).forEach((touch) => {
        delete touches[touch.identifier];
    });
    if(Object.keys(touches).length === 0){
        movement = {};
        socket.emit('movement', movement);
    }
    event.preventDefault();
});

const Meshes = [];
socket.on('state', (players, bullets, walls) => {
    Object.values(Meshes).forEach((mesh) => {mesh.used = false;});
    
    // Players
    Object.values(players).forEach((player) => {
        let playerMesh = Meshes[player.id];
        if(!playerMesh){
            console.log('create player mesh');
            playerMesh = new THREE.Group();
            playerMesh.castShadow = true;
            Meshes[player.id] = playerMesh;
            scene.add(playerMesh);
        }
        playerMesh.used = true;
        playerMesh.position.set(player.x + player.width/2, player.width/2, player.y + player.height/2);
        playerMesh.rotation.y = - player.angle;
        
        if(!playerMesh.getObjectByName('body')){
            console.log('create body mesh');
            mesh = new THREE.Mesh(new THREE.BoxGeometry(player.width, player.width, player.height), playerMaterial);
            mesh.castShadow = true;
            mesh.name = 'body';
            playerMesh.add(mesh);
        }

        if(font){
            if(!playerMesh.getObjectByName('nickname')){
                console.log('create nickname mesh');
                mesh = new THREE.Mesh(
                    new THREE.TextGeometry(player.nickname,
                        {font: font, size: 10, height: 1}),
                        nicknameMaterial,
                );
                mesh.name = 'nickname';
                playerMesh.add(mesh);

                mesh.position.set(0, 70, 0);
                mesh.rotation.y = Math.PI/2;
            }
            {
                let mesh = playerMesh.getObjectByName('health');

                if(mesh && mesh.health !== player.health){
                    playerMesh.remove(mesh);
                    mesh.geometry.dispose();
                    mesh = null;
                }
                if(!mesh){
                    console.log('create health mesh');
                    mesh = new THREE.Mesh(
                        new THREE.TextGeometry('*'.repeat(player.health),
                            {font: font, size: 10, height: 1}),
                            textMaterial,
                    );
                    mesh.name = 'health';
                    mesh.health = player.health;
                    playerMesh.add(mesh);
                }
                mesh.position.set(0, 50, 0);
                mesh.rotation.y = Math.PI/2;
            }
        }
        
        
        if(player.socketId === socket.id){
            // Your player
            camera.position.set(
                player.x + player.width/2 - 150 * Math.cos(player.angle),
                200,
                player.y + player.height/2 - 150 * Math.sin(player.angle)
            );
            camera.rotation.set(0, - player.angle - Math.PI/2, 0);
            
            // Write to 2D canvas
            context.clearRect(0, 0, canvas2d.width, canvas2d.height);
            context.font = '30px Bold Arial';
            context.fillText(player.point + ' point', 20, 40);
        }
    });
    
    // Bullets
    Object.values(bullets).forEach((bullet) => {
        let mesh = Meshes[bullet.id];
        if(!mesh){
            mesh = new THREE.Mesh(new THREE.BoxGeometry(bullet.width, bullet.width, bullet.height), bulletMaterial);
            mesh.castShadow = true;
            Meshes[bullet.id] = mesh;
            // Meshes.push(mesh);
            scene.add(mesh);
        }
        mesh.used = true;
        mesh.position.set(bullet.x + bullet.width/2, 80, bullet.y + bullet.height/2);
    });
    
    // Walls
    Object.values(walls).forEach((wall) => {
        let mesh = Meshes[wall.id];
        if(!mesh){
            mesh = new THREE.Mesh(new THREE.BoxGeometry(wall.width, 100, wall.height), wallMaterial);
            mesh.castShadow = true;
            Meshes.push(mesh);
            Meshes[wall.id] = mesh;
            scene.add(mesh);
        }
        mesh.used = true;
        mesh.position.set(wall.x + wall.width/2, 50, wall.y + wall.height/2);
    });
    
    // Clear unused Meshes
    Object.keys(Meshes).forEach((key) => {
        const mesh = Meshes[key];
        if(!mesh.used){
            console.log('removing mesh', key);
            scene.remove(mesh);
            mesh.traverse((mesh2) => {
                if(mesh2.geometry){
                    mesh2.geometry.dispose();
                }
            });
            delete Meshes[key];
        }
    });
});

socket.on('dead', () => {
    $("#start-screen").show();
});

それでは、2D版ゲームからの変更点を中心に見ていきましょう。

const renderer = new THREE.WebGLRenderer({canvas: canvas3d});
renderer.setClearColor('skyblue');
renderer.shadowMap.enabled = true;

まず、Three.jsを使ってWebGLによる3D描画をするために、THREE.WebGLRendererクラスのオブジェクトを作成し、色と影の設定をします。

const scene = new THREE.Scene();

sceneでは、Three.jsで利用する3Dオブジェクト全体を管理します。

const camera = new THREE.PerspectiveCamera( 100, 1, 0.1, 2000 );

3D描画するときの視点をあらわずカメラオブジェクトを、THREE.PerspectiveCameraで作成します。

// Floor
const floorGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1);
const floorMaterial = new THREE.MeshLambertMaterial({color : 'lawngreen'});
const floorMesh = new THREE.Mesh(floorGeometry, floorMaterial);
floorMesh.position.set(500, 0, 500);
floorMesh.receiveShadow = true;
floorMesh.rotation.x = - Math.PI / 2; 
scene.add(floorMesh);

まず、床を作成します。平面図形をTHREE.PlaneGeometryで作成し、THREE.MeshLambertMaterialで面の色を設定します。

そして、これらの設定を使って、3DオブジェクトをTHREE.Meshで作成します。オブジェクトの場所はposition.setで設定し、position.rotationで回転を指定します。ここでは、Xが(0,0)-(1000,1000)の平面を作成するので、中心位置をあらわずpositionを(500,500)にします。平面は縦向きになっているので、横向きにするため、-PI/2だけ回転しておきます。

作成した3Dオブジェクトは、scene.add()で追加しておきます。

camera.position.set(1000, 300, 1000);
camera.lookAt(floorMesh.position);

カメラの位置をcamera.positionで適当に設定し、カメラの向きが床を見るようにcamera.lookAt()を呼び出しておきます。

// Light
const light = new THREE.DirectionalLight(0xffffff, 1);
...
scene.add(light);
const ambient = new THREE.AmbientLight(0x808080);
scene.add(ambient);

ライトの設定もしておきます。DirectionalLightで平行な光を、AmbientLightは全方向からの光を設定します。

const loader = new THREE.FontLoader();
let font;
loader.load('/static/helvetiker_bold.typeface.json', function(font_) {
    font = font_;
});

WebGL内で文字を表示するにはフォントファイルが必要ですので、THREE.FontLoaderを利用してフォントをダウンロードしておきます。

function animate() {
  requestAnimationFrame( animate );
  renderer.render( scene, camera );
};
animate();

3D描画(レンダリング)は、renderer.render()関数で行います。requestAnimationFrame()を使うことで、描画が必要なタイミングでrender()関数を呼び出すようにします。

Meshes = [];
socket.on('state', (players, bullets, walls) => {
});

socket.on('state')では、サーバから受け取ったプレイヤー、弾、壁の状態から、3Dオブジェクトの作成、削除、設定を行います。使っている3Dオブジェクトの一覧はMeshes変数で保持しておきます。

            playerMesh = new THREE.Group();
...
            mesh = new THREE.Mesh(new THREE.BoxGeometry(player.width, player.width, player.height), playerMaterial);
            playerMesh.add(mesh);
...
                mesh = new THREE.Mesh(
                    new THREE.TextGeometry(player.nickname,
                        {font: font, size: 10, height: 1}),
                        nicknameMaterial,
                );
                playerMesh.add(mesh);
...
                    mesh = new THREE.Mesh(
                        new THREE.TextGeometry('*'.repeat(player.health),
                            {font: font, size: 10, height: 1}),
                            textMaterial,
                    );
                    playerMesh.add(mesh);

プレイヤーの3Dオブジェクトを作成します。プレイヤーは本体、名前部分、体力表示がありますので、THREE.Group()でグループ化して管理します。

        if(player.socketId === socket.id){
            // Your player
            camera.position.set(
                player.x + player.width/2 - 150 * Math.cos(player.angle),
                200,
                      player.y + player.height/2 - 150 * Math.sin(player.angle)
                  );
            camera.rotation.set(0, - player.angle - Math.PI/2, 0);
      
      // Write to 2D canvas
            context.clearRect(0, 0, canvas2d.width, canvas2d.height);
            context.font = '30px Bold Arial';
            context.fillText(player.point + ' point', 20, 40);
        }

自分のプレイヤーの位置からカメラの視点を設定し、TPSのように見えるようにします。 また、得点は2Dのキャンバスに表示するようにします。

            mesh = new THREE.Mesh(new THREE.BoxGeometry(bullet.width, bullet.width, bullet.height), bulletMaterial);
            scene.add(mesh);

弾オブジェクトを生成し、sceneに追加します。

          mesh = new THREE.Mesh(new THREE.BoxGeometry(wall.width, 100, wall.height), wallMaterial);
          scene.add(mesh);

壁オブジェクトも生成して、sceneに追加します。

オブジェクト一覧はMeshes配列に保持しておき、次回更新の際に同じidのオブジェクトがあれば再利用するようにします。

3D版ゲームを動かす

それでは、3D版ゲーム動かしてみましょう。

サーバが起動していなければ、"nodemon server.js"コマンドでサーバを起動します。

$ nodemon server.js

PaizaCloudの左側に"3000"と書かれたブラウザアイコンが表示されますので、クリックしてブラウザを起動しましょう。

ゲーム開始画面が表示されました!名前を入力して、"Start"ボタンを推し、ゲームを開始しましょう。

f:id:paiza:20180620182906p:plain

今度は、3Dでゲームが動いていますね!上下左右キーで動作し、スペースキーで弾が発射します!

体力がゼロになったらゲーム終了です。3Dオンラインマルチプレイヤーゲームの完成です!

マルチプレイヤーゲームですので、友達などと一緒に遊ぶことができます!スマホからもタッチで操作できますね。

なお、PaizaCloudの無料プランでは、一定時間が経つとサーバは停止します。継続的に動かしたい場合は、ベーシックプランへアップデートしてください。

詳しくはこちら https://paiza.cloud

ソースコード

今回作成したゲームのソースコードは以下で確認できます。

GitHub - yoshiokatsuneo/paiza-battle-ground

デモ

今回作成したゲームを以下のURLで実際にプレイできます。

2D版ゲーム: Paiza Battle Ground - JavaScriptで作れる3Dマルチプレイヤー対戦ゲーム! 3D版ゲーム: Paiza Battle Ground

まとめ

というわけで、JavaScriptとNode.jsで、2Dと3Dのオンラインマルチプレイヤーゲームを作ってみました。今回はPaizaCloudを使って、開発環境などを構築することなくブラウザだけで開発し、公開することができました。

意外とすぐに作れますので、みなさんもぜひ試してみてください!

(何かサービスができたらpaiza( @paiza_official )まで教えてくれるとうれしいです!)


PaizaCloud」は、環境構築に悩まされることなく、ブラウザだけで簡単にウェブサービスやサーバアプリケーションの開発や公開ができます。 https://paiza.cloud


paizaラーニング」では、未経験者でもブラウザさえあれば、今すぐプログラミングの基礎が動画で学べるレッスンを多数公開しております。

そして、paizaでは、Webサービス開発企業などで求められるコーディング力や、テストケースを想定する力などが問われるプログラミングスキルチェック問題も提供しています。

スキルチェックに挑戦した人は、その結果によってS・A・B・C・D・Eの6段階のランクを取得できます。必要なスキルランクを取得すれば、書類選考なしで企業の求人に応募することも可能です。「自分のプログラミングスキルを客観的に知りたい」「スキルを使って転職したい」という方は、ぜひチャレンジしてみてください。

paizaのスキルチェック