概要
Go言語で、Websocketの使い方を調べていて、簡単に使えることがわかりました。
本記事では、それを使った対戦シューティングゲームの作り方を詳しく解説していきたいと思います。
なお、使うGoのライブラリは以下の2つです。
作成するファイルは、Websocketサーバーのmain.go(250行程度)と、クライアントのindex.html(350行程度)の2つだけです。
元ネタとなるコード(Gopherくんの描画共有)は、melodyのreadme.md に書かれている50行程度のgoのコードです。これの解説と拡張した点なども併せて行っていきます。
ソースコード:https://github.com/tashxii/gopher-war
Go言語環境のセットアップとビルド、実行
- Go 1.11 (https://golang.org/dl/)
ダウンロードしていれるだけです。適当な場所に環境変数GOPATHに応じたフォルダを作り、そこの下にsrc,pkg,binフォルダを作成します。
Goの開発はGOPATHの下で行います。私は、%GOPATH%=C:\Develop\go\gopath
などに入れています。 - エディタ VS Code
拡張 (Go 0.6.89 ms-vscode.go)を入れておくだけで、かなり快適なインプリメンテーションができます。 - ginのインストール
go get github.com/gin-gonic/gin
を実行します。 - melodyのインストール
go get gopkg.in/olahol/melody.v1
を実行します。 - ビルド
go build
でビルドすると、gopher-war.exeができます。 - 実行
ブラウザで、localhost:5000
にアクセスすることでゲームできます。ゲーム開始時にプレーヤー名を入れる必要があります。
localhost:5000?name=player_name
にアクセスすることでプレーヤー名の入力をスキップできます。
ゲーム説明
操作方法、ルールは以下の通り。腕に覚えのある人は以下の書きなぐりの仕様だけで実装してみるのも面白いかもしれません。
ゲーム画面
操作説明
-
マウスの移動
Gopherくんの移動です。 -
マウスクリック
ボムの発射。クリックを連打すると、キャンセル→次弾発射ができます。 -
マウスクリック長押し(貯め撃ち)
Gopherくんが黄色い枠で囲まれるとチャージ開始(0.3秒)、赤い枠に囲まれるとチャージ完了(0.7秒)、ボタンを離すとミサイル発射。
ミサイル飛行中はミサイルのチャージ、発射はできません。
ミサイルはロマン兵器だからです。なお、ボムは発射できます。
-
ボム、ミサイルの発射方向
上下左右の4方向です。発射前の直前の位置から発射する位置の進行方向に近い方向で発射されます。 -
ボム、ミサイルの当たったとき
Gopherくんのライフ(デフォルト5点)から、ボムなら1点、ミサイルならなんと4点も減ります。ミサイルはロマン兵器だからです。
弾に当たると、Gopherくんは悲鳴を上げ、残りのライフに応じてGopherくんのサイズが小さくなります。ライフが0以下になるとゲームオーバーです。 -
カスタム
index.htmlのconfigというオブジェクトで、Gopherくんのライフや弾のダメージ、速度などを変更できます。
static/imagesの画像を変えることで、Gopherくんやボム、ミサイルの画像を変更することができます。
設計を始めよう
サーバーとクライアントの役割分担
ゲームの仕様は概ね、上に書いてある通りです。
Websocketサーバー側と、ブラウザのクライアント側を以下のように設計します。
-
Websocketサーバー
- 接続クライアント間で双方向通信を行う。
- 機体(Gopherくん)の位置情報と描画情報(チャージ中、ライフによるサイズなど)の、クライアント間での交換を行う。
- 弾(ボム、ミサイル)の位置の自動計算と当たり判定、ダメージ計算を行い、描画イベントをすべてのクライアントへ送信する。
-
ブラウザ
- サーバーと送受信を行い描画情報を処理し、自機の情報のみ管理する。
- 自機の現在位置、描画情報をサーバーに送信する。
- 弾の発射イベント(種類、方向など)をサーバーに送信する。
- サーバーから受信した機体の描画情報、弾の情報を描画する。
データの設計
サーバーで管理、計算する情報
サーバーでは、接続中のGopherくんたち機体の情報(TargetInfo)と、ボム、ミサイルの情報(BulletInfo)を管理します。
//TargetInfo model
type TargetInfo struct {
ID, NAME, CHARGE string
X, Y, LIFE, SIZE int
}
//ID=機体ID、NAME=プレイヤー名、CHARGE=黄色枠、赤枠、枠なしの状態
//X,Y=座標、LIFE=機体の残LIFE、SIZE=機体の描画サイズ
//BulletInfo model
type BulletInfo struct {
ID string
X, Y, LIFE, MAXLIFE, DIRECTION int
DAMAGE, SPEED, FIRERANGE, SIZE int
FIRE, SPECIAL bool
}
//ID=どの機体の弾か
//X,Y=座標、LIFE=残り飛距離、MAXLIFE=最大飛距離、DIRECTION=方向
//DAMAGE=ダメージ、SPEED=1LIFEで進むpx、FIRERANGE=安全地帯脱出距離
//SIZE=描画サイズ、FIRE=射出中、待機中のフラグ、SPECIAL=ミサイルならtrue
FIRERANGEは、当たり判定のロジックのところで説明しますが、自分の弾にも当たれる仕様なので、発射直後は当たり判定をなくしています。(自打球を避けるため)
当たり判定が発生する距離がFIRERANGEです。自機サイズが100、弾のMAXLIFEが30でSPEEDが20pxなら、FIRERANGE=24(120px離れてから当たり判定発生)くらいです。
機体情報、ボム情報、ミサイル情報は、接続しているセッションのポインタをキーとしたマップで以下のように、main関数内で保持しています。
func main() {
router := gin.Default() //ginのルーター
mrouter := melody.New() //melodyのルーター
targets := make(map[*melody.Session]*TargetInfo) //接続中の機体のマップ
bombs := make(map[*melody.Session]*BulletInfo) //ボムのマップ
missiles := make(map[*melody.Session]*BulletInfo) //ミサイルのマップ
...
クライアントで管理、計算する情報
クライアント側では、自機の情報だけを管理します。
<body>
<script>
var url = "ws://" + window.location.host + "/ws";
var wsOpened = false;
var myid = -1;// サーバーから受信する。
var mylife = -1;// myid初期化時に、configで取得する。
var mysize = -1;// myid初期化時に、configで取得する。
var myname = "";// ゲーム開始時にpromptで入力する。
var mycharge = "none"; // チャージ枠はデフォルト非表示
var prevX = 0;// 前回の位置情報X座標
var prevY = 0;// 前回の位置情報Y座標
var bulletDirection = 0;// 弾の発射方向(前回の位置情報から算出する)
var missileload = true;// ミサイル発射可能かどうか
...
ゲームのパラメータ設定
以下のパラメータをindex.html内のjsonとして保持し、接続時に、サーバーに送信します。
var config = {
maxLife: 5, // 機体の最大ライフ
maxSize: 100, // 機体の最大サイズ(px)
bombLife: 30, // ボムの最大生存時間
bombSize: 30, // ボムのサイズ(px)
bombSpeed: 20, // ボムの1LIFE当たりの移動距離(px)
bombFire: 24, // ボムの当たり判定発生距離
bombDmg: 1, // ボムのダメージ
missileLife: 40, // ミサイルの最大生存時間
missileSize: 50, // ミサイルのサイズ(px)
missileSpeed: 32, // ミサイルの1LIFE当たりの移動距離(px)
missileFire: 36, // ミサイルの当たり判定発生距離
missileDmg: 4, // ミサイルのダメージ
dmgSize: 12, // ヒット時の機体の縮小サイズ
missileCharging: 300, // チャージ中の表示時間(millisecond)
missileCharged: 700, // チャージ中からチャージ完了までの時間(millisecond)
dmgMessage: "Ouch...", // ボムヒット時のメッセージ
missileMessage: "Help me!!", // ミサイルヒット時のメッセージ
};
サーバー側でも同じjsonの構造体を定義しています。
https://github.com/tashxii/gopher-war/blob/master/main.go#L31-L45
送受信イベントの設計
サーバーとクライアントで双方向で受信するイベントです。以下のようなものがあります。
サーバーとクライアントで送受信するイベント
クライアント主体の表現で記述します。
イベントID | 説明 | 方向 |
---|---|---|
init | ゲームパラメータのconfig(json)を送信 | クライアントからサーバー |
show | 機体の描画情報(座標、チャージ状態など)を送信 | クライアントからサーバー |
fire-bomb | ボム発射を送信 | クライアントからサーバー |
fire-missile | ミサイル発射を送信 | クライアントからサーバー |
refresh | 弾移動、当たり判定計算依頼を送信 | クライアントからサーバー |
appear | 接続後のIDを受信 | サーバーからクライアント |
show* | 機体の表示情報(座標、LIFEなど)を受信 | サーバーからクライアント |
bullet | 弾の表示情報(座標など)を受信 | サーバーからクライアント |
miss | 外れた弾のIDを受信(描画から削除する) | サーバーからクライアント |
hit | 当たった機体のIDと弾のIDを受信(描画変更) | サーバーからクライアント |
dead | 破壊された機体のIDと弾のIDを受信 | サーバーからクライアント |
*showはクライアントとサーバーでかぶっていますが、サーバー内、クライアント内でそれぞれユニークであれば問題はないです。 |
実装してみよう
送受信の方法
サーバーからの送信、受信、クライアントからの送信、受信という記述だと、サーバーの送信=クライアントの受信という意味になります。
これだと、どっちからどっち方向か混乱するため、以下の記述では、すべてクライアントを主体(主語)として、記述します。
- クライアントは、能動的に「メッセージ」を送信、受信します。
- サーバーは受動的にクライアントからの「処理」を受付させられ、時にクライアントで処理すべき「イベント」を返信させられます。
クライアントでのWebsocketのメッセージ送信方法
Websocket.send(messsage)
APIを使用して、文字列をWebsocketサーバーに送信します。
var url = "ws://" + window.location.host + "/ws";
ws = new WebSocket(url);
//マウスのX,Y座標と、自機のチャージ状態をサーバーに送信
ws.send(["show", e.pageX, e.pageY, mycharge].join(" "));
クライアントでのWebsocketのメッセージ受信方法
Websocket.onmessage = function(message) {...}
APIでイベントハンドラとしてコールバックする関数を登録します。
ws.onmessage = function (msg) {
var cmds = {"appear": appear, "show": show, "bullet": bullet, "hit":hit, "miss": miss, "dead": dead};
if (msg.data) {
var parts = msg.data.split(" ")
var cmd = cmds[parts[0]];
if (cmd) {
cmd.apply(null, parts.slice(1));
}
}
};
上の実装は、文字列の先頭のコマンドタイプ(showやbulletなど)をもとに、javascript内の別の関数を、function.apply
を使ってディスパッチしています。
例えばサーバーからは、show 1 100 200 4 player1 charging
のような文字列を受け取り、javascript内のshow関数に渡します。
//"show %s %d %d %d %s %s", ID, X, Y, LIFE, NAME, CHARGE
function show(id, x, y, life, name, charge) {
サーバーの処理の受付
以下にあるmelodyのAPIを使います。
-
melody.Melody.HandleConnect(func)
サーバー接続時に呼び出す処理。melody.Sessionに、write関数を使って文字列をクライアントに送信できる。 -
melody.Melody.HandleMessage
クライアントからメッセージ(文字列)を受信したときに行う処理。
BroadCasts(message)
で全クライアントにメッセージ送信、BroadCastOthers(message,session)
で、自分以外のクライアントにメッセージ送信する。 -
melody.Melody.HandleDissconnect
サーバー切断時に呼び出す処理。
//クライアントから接続したときに呼び出される関数
mrouter.HandleConnect(func(s *melody.Session) {
lock.Lock()
for _, target := range targets {
// 他の機体をクライアントに表示させるイベントを返信する。
message := fmt.Sprintf("show %s %d %d %d %s %s", target.ID, target.X, target.Y, target.LIFE, target.NAME, target.CHARGE)
s.Write([]byte(message))
}
// 機体とボムとミサイルをマップに追加
targets[s] = &TargetInfo{ID: strconv.Itoa(counter), NAME: "", CHARGE: "none"}
bombs[s] = &BulletInfo{ID: targets[s].ID, SPECIAL: false}
missiles[s] = &BulletInfo{ID: targets[s].ID, SPECIAL: true}
// 新しい機体のIDをクライアントに返信する。
message := fmt.Sprintf("appear %s", targets[s].ID)
s.Write([]byte(message))
counter++
lock.Unlock()
})
// クライアント切断時に呼び出される関数
mrouter.HandleDisconnect(func(s *melody.Session) {
lock.Lock()
// なくなったことをほかのクライアントに伝える。
mrouter.BroadcastOthers([]byte(fmt.Sprintf("dead %s", targets[s].ID)), s)
// マップからエントリを削除する。
delete(targets, s)
delete(bombs, s)
delete(missiles, s)
lock.Unlock()
})
// クライアントから計算や処理依頼を受け取るときのコールバック関数を設定する
mrouter.HandleMessage(func(s *melody.Session, msg []byte) {
params := strings.Split(string(msg), " ")
lock.Lock()
if params[0] == "init" {
//ゲームパラメータの初期化
...(省略)
}
//["show", e.pageX, e.pageY, charge]
if params[0] == "show" && len(params) == 4 {
// 機体の表示
moveTarget(targets[s], params, &config, mrouter, s)
}
//["fire-xxx", e.pageX, e.pageY, direction]
if params[0] == "fire-bomb" && len(params) == 4 {
// ボムの発射
fireBullet(bombs[s], params, &config, mrouter, s)
}
if params[0] == "fire-missile" && len(params) == 4 {
// ミサイルの発射
fireBullet(missiles[s], params, &config, mrouter, s)
}
if params[0] == "refresh" {
// ボムの移動、当たり判定
...(省略)
}
lock.Unlock()
})
サーバーからのイベント依頼の返信
これらのAPIは、 mrouter.HandleXxx
内でイベントの返信やブロードキャストをする際に使う。
-
melody.Session.Write(message)
... 接続セッションに返信。例:機体IDを接続先に返す。 -
melody.Melody.BroadCastOthers(message, melodySession)
... 自分以外に送信。例:機体の表示(自身は表示済みのため、他へのみでよい) -
melody.Melody.BroadCast(message)
... 全てに送信。例:弾の移動。サーバー側で自動計算するため全てのクライアントに返す必要がある。
イベントの実装
ここでも、クライアント目線で送信、受信と記載します。
以下の項目の先頭にjs-
と書かれているのは、javascriptでの実装、go-
と書かれているのはgoでの実装です。
役割設計の通り、サーバー側では、座標値、ダメージ、弾の当たり判定、弾の外れなどの計算を行い、クライアント側では、描画処理を行うだけになっています。
js-ゲームパラメータの初期化:送信(init)
configとして定義されたjsonを文字列としてサーバーに送信しています。
WebSocketが接続前に、送信(ws.send)しないよう、wsOpenedで判定して、未接続ならsetTimeoutで1秒待ってから送信するコードとなっています。
//init (send config to server)
function init() {
//initWebsocket();
console.log("ws is opened = "+ wsOpened)
if (wsOpened === false) {
console.log("Socket connection is not established yet.");
setTimeout(()=>{
console.log("ws is opened(waited) = "+ wsOpened)
ws.send("init " + myname + " " +JSON.stringify(config));
console.log("Starting refresh event...")
timerid = setInterval(refreshEvent, 25);
}, 1000);
} else {
ws.send("init " + myname + " " +JSON.stringify(config));
console.log("Starting refresh event...")
timerid = setInterval(refreshEvent, 25);
}
}
js-自機の初期化:受信(appear)
サーバーから、接続時に、melodySession.Writeで送られたIDを受信、ライフとサイズの初期化を行っているだけです。
//start
function appear(id) {
myid = id;
mylife = config.maxLife;
mysize = config.maxSize;
mycharge = "none";
missileload = true;
}
js-機体の表示:受信(show)
サーバーから受信した、機体ID、座標値、ライフ(サイズ計算に使用)、プレーヤー名、チャージ状態をもとに、Gopherくんを表示します。
//"show %s %d %d %d %s %s", ID, X, Y, LIFE, NAME, CHARGE
function show(id, x, y, life, name, charge) {
if (life <= 0) {
return;
}
// Gopherくんの<div>ノードを作成
var node = document.getElementById("target-" + id);
// ライフ情報からGopherくんのサイズを算出
var newsize = config.maxSize - config.dmgSize*(config.maxLife-parseInt(life));
if (!node) {
node = document.createElement("div");
document.body.appendChild(node);
node.className = "target";
node.style.zIndex = id + 1;
node.id = "target-" + id;
// プレーヤー名を紫で表示。
text = document.createElement("div")
node.appendChild(text)
text.className = "target-name";
text.textContent = name;
text.style.left = "30px";
}
// マウスのカーソルが真ん中になるよう調整
node.style.left = (parseInt(x) - newsize/2) + "px";
node.style.top = (parseInt(y) - newsize/2) + "px";
// 算出したサイズで描画
node.style.width = newsize + "px";
node.style.height = newsize + "px";
// チャージ状態に応じて、黄色枠、赤枠、無枠を設定
if (charge === "charging") {
node.style.border = "5px dotted yellow";
} else if (charge === "charged"){
node.style.border = "5px double red";
} else {
node.style.border = "none";
}
}
js-弾の描画:受信(bullet)
サーバーからもらった座標値に弾丸を描画。SPECIAL
で、ミサイルかボムかを判定。
//"bullet %s %d %d %d %t", ID, X, Y, DIRECTION, SPECIAL
function bullet(id, x, y, direction, special) {
// ミサイルとボムの判定
var prefix = (special === "true") ? "missile-" : "bomb-";
var className = (special === "true") ? "missile" : "bomb";
var bulletSize = (special === "true") ? config.missileSize : config.bombSize;
// 弾の<div>ノードがなければ作成
var node = document.getElementById(prefix + id);
if (!node) {
node = document.createElement("div");
document.body.appendChild(node);
node.className = className;
node.style.zIndex= id + 1;
node.id = prefix + id;
node.style.width = bulletSize + "px";
node.style.height = bulletSize + "px";
}
//座標値に描画
node.style.left = (parseInt(x) - bulletSize/2) + "px";
node.style.top = (parseInt(y) - bulletSize/2) + "px";
// 弾の方向に応じて、画像を回転
...(省略)
}
js-マウス移動イベントと弾の方向決め
mousemoveイベントで、マウス移動時に、自機の移動した地点へ描画を行っています。
その際、前回の移動位置からの差分、dx, dyを計算し絶対値が大きな方向(上下か左右か)を決めています。
自機を表示したので、その移動情報を、ws.send("show ...")
で他のクライアントにサーバー経由で伝えます(サーバーでBroadCastOthersが呼ばれる)。
//move target event
window.addEventListener("mousemove", function (e) {
if (myid > -1 && mylife > 0) {
// 自機の表示
show(myid, e.pageX, e.pageY, mylife, myname, mycharge);
var dx = e.pageX - prevX;
var dy = e.pageY - prevY;
if( dx != 0 || dy != 0 ){
if (Math.abs(dx)>=Math.abs(dy)) {
bulletDirection = (dx>=0) ? 1 : 3;
} else {
bulletDirection = (dy>=0) ? 0 : 2;
}
prevX = e.pageX;
prevY = e.pageY;
}
ws.send(["show", e.pageX, e.pageY, mycharge].join(" "));
}
});
js-弾の方向別描画
cssのstyle.transform
に、rotate(90deg)
などを渡して画像を回転させます。
// 弾の方向に応じて、画像を回転
var rotatedeg = "rotate(90deg)"
if (direction === "1") {
rotatedeg = "rotate(0deg)"
} else if(direction === "2") {
rotatedeg = "rotate(270deg)"
} else if(direction === "3") {
rotatedeg = "rotate(180deg)"
}
node.style.transform = rotatedeg;
go-弾の計算処理依頼:送信(refresh)
サーバーのコールバック関数 mrouter.HandleMessage(func(s *melody.Session, msg []byte)
を登録する際に、設定します。
if params[0] == "refresh" {
currentMillisecond := time.Now().UnixNano() / int64(time.Millisecond)
//Max FPS = 50 までになるよう流動制御
if currentMillisecond-previousMillisecond >= 20 {
previousMillisecond = currentMillisecond
for _, missile := range missiles {
// ミサイルの移動
moveBullet(missile, &config, mrouter)
}
for _, bomb := range bombs {
// ボムの移動
moveBullet(bomb, &config, mrouter)
}
// ミサイル、ボムの当たり判定チェック
for _, target := range targets {
for _, missile := range missiles {
judgeHitBullet(target, missile, &config, mrouter)
}
for _, bomb := range bombs {
judgeHitBullet(target, bomb, &config, mrouter)
}
}
}
}
js-setIntervalによる定期送信(refresh)
このrefresh
イベントは、弾の移動に関する計算結果を全てのクライアントに送信する必要があるため、自動的に更新(ws.send("refresh)
)するよう、javascript側で、setInterval
関数から25ms間隔で、繰り返し呼び出しています。
function init() {
... (省略)
ws.send("init " + myname + " " +JSON.stringify(config));
console.log("Starting refresh event...")
timerid = setInterval(refreshEvent, 25);
... (省略)
}
function refreshEvent() {
ws.send(["refresh"].join(" "))
}
go-流動制御
クライアント側で、setInterval
で25msごとに弾の再計算処理を呼び出しているのですが、クライアントが増えれば増えるほど、refresh
は頻繁に呼び出されるため、弾の速度がカオスなほど早くなってしまいました。
したがって、サーバー側で、流動制御を行う必要がありました。
Go言語では、ミリ秒を直接取得するAPIはないため、一旦UnixNano
秒にしてミリで割るという処理が必要です。こうして取得したミリ秒で、20ミリ秒以上待ってから弾の再計算処理を行うようにしています。
currentMillisecond := time.Now().UnixNano() / int64(time.Millisecond)
//Max FPS = 50
if currentMillisecond-previousMillisecond >= 20 {
previousMillisecond = currentMillisecond
go-弾の移動
moveBullet
という関数で以下のコメントのように実装しています。
func moveBullet(bullet *BulletInfo, config *Config, mrouter *melody.Melody) {
// 弾が発射されている間だけ移動させる。
if bullet.FIRE == false {
return
}
// 移動するたび、寿命を減らす。
bullet.LIFE = bullet.LIFE - 1
if bullet.LIFE <= 0 {
bullet.FIRE = false
// 寿命が切れたら、missイベントを全クライアントに返信する。
message := fmt.Sprintf("miss %s %t", bullet.ID, bullet.SPECIAL)
mrouter.Broadcast([]byte(message))
return
}
// 方向に応じて座標値を計算する。
var dx, dy int
switch bullet.DIRECTION {
case 0:
dy = bullet.SPEED
case 1:
dx = bullet.SPEED
case 2:
dy = -bullet.SPEED
case 3:
dx = -bullet.SPEED
}
bullet.X += dx
bullet.Y += dy
// 全てのクライアントに新しい座標値を返信する。
message := fmt.Sprintf("bullet %s %d %d %d %t", bullet.ID, bullet.X, bullet.Y, bullet.DIRECTION, bullet.SPECIAL)
mrouter.Broadcast([]byte(message))
}
go-弾のはずれイベント(miss)の返信
上記抜粋のmoveBullet
内でbullet.LIF <= 0
で判定し、BroadCast
を使って全てのクライアントに弾の寿命が切れ、描画を止めさせるように返信します。
go-弾の当たり判定
普通の2重ループです。自打球判定くらいが特殊でしょうか。
//機体と弾の2重ループで当たりチェック
for _, target := range targets {
for _, missile := range missiles {
judgeHitBullet(target, missile, &config, mrouter)
}
for _, bomb := range bombs {
judgeHitBullet(target, bomb, &config, mrouter)
}
}
...(省略)
// 当たり判定の関数
func judgeHitBullet(target *TargetInfo, bullet *BulletInfo, config *Config, mrouter *melody.Melody) {
// 弾が発射中でない、機体が破壊済み、自打球になりそうならスキップ
if bullet.FIRE == false || target.LIFE <= 0 || bullet.LIFE >= bullet.FIRERANGE {
return
}
// 中心座標同士を比較する
if target.X-target.SIZE/2 <= bullet.X-bullet.SIZE/2 &&
bullet.X+bullet.SIZE/2 <= target.X+target.SIZE/2 &&
target.Y-target.SIZE/2 <= bullet.Y-bullet.SIZE/2 &&
bullet.Y+bullet.SIZE/2 <= target.Y+target.SIZE/2 {
target.LIFE = target.LIFE - bullet.DAMAGE
bullet.LIFE = 0
bullet.FIRE = false
// 機体のライフが0以下ならdeadを全てのクライアントに返信
if target.LIFE <= 0 {
message := fmt.Sprintf("dead %s %s %t", target.ID, bullet.ID, bullet.SPECIAL)
mrouter.Broadcast([]byte(message))
} else {
// 機体が生存ならhitを全てのクライアントに返信
message := fmt.Sprintf("hit %s %s %d %t", target.ID, bullet.ID, target.LIFE, bullet.SPECIAL)
mrouter.Broadcast([]byte(message))
}
}
}
go-弾の当たりイベント(hit)/機体の死亡イベント(dead)の返信
上記の抜粋の、target.LIFE <= 0
の後の、mrouter.BroadCast
が返信処理になります。当たった弾と、機体のサイズもしくは破壊による描画停止をするため、機体ID、弾ID、機体ライフ等を返信しています。
js-弾の当たり:受信(hit)
機体のサイズ、ダメージメッセージの描画と弾の削除です。
missileload
フラグで再発射可能にしなおしています。
//"hit %s %s %d %t", target.ID, bullet.ID, target.LIFE, bullet.SPECIAL
function hit(id, bulletid, life, special) {
// 機体の再描画
var node = document.getElementById("target-" + id);
if (node) {
// ライフによるサイズ変更
var newsize = config.maxSize - config.dmgSize*(config.maxLife-life)
node.style.width = newsize + "px";
node.style.height = newsize + "px";
if (id === myid) {
mylife = life;
mysize = newsize;
}
// ダメージメッセージの配置
var msg = document.createElement("div");
document.body.appendChild(msg);
msg.className = "msg " + "msg-" + id;
msg.style.zIndex= -1;
msg.style.position = "absolute";
var message = (special === "true") ? config.missileMessage : config.dmgMessage;
msg.textContent = message;
msg.style.left = (parseInt(node.style.left,10) + newsize) + "px";
msg.style.top = (parseInt(node.style.top,10) + newsize/2) + "px";
}
// ボムまたはミサイルの削除
var prefix = (special === "true") ? "missile-" : "bomb-";
var bullet = document.getElementById(prefix + bulletid);
if (bullet) {
document.body.removeChild(bullet);
}
// 自分のミサイルなら再発射可能にする
if (bulletid === myid && special === "true") {
missileload = true;
}
}
js-弾のはずれ:受信(miss)
ボムかミサイルか判定して消すだけです。自分のミサイルなら再発射可能にします。
//"miss %s %t", bullet.ID, bullet.SPECIAL
function miss(bulletid, special) {
// ボムまたはミサイルの削除
var prefix = (special === "true") ? "missile-" : "bomb-";
var bullet = document.getElementById(prefix + bulletid);
if (bullet) {
document.body.removeChild(bullet);
}
// 自分のミサイルなら再発射可能にする
if (bulletid === myid && special === "true") {
missileload = true;
}
}
js-機体の死亡:受信(dead)
弾、機体、ダメージメッセージの削除とゲームオーバー判定です。
//"dead %s %s %t", target.ID, bullet.ID, bullet.SPECIAL
function dead(id, bulletid, special) {
// 自分のミサイルなら再発射可能にする
if (bulletid === myid && special === "true") {
missileload = true;
}
// 機体の描画削除
var node = document.getElementById("target-" + id);
if (node) {
document.body.removeChild(node);
}
// 弾の描画削除
var prefix = (special === "true") ? "missile-" : "bomb-";
var bullet = document.getElementById(prefix + bulletid);
if (bullet) {
document.body.removeChild(bullet);
}
// 全てのダメージメッセージの削除
var msgs = document.querySelectorAll(".msg-" + id)
for(var i=0; i<msgs.length; i++) {
document.body.removeChild(msgs[i])
}
// 自機なら死亡にし、ゲームオーバー画面にする。
if (id === myid) {
mylife = 0;
document.body.className = "body-gameover";
document.body.style.animation = "background-animation 4s linear infinite alternate"
if (timerid) {
// 弾の計算処理のための自動送信を停止する。
console.log("Stop refresh event...")
setTimeout(function() {clearInterval(timerid)}, 10000)
}
}
}
js-貯め撃ち発射(黄色い枠と赤い枠)の描画変更:送信(show)
少しトリッキーなコードとなっています。
setTimeoutを2段階で呼び出しています。まずチャージ中にする時間(黄色い枠を表示)を待ってから、そのコールバックの中でチャージ完了する時間(赤い2重枠を表示)まで待つようにしています。
javascript内のshow
関数とws.send("show ...")
で描画を変えています。
//charge event
window.addEventListener("mousedown", function (e) {
if (missileload === true) {
if (mycharge === "none") {
// 1段階目、チャージ開始まで待つ
chargingTimerid = setTimeout(()=>{
mycharge = "charging";
// 自機の描画変更(自分のブラウザ)
show(myid, e.pageX, e.pageY, mylife, myname, mycharge);
// 参加者全員の描画変更
ws.send(["show", e.pageX, e.pageY, mycharge].join(" "));
// 2段階目、チャージ完了まで待つ
chargedTimerid = setTimeout(()=>{
mycharge = "charged"
// 自分と参加者の描画変更
show(myid, e.pageX, e.pageY, mylife, myname, mycharge);
ws.send(["show", e.pageX, e.pageY, mycharge].join(" "));
}, config.missileCharged);
}, config.missileCharging);
}
}
});
js-ボム/ミサイル発射:送信(fire-bomb/fire-missile)
mycharge変数がcharged
ならミサイル、それ以外ならボムを発射します。
黄色い枠と赤い枠への変更タイマーのキャンセルを行っています。
(これで貯め撃ち処理とその後始末が完了です。)
//fire bomb or missile event
window.addEventListener("mouseup", function(e) {
// チャージ開始待ちのキャンセル
if(chargingTimerid) {
clearTimeout(chargingTimerid);
chargingTimerid = undefined;
}
// チャージ完了、発射待ちのキャンセル
if(chargedTimerid) {
clearTimeout(chargedTimerid);
chargedTimerid = undefined;
}
if (myid > -1 && mylife > 0) {
// "charged"
if(mycharge === "charged") {
mycharge = "none";
show(myid, e.pageX, e.pageY, mylife, myname, mycharge);
ws.send(["show", e.pageX, e.pageY, mycharge].join(" "));
if (missileload === true){
missileload = false;
bullet(myid, e.pageX, e.pageY, bulletDirection, "true");
ws.send(["fire-missile", e.pageX, e.pageY, bulletDirection].join(" "));
}
} else {
mycharge = "none";
show(myid, e.pageX, e.pageY, mylife, myname, mycharge);
ws.send(["show", e.pageX, e.pageY, mycharge].join(" "));
bullet(myid, e.pageX, e.pageY, bulletDirection, "false");
ws.send(["fire-bomb", e.pageX, e.pageY, bulletDirection].join(" "));
}
}
})
go-ボム/ミサイルの発射処理依頼
発射中にし、座標値、方向を決めて移動処理対象にします。
描画処理を自分のクライアント以外にBroadCastOthers
しています。
func fireBullet(bullet *BulletInfo, params []string, config *Config, mrouter *melody.Melody, s *melody.Session) {
bullet.FIRE = true
bullet.LIFE = bullet.MAXLIFE
bullet.X, _ = strconv.Atoi(params[1])
bullet.Y, _ = strconv.Atoi(params[2])
bullet.DIRECTION, _ = strconv.Atoi(params[3])
message := fmt.Sprintf("bullet %s %d %d %d %t", bullet.ID, bullet.X, bullet.Y, bullet.DIRECTION, bullet.SPECIAL)
mrouter.BroadcastOthers([]byte(message), s)
}
js-ゲームオーバー後、キー押下でContinueの実装
キー押下のイベントに関数を追加します。ゲームオーバー画面を少し見せて(setTimeout
)から再ロードです。
この時、?name=player_name
を渡してプレーヤー名を再入力しないようにしています。
//gameover continue event
window.addEventListener("keydown", function(e) {
if (mylife <= 0) {
setTimeout(function(){
location.replace("?name=" + encodeURI(myname));
},500);
}
});
まとめ
Go言語によるWebsocketサーバーの実装は上記のように簡単に行えます。
https://github.com/tashxii/gopher-war/blob/master/main.go
描画系の処理をさせるには、Javascript側がかなり大変になるかと思います。
https://github.com/tashxii/gopher-war/blob/master/static/index.html
この記事のおまけとしては、static/imagesのファイル差し替えると雰囲気をがらりと変えることができます。
以上です。長々とお付き合いいただきありがとうございました。