前回、作成したネット対戦ゲーム型のスネークゲームですが、ただスネーク型のキャラクタが移動するだけなので、もう少し機能を追加してゲームらしくさせてみます。
まず餌と当たり判定です。餌を食べると身体が伸びていき、他のプレイヤーや自分自身に頭をぶつけてしまうとゲームオーバーになります。ゲームオーバー後にもう一度ゲームを開始する方法も考えないといけないのですが、ここでは餌の出現と餌との当たり判定を考えます。
Contents
餌と当たり判定とその後の処理
餌は最初は最大50個とします。プレイヤーが餌を食べると減った分を補います。またユーザーが途中で離脱してもすでに存在する餌は消えないものとします。そしてプレイヤーの頭の部分と接すると餌を食べたということにして、プレイヤーの体長を少し伸ばします。
SnakeGame_Foodクラス
まず餌を管理するためのSnakeGame_Foodクラスをつくります。
餌の大きさと色は乱数で決めます。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class SnakeGame_Food{ constructor(x, y){ this.X = x; this.Y = y; this.Radius = Math.floor(Math.random() * 4) + 2; this.Dead = false; this.ColorString = this.CreateColorString(); } CreateColorString() { let r = Math.floor(Math.random() * 128) + 128; let g = Math.floor(Math.random() * 128) + 128; let b = Math.floor(Math.random() * 128) + 128; let val = r * 256 * 256 + g * 256 + b; let str = val.toString(16); return '#' + ('000000' + str).slice(-6); } } |
餌の配置する
クライアントにはプレイヤーの状態だけでなく餌の位置も送るのでSnakeGame_Dataのプロパティを追加します。
app.js
1 2 3 4 5 6 |
class SnakeGame_Data{ constructor(){ this.Players = []; this.Foods = []; // 追加 } } |
アプリケーションが開始されたら餌をフィールド内に50個ランダムに配置します。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
class SnakeGame { static Foods = []; static FoodsMax = 50; // 餌は最初は最大50個。プレイヤーが増えるたびに増やす予定 constructor(socket_io){ let snakeGame = socket_io.of('/snake-game').on('connection', function (socket){ socket.on('client_to_server_first', (data) => SnakeGame.ClientToServerFirst(snakeGame, socket, data)); socket.on('client_to_server_keydown', (data) => SnakeGame.ClientToServerKeydown(snakeGame, socket, data)); socket.on('client_to_server_keyup', (data) => SnakeGame.ClientToServerKeyup(snakeGame, socket, data)); socket.on('disconnect', () => SnakeGame.Disconnect(snakeGame, socket)); }); setInterval(() => SnakeGame.Interval(snakeGame), 1000/60); this.InitFoods(); // 追加 } // 餌を50個ランダムに配置 InitFoods(){ for(let i =0; i<SnakeGame.FoodsMax; i++) SnakeGame.CreateNewFood(); } static CreateNewFood(){ // フィールド内にランダムに配置する let minX = SnakeGame_Player.MinPosX; let minY = SnakeGame_Player.MinPosY; let maxX = SnakeGame_Player.MaxPosX; let maxY = SnakeGame_Player.MaxPosY; let x = Math.floor(Math.random() * (maxX - minX)) + minX; let y = Math.floor(Math.random() * (maxY - minY)) + minY; SnakeGame.Foods.push(new SnakeGame_Food(x, y)); } } |
1000/60ミリ秒ごとにプレイヤーの状態と餌の位置をクライアントに送信します。
1 2 3 4 5 6 7 8 9 10 11 12 |
class SnakeGame { static Interval(snakeGame){ SnakeGame.Players.forEach(player => { player.Move(); // ここで当たり判定をする(後述) }); let data = new SnakeGame_Data(); data.Players = SnakeGame.Players; data.Foods = SnakeGame.Foods; // 餌の情報を追加 snakeGame.emit('server_to_client_objects', {value : data}); } } |
餌との当たり判定
次に餌との当たり判定、そして死亡判定をおこないます。SnakeGame_Player.Move関数は長くなるので分けました。新しく追加したのは餌との当たり判定、プレイヤーの死亡チェック、死亡時の処理です。あとは前回のMove関数と同じです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
class SnakeGame_Player { Move() { if (this.Dead){ // 死亡時の処理(移動処理はおこなわず、数回点滅させて消滅させる) this.Final(); return; } // 餌を食べることができたかチェック。 this.CheckEatFood(); // 死亡チェック this.CheckDead(); // キー入力による方向転換(前回と同じ) this.TurnAround(); // プレイヤーの座標を変更(前回と同じ) this.PosX += this.Speed * Math.cos(this.Angle); this.PosY += this.Speed * Math.sin(this.Angle); // 体長の最適化(前回と同じ) this.OptimizeLength(); } // キー入力による方向転換 TurnAround(){ if (this.IsLeftKeyDown) this.Angle += -0.1; if (this.IsRightKeyDown) this.Angle += 0.1; } // 体長の最適化(前回と同じ) OptimizeLength(){ this.TrajectoryX.push(this.PosX); this.TrajectoryY.push(this.PosY); let removeCount = this.TrajectoryX.length - this.Length; if (removeCount > 0) { this.TrajectoryX.splice(0, removeCount); this.TrajectoryY.splice(0, removeCount); } } } |
餌を食べたときの処理
餌との当たり判定ですが、プレイヤーの頭の部分と餌の中心部の距離と双方の半径の和(平方根を計算するとそのぶん処理が遅くなるので実際には双方の2乗の値)を比較しています。餌の半径の倍だけプレイヤーの体長を伸ばしています。このとき餌の最大個数を下回っている場合は追加で出現させています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class SnakeGame_Player { CheckEatFood(){ SnakeGame.Foods.forEach(food => { let distance2 = Math.pow(this.PosX - food.X, 2) + Math.pow(this.PosY - food.Y, 2); if(distance2 - Math.pow(this.Radius + food.Radius, 2) < 0){ food.Dead = true; this.Length += food.Radius * 2; } }); // 死亡フラグがない餌だけを集める SnakeGame.Foods = SnakeGame.Foods.filter(food => !food.Dead); // 不足した餌の補充 for(let i=0; i<SnakeGame.FoodsMax - SnakeGame.Foods.length; i++) SnakeGame.CreateNewFood(); } } |
プレイヤーの死亡判定とその後の処理
プレイヤー死亡の場合はSnakeを点滅させて消滅させます。死亡フラグがセットされると前述のMove関数のなかで移動処理はおこなわれず、消滅へ向けた処理がおこなわれます。そして死亡したプレイヤーがいた場所には餌が残ります。敗者は勝者の養分となるのです。
プレイヤーの死亡の条件
プレイヤーの死亡判定ですが、プレイヤーの頭部が身体に接触した段階で死亡として扱います。Slither.io(スリザリオ)では自分自身に対しては当たり判定はありませんが、このゲームでは自他問わずプレイヤーの身体にぶつかったら死亡として処理します。またフィールドの外に出た場合も死亡です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class SnakeGame_Player { CheckDead(){ let dead = false; // 自分を含めて頭部が身体に接触したら死亡 for(let i=0; i< SnakeGame.Players.length; i++){ // すでに死亡しているプレイヤーとの当たり判定はない if(!SnakeGame.Players[i].Dead){ dead = this.IsCrash(SnakeGame.Players[i]); if(dead) break; } } // フィールドの外に出たら死亡 if (!dead && IsOutOfField()) dead = true; // 死亡判定されたら消滅する // this.Dead = trueになるとMove関数のなかで移動処理はおこなわれず // 自機消滅の処理がおこなわれる if(dead){ this.Dead = true; this.FinalCount = 0; } } } |
頭部がプレイヤーに接触していないか?
プレイヤーの頭部がプレイヤーの身体に接触していないかを調べる関数を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class SnakeGame_Player { IsCrash(player){ let trajectoryX = player.TrajectoryX; let trajectoryY = player.TrajectoryY; for(let i=0; i< trajectoryX.length-30; i++){ let x = trajectoryX[i]; let y = trajectoryY[i]; let distance2 = Math.pow(this.PosX - x, 2) + Math.pow(this.PosY - y, 2); // 自分と対象の相手とでは身体の太さが違うかもしれないので // this.Radius + player.Radiusの2乗と双方の距離の2乗を比較している if(this != player && distance2 < Math.pow(this.Radius + player.Radius, 2)) return true; // 自分自身との当たり判定はやや甘めにしている if(this == player && i < trajectoryX.length / 2 && distance2 < Math.pow(this.Radius, 2)) return true; } return false; } } |
死亡判定後の処理
死亡判定された場合はそのプレイヤーを点滅させて消滅させます。1フレームごとに表示色を変えています。七色に輝きながら最期の時を迎えるのです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class SnakeGame_Player { Final(){ this.FinalCount++; let a = this.FinalCount % 6; let colorString = '#FF0000'; if(a == 0) colorString = '#00FF00'; if(a == 1) colorString = '#0000FF'; if(a == 2) colorString = '#FFFF00'; if(a == 3) colorString = '#00FFFF'; if(a == 4) colorString = '#FF00FF'; if(a == 5) colorString = '#FFFFFF'; this.ColorString1 = colorString; this.ColorString2 = colorString; // 死亡判定のあと16フレーム後に消滅させる // そのあと餌を配置する。敗者は勝者の養分に・・・ if(this.FinalCount > 16){ SnakeGame.Players = SnakeGame.Players.filter(player => player != this); this.ChangeNewFoods(); } } // プレイヤー死亡の位置に餌を配置する ChangeNewFoods(){ for(let i=0; i<this.Length; i += 8) SnakeGame.Foods.push(new SnakeGame_Food(this.TrajectoryX[i], this.TrajectoryY[i])); } } |
クライアント側の処理
クライアント側では餌の情報もいっしょに送られてくるので、餌を描画する処理をおこないます。
snake-game/snake-game.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
snake_game.on("server_to_client_objects", function(data){ con.fillStyle = "#000000"; con.fillRect(0, 0, can.width, can.height); let dataFromServer = data.value; dataFromServer.Players.forEach(snake => { DrawSnake(snake); }); // 追加ここから dataFromServer.Foods.forEach(food => { DrawFood(food); }); // 追加ここまで }); function DrawFood(food){ con.fillStyle = food.ColorString; con.beginPath(); con.arc(food.X, food.Y, food.Radius, 0, 2 * Math.PI, false); con.fill(); con.stroke(); } |