以前、C# WindowsFormsで『スペースウォー!』(Spacewar!)をつくりました。今回はJavaScriptでつくります。
スペースウォー!は1962年、当時マサチューセッツ工科大学(MIT)の学生であったスティーブ・ラッセルを中心に、DEC社のミニコンPDP-1上で稼動するデモンストレーションプログラムとして開発されました。世界初のシューティングゲームとされています。
特徴として、方向転換しただけでは進行方向を変えることはできません。また中心には太陽があり、プレイヤーの移動に影響を与えます。近づきすぎると重力に引き込まれてミスとなります。操作が難しいゲームといえます。
Contents
HTML部分
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>鳩でもわかるスペースウォー!(Spacewar!)</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel = "stylesheet" href = "./style.css" type = "text/css" media = "all"> <style> </style> </head> <body> <div id = "container"> <canvas id = "canvas"></canvas> <button id = "start" class = "buttons">START</button> <div id = "ctrl-buttons"> <button id = "up" class = "buttons">加速</button> <button id = "left" class = "buttons">LEFT</button> <button id = "right" class = "buttons">RIGHT</button> <button id = "shot" class = "buttons">SHOT</button> </div> <div id = "config"> <p>効果音: <input type="range" id = "volume-range" min="0" max="1" step="0.01"> <span id = "volume-value"></span> <button id = "volume-test">効果音のテスト</button> </p> <p><input type="checkbox" id = "hide-buttons"><label for="hide-buttons">スマホ用のボタンを非表示にする</label></p> <p>中心にある太陽の重力に引き込まれないように注意して敵を倒してください。</p> <p>方向転換しただけでは進行方向を変えることはできません。 また減速するときは逆方向に回頭してから加速する必要があります。操作が難しいゲームです。</p> <p>『スペースウォー!』(Spacewar!)は1962年、当時マサチューセッツ工科大学(MIT)の学生であったスティーブ・ラッセルを中心に、 DEC社のミニコンPDP-1上で稼動するデモンストレーションプログラムとして開発されました。世界初のシューティングゲームとされています。</p> </div> </div> <script src= "./index.js"></script> </body> </html> |
style.css
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
body { background-color: #000; color: #fff; } #container { width: 360px; } #canvas { display: block; } #ctrl-buttons { margin-top: -50px; height: 160px; position: relative; } .buttons { position: absolute; width: 160px; height: 60px; background-color: transparent; color: #fff; border-color: #fff; } #up { left: 100px; top: 0px; } #start { left: 100px; top: 200px; } #left { left: 10px; top: 70px; } #right { left: 190px; top: 70px; } #shot { left: 100px; top: 140px; } #config { margin-top: 50px; } #volume-range { width: 240px; vertical-align: middle; margin-bottom: 20px; } |
使用する画像
グローバル変数と定数
グローバル変数と定数を示します。
playerOuterPointsとplayerInnerPointsは当たり判定のときに使用します。プレイヤーを原点が中心になるように配置したときの点の位置です。この黄色い点で構成される部分の内部に弾丸が入ったときは弾丸が命中したことになり、wedgeの青い点で構成される部分の内部にneedleの青い点が入ったときは両者が衝突したことになります。
index.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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
// canvasのサイズ const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 360; const BULLET_SPEED = 4; // 弾丸の速度 const SUN_RADIUS = 24; // 太陽の半径 const playerSizes = [ // プレイヤーの幅と高さ {Width:26, Length:50}, {Width:14, Length:62}, ] const playerOuterPoints = [ [{X: -26, Y: -15},{X: -11, Y: -15},{X: 26,Y: -3},{X: 26,Y: 2},{X: -11,Y: 14},{X: -26,Y: 14}], [{X: -32, Y: -9},{X: -15, Y: -9},{X: -13, Y: -7},{X: 23, Y: -5},{X: 32, Y: -3},{X: 32, Y: 2},{X: 23, Y: 4},{X: -13, Y: 6},{X: -15, Y: 8},{X: -32, Y: 8}] ] const playerInnerPoints = [ [{X: -24, Y: -12},{X: -12,Y: -12},{X: 24,Y: -1},{X: 24,Y: 2},{X: -12,Y: 13},{X: -24,Y: 13}], [{X: -30,Y: -6},{X: -30,Y: 7},{X: -17,Y: -6},{X: -17,Y: 7},{X: 31,Y: 0},{X: 21,Y: 0},{X: 11,Y: 0},{X: 1,Y: 0},{X: -9,Y: 0}] ] // canvas要素とコンテキスト const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); // ボタン要素 const $start = document.getElementById('start'); const $up = document.getElementById('up'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); const $shot = document.getElementById('shot'); const ctrlButtons = [$up, $left, $right, $shot]; const $hideButtons = document.getElementById('hide-buttons'); // 効果音 const explodeSound = new Audio('./sounds/explode.mp3'); const shotSounds = [ new Audio('./sounds/shot.mp3'), // 連射時にも対応できるように同じ音のオブジェクトを複数生成する new Audio('./sounds/shot.mp3'), new Audio('./sounds/shot.mp3'), new Audio('./sounds/shot.mp3'), ]; const speedupSound = new Audio('./sounds/speedup.mp3'); // ボリューム調整用のレンジスライダーと数値を表示する要素 const $volumeRange = document.getElementById('volume-range'); const $volumeValue = document.getElementById('volume-value'); let volume = 0.3; // 設定されているボリューム // Playerオブジェクト(自機と敵) let player = null; let enemy = null; let isPlaying = false; // 現在プレイ中か? // 移動用のキーは押下されているか? let pressLeft = false; let pressRight = false; let pressUp = false; let fireballs = []; // 火球を格納する配列 |
Bulletクラスの定義
弾丸の状態を更新し描画するためにBulletクラスを定義します。
レーザーっぽく見せるために更新前の弾丸の座標を記憶しておき、これを利用してぼかしがついた直線を描画します。弾丸は自機と敵で色を変え、命中しなくてもLife回更新されたら消えてしまうようにします。
コンストラクタ
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Bullet{ constructor(x, y, vx, vy, type){ this.X = x; this.Y = y; this.HistoryX = []; this.HistoryY = []; this.VX = vx; this.VY = vy; this.IsDead = false; this.Life = 48; if(type == 0) this.Color = '#0ff'; else this.Color = '#f0f'; } } |
更新処理
更新の処理を示します。
更新ごとにLifeをデクリメントし、現在の座標をHistoryXYの先頭に追加し先頭の20個を取ります。これでレーザーが一定の長さを超えなくなります。canvasの外に出て行ったものやLifeが0になったオブジェクトには死亡フラグをセットします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Bullet{ Update(){ this.Life--; this.HistoryX.unshift(this.X); this.HistoryY.unshift(this.Y); if(this.HistoryX.length > 20){ this.HistoryX.length = 20; this.HistoryY.length = 20; } this.X += this.VX; this.Y += this.VY; if(this.Life <= 0 || this.X < 0 || this.X > CANVAS_WIDTH || this.Y < 0 || this.Y > CANVAS_HEIGHT) this.IsDead = true; } } |
描画処理
描画の処理を示します。
現在の座標とHistoryXYの最後に格納されている座標を結ぶ直線を描画します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Bullet{ Draw(){ if(this.IsDead || this.HistoryX.length == 0) return; const sx = this.HistoryX[this.HistoryX.length -1]; const sy = this.HistoryY[this.HistoryY.length -1]; ctx.beginPath(); ctx.moveTo(this.X, this.Y); ctx.lineTo(sx, sy); ctx.lineWidth = 2; ctx.strokeStyle = this.Color; ctx.shadowColor = this.Color; ctx.shadowBlur = 6; ctx.stroke(); // ぼかしをある程度濃くするために3回描画する ctx.stroke(); ctx.stroke(); ctx.shadowBlur = 0; } } |
Playerクラスの定義
自機と敵の状態を更新し描画するためみPlayerクラスを定義します。
Playerにはくさび形とニードル型の2つがあります。前者が自機、後者が敵機です。
コンストラクタと初期化
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 |
class Player { constructor(type){ this.Type = type; this.IsDead = false; this.AllowShot = true; // 射撃は可能か? 連射制限 this.Image = new Image(); if(type == 0){ // 初期の方向、座標、画像を設定 this.Angle = 0; this.CenterX = 36; this.CenterY = 36; this.Image.src = './images/wedge.png'; this.playerSize = playerSizes[0]; } else { this.Angle = Math.PI; this.CenterX = CANVAS_WIDTH - 36; this.CenterY = CANVAS_HEIGHT - 36; this.Image.src = './images/needle.png'; this.playerSize = playerSizes[1]; } this.VX = 0; this.VY = 0; this.Bullets = []; // 発射した弾丸を格納する配列 this.Score = 0; } } |
Init関数は次のゲームが開始されるまえにプレイヤーの状態を初期化するために呼び出されます。座標、方向、発射した弾丸、移動速度の初期化、死亡フラグのクリアをおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Player { Init(){ if(this.Type == 0){ this.Angle = 0; this.CenterX = 36; this.CenterY = 36; } else { this.Angle = Math.PI; this.CenterX = CANVAS_WIDTH - 36; this.CenterY = CANVAS_HEIGHT - 36; } this.IsDead = false; this.VX = 0; this.VY = 0; this.Bullets = []; } } |
方向転換と加速
プレイヤーの速度と方向を取得する処理を示します。GetAngle関数は -πからπまでの値を返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Player { GetSpeed(){ return Math.sqrt(Math.pow(this.VX, 2) + Math.pow(this.VY, 2)); } GetAngle(){ const angle = this.Angle % (Math.PI * 2); if(angle > Math.PI) return angle - Math.PI * 2; else if(angle < -Math.PI) return angle + Math.PI * 2; else return angle; } } |
プレイヤーの方向転換、加速時におこなわれる処理を示します。このゲームは方向転換しても加速しなければ進行方向はかわりません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Player { RotateL(){ this.Angle -= 0.04; } RotateR(){ this.Angle += 0.04; } Accelerate(){ if(this.IsDead) return false; this.VX += 0.5 * Math.cos(this.Angle); this.VY += 0.5 * Math.sin(this.Angle); return true; } } |
更新処理
更新処理を示します。
座標を速度分だけ変更し、canvas内から外に出た場合は反対側にワープさせます。そして弾丸についても更新処理をおこないます。そして死亡フラグがセットされた弾丸は配列のなかから取り除きます。
1 2 3 4 5 6 7 8 9 10 11 |
class Player { Update(){ this.CenterX += this.VX + CANVAS_WIDTH; this.CenterY += this.VY + CANVAS_HEIGHT; this.CenterX %= CANVAS_WIDTH; this.CenterY %= CANVAS_HEIGHT; this.Bullets.forEach(bullet => bullet.Update()); this.Bullets = this.Bullets.filter(bullet => !bullet.IsDead); } } |
描画処理
描画処理を示します。
弾丸を描画したあと自分自身を描画します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Player { Draw(){ if(this.IsDead) return; this.Bullets.forEach(bullet => bullet.Draw()); // 回転した状態で自機を描画する ctx.save(); ctx.translate(this.CenterX, this.CenterY); ctx.rotate(this.Angle); ctx.translate(-this.CenterX, -this.CenterY); ctx.drawImage(this.Image, this.CenterX - this.playerSize.Length / 2, this.CenterY - this.playerSize.Width / 2); ctx.restore(); } } |
弾丸の発射
弾丸を発射する処理を示します。
弾丸を発射したら0.1秒間は発射できないようにして連射制限をかけます。また死亡時やゲーム開始前は発射できないようにします。発射時は自機の中心を弾丸の初期位置とし、発射方向は自機の向き、初速はBULLET_SPEEDだけでなく自機の速度を加算します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Player { Shot(){ if(this.IsDead || !this.AllowShot || !isPlaying) return false; this.AllowShot = false; setTimeout(() => this.AllowShot = true, 100); const vx = this.VX + BULLET_SPEED * Math.cos(this.Angle); const vy = this.VY + BULLET_SPEED * Math.sin(this.Angle); this.Bullets.push(new Bullet(this.CenterX, this.CenterY, vx, vy, this.Type)); return true; } } |
Fireballクラスの定義
爆発の描画をするためにFireballクラスを定義します。
やっていることは 爆発のエフェクトをシミュレーションする とあまり変わりません。火球を大量生成するため、一度生成したイメージを再利用できるように配列のなかに格納しているのと、火球の色が複数ある点が異なるだけです。
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 46 47 48 49 50 51 52 53 54 |
class Fireball{ // 火球の種類は2つあるので配列も2つ用意する static Images = [ [],[] ]; constructor(x, y, type){ this.Type = type; this.X = x; this.Y = y; this.Radius = 1; this.IsDead = false; this.Life = 28; // 火球の寿命 } Update(){ // 火球の寿命が尽きるまで半径を大きくする this.Life--; if(this.Life > 0) this.Radius += 1; else this.IsDead = true; } Draw(){ if(this.IsDead) return; // イメージが存在しない場合は生成する if(Fireball.Images[this.Type][this.Life] == undefined){ const shadowBlur = 24; const $tempCanvas = document.createElement('canvas'); $tempCanvas.width = (this.Radius + shadowBlur) * 2; $tempCanvas.height = (this.Radius + shadowBlur) * 2; const tempCtx = $tempCanvas.getContext('2d'); tempCtx.shadowBlur = shadowBlur; if(this.Type == 0) tempCtx.shadowColor = 'rgb(0, 200, 200)'; else tempCtx.shadowColor = 'rgb(200, 0, 200)'; tempCtx.fillStyle = 'rgb(200, 200, 200)'; tempCtx.beginPath(); tempCtx.arc(this.Radius + shadowBlur, this.Radius + shadowBlur, this.Radius, 0, Math.PI * 2); tempCtx.fill(); const image = new Image(); image.src = $tempCanvas.toDataURL(); Fireball.Images[this.Type][this.Life] = image; } const image = Fireball.Images[this.Type][this.Life]; ctx.drawImage(image, this.X-image.width/2, this.Y-image.height/2); } } |
爆発を発生させる処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
async function explode(x, y, type){ explodeSound.volume = volume; explodeSound.currentTime = 0; explodeSound.play(); const fireballDelay = 10; // 次の火球が出現するまでの時間(ミリ秒) const fireballsCount = 8; // ひとつの爆発で出現する火球の数 const explosionRadius = 16; // 爆発の中心点と火球が出現する位置の距離の最大値 for(let i = 0; i < fireballsCount; i++){ const dx = Math.random() * explosionRadius * 2 - explosionRadius; const dy = Math.random() * explosionRadius * 2 - explosionRadius; fireballs.push(new Fireball(x + dx, y + dy, type)); await new Promise(resolve => setTimeout(() => resolve(''), fireballDelay)); } } |